diff --git a/apps/web/cypress/component/CheckboxOption.cy.tsx b/apps/web/cypress/component/CheckboxOption.cy.tsx index 02450a0..494448b 100644 --- a/apps/web/cypress/component/CheckboxOption.cy.tsx +++ b/apps/web/cypress/component/CheckboxOption.cy.tsx @@ -65,4 +65,76 @@ describe("CheckboxOption", () => { cy.get("input[type=checkbox]").should("not.be.checked").click(); cy.get("input[type=checkbox]").should("be.checked"); }); + + it("calls onChange with false when clicking while already checked", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Végétarien + , + ); + + cy.get("input[type=checkbox]").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", false); + }); + + it("merges the caller's className with the container layout, alongside is-selected", () => { + cy.mount( + {}} className="allergy-select__option"> + Végétarien + , + ); + cy.get("label") + .should("have.class", "allergy-select__option") + .and("not.have.class", "is-selected"); + + cy.mount( + {}} className="allergy-select__option"> + Végétarien + , + ); + cy.get("label").should("have.class", "allergy-select__option").and("have.class", "is-selected"); + }); + + it("has no className at all when the caller doesn't pass one", () => { + cy.mount( + {}}> + Végétarien + , + ); + // `[className, checked && "is-selected"].filter(Boolean).join(" ")` with + // both falsy collapses to "" — worth pinning down since a stray + // "false"/"null" string in `class` would be a real (if harmless-looking) + // regression. + cy.get("label").should("have.attr", "class", ""); + }); + + it("toggles when the click lands on the label text, not just the input itself", () => { + // The label wraps the input (native HTML forwards the click), which is + // what actually makes the whole row clickable — not just a tiny + // checkbox hitbox. This is real browser behavior, not something the + // component's own code implements, but it's exactly the contract the + // "selectable card" look (global.scss) relies on, so it's worth pinning + // down here rather than trusting it silently. + const onChange = cy.stub().as("onChange"); + cy.mount( + + Végétarien + , + ); + + cy.contains("label", "Végétarien").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", true); + }); + + it("marks the check-mark decoration as aria-hidden, so screen readers only announce the checkbox itself", () => { + cy.mount( + {}}> + Végétarien + , + ); + cy.get("span.check-mark").should("have.attr", "aria-hidden", "true"); + }); }); diff --git a/apps/web/cypress/component/RadioOption.cy.tsx b/apps/web/cypress/component/RadioOption.cy.tsx new file mode 100644 index 0000000..d80b6ce --- /dev/null +++ b/apps/web/cypress/component/RadioOption.cy.tsx @@ -0,0 +1,151 @@ +import { useState } from "react"; +import { RadioOption } from "../../src/components/ui/Radio"; + +// The `type="radio"` sibling of CheckboxOption.cy.tsx — same "selectable +// card" markup, but exercised for what actually differs about a radio +// input: the mandatory `name`/`value` pair and the mutually-exclusive +// group behavior that's the whole reason to reach for radio over checkbox. + +describe("RadioOption", () => { + it("renders its label content", () => { + cy.mount( + {}}> + Sombre + , + ); + + cy.contains("label", "Sombre").should("be.visible"); + }); + + it("sets the native input's name and value", () => { + cy.mount( + {}}> + Sombre + , + ); + + cy.get("input[type=radio]") + .should("have.attr", "name", "theme") + .and("have.attr", "value", "dark"); + }); + + it("reflects the checked prop on the native input, and the is-selected class", () => { + cy.mount( + {}}> + Sombre + , + ); + cy.get("input[type=radio]").should("not.be.checked"); + cy.get("label").should("not.have.class", "is-selected"); + + cy.mount( + {}}> + Sombre + , + ); + cy.get("input[type=radio]").should("be.checked"); + cy.get("label").should("have.class", "is-selected"); + }); + + it("calls onChange with its own value when clicked while unchecked", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Sombre + , + ); + + cy.get("input[type=radio]").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", "dark"); + }); + + it("does not fire onChange again when clicking a radio that's already checked", () => { + // Native radio inputs only emit a `change` event when their checked + // state actually flips — clicking an already-selected option in a + // group is a no-op, unlike a checkbox which always toggles. + const onChange = cy.stub().as("onChange"); + cy.mount( + + Sombre + , + ); + + cy.get("input[type=radio]").click(); + + cy.get("@onChange").should("not.have.been.called"); + }); + + it("toggles when the click lands on the label text, not just the input itself", () => { + const onChange = cy.stub().as("onChange"); + cy.mount( + + Sombre + , + ); + + cy.contains("label", "Sombre").click(); + + cy.get("@onChange").should("have.been.calledOnceWith", "dark"); + }); + + it("merges the caller's className with the container layout, alongside is-selected", () => { + cy.mount( + {}} + className="theme-select__option" + > + Sombre + , + ); + cy.get("label").should("have.class", "theme-select__option").and("have.class", "is-selected"); + }); + + it("marks the check-mark decoration as aria-hidden, so screen readers only announce the radio itself", () => { + cy.mount( + {}}> + Sombre + , + ); + cy.get("span.check-mark").should("have.attr", "aria-hidden", "true"); + }); + + it("behaves as a mutually-exclusive group when several options share the same name", () => { + // A stateful wrapper mirroring UserPreferencesPage's theme picker — the + // one real caller — mounting 3 RadioOptions that share `name="theme"` + // and one `value` of state between them. + function ThemeGroup() { + const [theme, setTheme] = useState<"system" | "light" | "dark">("system"); + return ( + <> + + Système + + + Clair + + + Sombre + + + ); + } + cy.mount(); + + cy.contains("label", "Système").should("have.class", "is-selected"); + cy.contains("label", "Clair").should("not.have.class", "is-selected"); + cy.contains("label", "Sombre").should("not.have.class", "is-selected"); + + cy.contains("label", "Sombre").click(); + + cy.contains("label", "Sombre").should("have.class", "is-selected"); + cy.contains("label", "Système").should("not.have.class", "is-selected"); + cy.contains("label", "Clair").should("not.have.class", "is-selected"); + // The native `name` grouping also keeps the browser's own radio + // semantics honest — only one input in the group can be `:checked`. + cy.get("input[type=radio]:checked").should("have.length", 1); + }); +});