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);
+ });
+});