From 04783d3f62ab3cf19a3110fe66e73fc0b5e9eaa5 Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Thu, 9 Oct 2025 14:57:51 -0600
Subject: [PATCH] Radio button and group component with storybook and testing
---
app/components/Checkbox.js | 4 +-
app/components/RadioButton.js | 148 +++++++
app/components/RadioGroup.js | 65 +++
app/forms/page.js | 103 +++--
stories/RadioButton.stories.js | 93 ++++
stories/RadioGroup.stories.js | 133 ++++++
.../unit/RadioButton.a11y.test.jsx | 231 ++++++++++
.../unit/RadioGroup.a11y.test.jsx | 317 +++++++++++++
.../RadioButton.integration.test.jsx | 367 +++++++++++++++
.../RadioGroup.integration.test.jsx | 419 ++++++++++++++++++
.../RadioButton.interactions.test.js | 126 ++++++
tests/storybook/RadioButton.storybook.test.js | 177 ++++++++
.../storybook/RadioGroup.interactions.test.js | 184 ++++++++
tests/storybook/RadioGroup.storybook.test.js | 253 +++++++++++
tests/unit/RadioButton.test.jsx | 236 ++++++++++
tests/unit/RadioGroup.test.jsx | 240 ++++++++++
16 files changed, 3053 insertions(+), 43 deletions(-)
create mode 100644 app/components/RadioButton.js
create mode 100644 app/components/RadioGroup.js
create mode 100644 stories/RadioButton.stories.js
create mode 100644 stories/RadioGroup.stories.js
create mode 100644 tests/accessibility/unit/RadioButton.a11y.test.jsx
create mode 100644 tests/accessibility/unit/RadioGroup.a11y.test.jsx
create mode 100644 tests/integration/RadioButton.integration.test.jsx
create mode 100644 tests/integration/RadioGroup.integration.test.jsx
create mode 100644 tests/storybook/RadioButton.interactions.test.js
create mode 100644 tests/storybook/RadioButton.storybook.test.js
create mode 100644 tests/storybook/RadioGroup.interactions.test.js
create mode 100644 tests/storybook/RadioGroup.storybook.test.js
create mode 100644 tests/unit/RadioButton.test.jsx
create mode 100644 tests/unit/RadioGroup.test.jsx
diff --git a/app/components/Checkbox.js b/app/components/Checkbox.js
index b5ad828..70a7672 100644
--- a/app/components/Checkbox.js
+++ b/app/components/Checkbox.js
@@ -69,9 +69,9 @@ const Checkbox = memo(
const conditionalHoverOutlineClass =
"hover:outline hover:outline-1 hover:outline-[var(--color-border-default-brand-primary)]";
- // Focus state for standard/unchecked with utility info color and specific blur/spread
+ // Focus state for standard/unchecked with brand primary color and specific blur/spread
const conditionalFocusClass =
- "focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-border-default-utility-info)]";
+ "focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-surface-inverse-brand-primary)]";
const handleToggle = (e) => {
if (disabled) return;
diff --git a/app/components/RadioButton.js b/app/components/RadioButton.js
new file mode 100644
index 0000000..0bc51bc
--- /dev/null
+++ b/app/components/RadioButton.js
@@ -0,0 +1,148 @@
+"use client";
+
+import React, { memo, useCallback } from "react";
+
+const RadioButton = ({
+ checked = false,
+ mode = "standard",
+ state = "default",
+ disabled = false,
+ label,
+ onChange,
+ id,
+ name,
+ value,
+ ariaLabel,
+ className = "",
+ ...props
+}) => {
+ const isInverse = mode === "inverse";
+
+ // Base tokens (using same design tokens as Checkbox)
+ const colorSurface = isInverse
+ ? "var(--color-surface-inverse-primary)"
+ : "var(--color-surface-default-primary)";
+ const colorContent = isInverse
+ ? "var(--color-content-inverse-primary)"
+ : "var(--color-content-default-primary)";
+ const colorBrand = isInverse
+ ? "var(--color-content-inverse-brand-primary)"
+ : "var(--color-content-default-brand-primary)";
+
+ // Visual container depending on state
+ const baseBox = `flex items-center justify-center shrink-0 w-[var(--measures-sizing-024)] h-[var(--measures-sizing-024)] rounded-[var(--measures-radius-medium)] transition-all duration-200 ease-in-out`;
+
+ const stateStyles = {
+ default: "",
+ hover: "",
+ focus: "",
+ };
+
+ // Background behavior:
+ // - Standard: background does not change on check; only dot appears
+ // - Inverse: transparent background, dot appears on check
+ const backgroundWhenChecked = isInverse
+ ? "var(--color-surface-default-transparent)"
+ : "var(--color-surface-default-primary)";
+
+ // Dot color for selected state
+ const dotColor = checked
+ ? isInverse
+ ? "var(--color-content-inverse-primary)"
+ : "var(--color-border-default-brand-primary)"
+ : "transparent";
+ const labelColor = colorContent;
+
+ const combinedBoxStyles = `${baseBox} ${stateStyles[state]}`;
+
+ // Force visible outline for standard / default / unchecked
+ const defaultOutlineClass = isInverse
+ ? "outline outline-1 outline-[var(--color-border-inverse-primary)]"
+ : "outline outline-1 outline-[var(--color-border-default-tertiary)]";
+
+ // Apply brand outline only on actual :hover
+ // Standard mode uses default brand primary, inverse mode uses inverse brand primary
+ const conditionalHoverOutlineClass = isInverse
+ ? "hover:outline hover:outline-1 hover:outline-[var(--color-border-inverse-brand-primary)]"
+ : "hover:outline hover:outline-1 hover:outline-[var(--color-border-default-brand-primary)]";
+
+ // Focus state for standard/unchecked with brand primary color and specific blur/spread
+ const conditionalFocusClass =
+ "focus:outline focus:outline-1 focus:outline-[var(--color-border-default-utility-info)] focus:shadow-[0_0_10px_1px_var(--color-surface-inverse-brand-primary)]";
+
+ // Generate unique ID for accessibility if not provided
+ const radioId = id || `radio-${Math.random().toString(36).substr(2, 9)}`;
+
+ const handleToggle = useCallback(
+ (e) => {
+ if (!disabled && onChange && !checked) {
+ onChange({ checked: true, value });
+ }
+ },
+ [disabled, onChange, checked, value]
+ );
+
+ return (
+
+ );
+};
+
+RadioButton.displayName = "RadioButton";
+
+export default memo(RadioButton);
diff --git a/app/components/RadioGroup.js b/app/components/RadioGroup.js
new file mode 100644
index 0000000..dbb0ed8
--- /dev/null
+++ b/app/components/RadioGroup.js
@@ -0,0 +1,65 @@
+"use client";
+
+import React, { memo, useCallback } from "react";
+import RadioButton from "./RadioButton";
+
+const RadioGroup = ({
+ name,
+ value,
+ onChange,
+ mode = "standard",
+ state = "default",
+ disabled = false,
+ options = [],
+ className = "",
+ ...props
+}) => {
+ // Generate unique ID for accessibility if not provided
+ const groupId =
+ name || `radio-group-${Math.random().toString(36).substr(2, 9)}`;
+
+ const handleChange = useCallback(
+ (optionValue) => {
+ if (!disabled && onChange) {
+ onChange({ value: optionValue });
+ }
+ },
+ [disabled, onChange]
+ );
+
+ return (
+
+ {options.map((option, index) => {
+ const isSelected = value === option.value;
+
+ return (
+ {
+ if (checked) {
+ handleChange(option.value);
+ }
+ }}
+ />
+ );
+ })}
+
+ );
+};
+
+RadioGroup.displayName = "RadioGroup";
+
+export default memo(RadioGroup);
diff --git a/app/forms/page.js b/app/forms/page.js
index d4e9d52..18b5153 100644
--- a/app/forms/page.js
+++ b/app/forms/page.js
@@ -2,28 +2,22 @@
import React, { useState } from "react";
import Checkbox from "../components/Checkbox";
+import RadioButton from "../components/RadioButton";
+import RadioGroup from "../components/RadioGroup";
export default function FormsPlayground() {
const [standardChecked, setStandardChecked] = useState(false);
const [inverseChecked, setInverseChecked] = useState(true);
-
- const variations = [
- { title: "Standard / Default", mode: "standard", state: "default" },
- { title: "Standard / Hover", mode: "standard", state: "hover" },
- { title: "Standard / Focus", mode: "standard", state: "focus" },
- { title: "Inverse / Default", mode: "inverse", state: "default" },
- { title: "Inverse / Hover", mode: "inverse", state: "hover" },
- { title: "Inverse / Focus", mode: "inverse", state: "focus" },
- ];
+ const [radioValue, setRadioValue] = useState("option1");
+ const [standardRadioValue, setStandardRadioValue] = useState("option1");
+ const [inverseRadioValue, setInverseRadioValue] = useState("option2");
return (
-
- Forms Playground — Checkbox
-
+
Forms Playground
- Interactive examples
+ Checkbox Examples
- Static states
-
- {variations.map((v) => (
-
-
{v.title}
-
-
- {}}
- />
- {}}
- />
-
-
-
- ))}
+
Radio Button Examples
+
+ checked && setRadioValue("option1")}
+ />
+ checked && setRadioValue("option2")}
+ />
+
+
+
+
+ Radio Group
+
+
+
Standard Mode
+ setStandardRadioValue(value)}
+ options={[
+ { value: "option1", label: "Option 1" },
+ { value: "option2", label: "Option 2" },
+ { value: "option3", label: "Option 3" },
+ ]}
+ />
+
+
+
+
Inverse Mode
+ setInverseRadioValue(value)}
+ options={[
+ { value: "option1", label: "Option 1" },
+ { value: "option2", label: "Option 2" },
+ { value: "option3", label: "Option 3" },
+ ]}
+ />
+
diff --git a/stories/RadioButton.stories.js b/stories/RadioButton.stories.js
new file mode 100644
index 0000000..532fdf0
--- /dev/null
+++ b/stories/RadioButton.stories.js
@@ -0,0 +1,93 @@
+import RadioButton from "../app/components/RadioButton";
+import {
+ DefaultInteraction,
+ CheckedInteraction,
+ StandardInteraction,
+ InverseInteraction,
+ KeyboardInteraction,
+ AccessibilityInteraction,
+ FormIntegration,
+} from "../tests/storybook/RadioButton.interactions.test";
+
+const meta = {
+ title: "Forms/RadioButton",
+ component: RadioButton,
+ parameters: {
+ layout: "centered",
+ backgrounds: {
+ default: "dark",
+ values: [{ name: "dark", value: "black" }],
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ checked: { control: "boolean" },
+ mode: {
+ control: { type: "select" },
+ options: ["standard", "inverse"],
+ },
+ state: {
+ control: { type: "select" },
+ options: ["default", "hover", "focus"],
+ },
+ label: { control: "text" },
+ },
+ args: {
+ checked: false,
+ mode: "standard",
+ state: "default",
+ label: "Radio Button Label",
+ },
+};
+
+export default meta;
+
+export const Default = {
+ args: {
+ checked: false,
+ mode: "standard",
+ state: "default",
+ label: "Default radio button",
+ },
+ play: DefaultInteraction.play,
+};
+
+export const Checked = {
+ args: {
+ checked: true,
+ mode: "standard",
+ state: "default",
+ label: "Checked radio button",
+ },
+ play: CheckedInteraction.play,
+};
+
+export const Standard = {
+ render: () => (
+
+
+
Standard Mode
+
+
+
+
+
+
+ ),
+ play: StandardInteraction.play,
+};
+
+export const Inverse = {
+ render: () => (
+
+ ),
+ play: InverseInteraction.play,
+};
diff --git a/stories/RadioGroup.stories.js b/stories/RadioGroup.stories.js
new file mode 100644
index 0000000..936446f
--- /dev/null
+++ b/stories/RadioGroup.stories.js
@@ -0,0 +1,133 @@
+import React from "react";
+import RadioGroup from "../app/components/RadioGroup";
+import {
+ DefaultInteraction,
+ StandardInteraction,
+ InverseInteraction,
+ InteractiveInteraction,
+ KeyboardInteraction,
+ AccessibilityInteraction,
+ SingleSelectionInteraction,
+ FormIntegration,
+} from "../tests/storybook/RadioGroup.interactions.test";
+
+const meta = {
+ title: "Forms/RadioGroup",
+ component: RadioGroup,
+ parameters: {
+ layout: "centered",
+ backgrounds: {
+ default: "dark",
+ values: [{ name: "dark", value: "black" }],
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ mode: {
+ control: { type: "select" },
+ options: ["standard", "inverse"],
+ },
+ state: {
+ control: { type: "select" },
+ options: ["default", "hover", "focus"],
+ },
+ value: { control: "text" },
+ },
+ args: {
+ mode: "standard",
+ state: "default",
+ value: "option1",
+ options: [
+ { value: "option1", label: "Option 1" },
+ { value: "option2", label: "Option 2" },
+ { value: "option3", label: "Option 3" },
+ ],
+ },
+};
+
+export default meta;
+
+export const Default = {
+ args: {
+ mode: "standard",
+ state: "default",
+ value: "option1",
+ options: [
+ { value: "option1", label: "Option 1" },
+ { value: "option2", label: "Option 2" },
+ { value: "option3", label: "Option 3" },
+ ],
+ },
+ play: DefaultInteraction.play,
+};
+
+export const Standard = {
+ render: () => (
+
+
+
Standard Mode
+ {}}
+ />
+
+
+ ),
+ play: StandardInteraction.play,
+};
+
+export const Inverse = {
+ render: () => (
+
+
+
Inverse Mode
+ {}}
+ />
+
+
+ ),
+ play: InverseInteraction.play,
+};
+
+export const Interactive = {
+ render: () => {
+ const [value, setValue] = React.useState("option1");
+
+ return (
+
+
+
Interactive Example
+
Selected: {value}
+
setValue(value)}
+ />
+
+
+ );
+ },
+ play: InteractiveInteraction.play,
+};
diff --git a/tests/accessibility/unit/RadioButton.a11y.test.jsx b/tests/accessibility/unit/RadioButton.a11y.test.jsx
new file mode 100644
index 0000000..0acb0e0
--- /dev/null
+++ b/tests/accessibility/unit/RadioButton.a11y.test.jsx
@@ -0,0 +1,231 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import RadioButton from "../../../app/components/RadioButton";
+
+describe("RadioButton Accessibility", () => {
+ it("has proper ARIA attributes", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("role", "radio");
+ expect(radioButton).toHaveAttribute("aria-checked", "false");
+ expect(radioButton).toHaveAttribute("tabIndex", "0");
+ });
+
+ it("updates aria-checked when checked state changes", () => {
+ const { rerender } = render(
+
+ );
+
+ let radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("aria-checked", "false");
+
+ rerender();
+
+ radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("associates label with radio button", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ const labelId = radioButton.getAttribute("aria-labelledby");
+ expect(labelId).toBeTruthy();
+
+ const labelElement = document.getElementById(labelId);
+ expect(labelElement).toHaveTextContent("Accessible Radio");
+ });
+
+ it("uses aria-label when provided", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("aria-label", "Custom Aria Label");
+ expect(radioButton).not.toHaveAttribute("aria-labelledby");
+ });
+
+ it("prioritizes aria-label over aria-labelledby", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("aria-label", "Hidden Aria Label");
+ expect(radioButton).not.toHaveAttribute("aria-labelledby");
+ });
+
+ it("is keyboard accessible", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ radioButton.focus();
+
+ expect(radioButton).toHaveFocus();
+
+ await user.keyboard(" ");
+ expect(handleChange).toHaveBeenCalled();
+ });
+
+ it("handles Enter key activation", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ await user.click(radioButton); // Focus the element first
+ await user.keyboard("Enter");
+
+ expect(handleChange).toHaveBeenCalled();
+ });
+
+ it("handles Space key activation", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ radioButton.focus();
+ await user.keyboard(" ");
+
+ expect(handleChange).toHaveBeenCalled();
+ });
+
+ it("ignores other keys", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ radioButton.focus();
+ await user.keyboard("a");
+ await user.keyboard("Tab");
+ await user.keyboard("Escape");
+
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+
+ it("has proper tab order", () => {
+ render(
+
+
+
+
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons.forEach((button) => {
+ expect(button).toHaveAttribute("tabIndex", "0");
+ });
+ });
+
+ it("generates unique IDs for accessibility", () => {
+ render(
+
+
+
+
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+ const ids = radioButtons.map((button) => button.id);
+ const uniqueIds = new Set(ids);
+
+ expect(uniqueIds.size).toBe(3);
+ expect(ids.every((id) => id.startsWith("radio-"))).toBe(true);
+ });
+
+ it("uses provided ID for accessibility", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("id", "custom-radio-id");
+ });
+
+ it("has accessible name from label", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ const accessibleName = radioButton.getAttribute("aria-labelledby");
+ const labelElement = document.getElementById(accessibleName);
+
+ expect(labelElement).toHaveTextContent("Accessible Name Radio");
+ });
+
+ it("has accessible name from aria-label", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("aria-label", "Aria Label Name");
+ });
+
+ it("maintains focus management", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ const { rerender } = render(
+
+ );
+
+ const radioButton = screen.getByRole("radio");
+ radioButton.focus();
+ expect(radioButton).toHaveFocus();
+
+ // Change checked state
+ rerender(
+
+ );
+
+ // Should still be focusable
+ expect(radioButton).toHaveAttribute("tabIndex", "0");
+ });
+
+ it("has proper role and state", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("role", "radio");
+ expect(radioButton).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("supports screen reader navigation", () => {
+ render(
+
+
+
+
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+
+ // All should be in tab order
+ radioButtons.forEach((button) => {
+ expect(button).toHaveAttribute("tabIndex", "0");
+ expect(button).toHaveAttribute("role", "radio");
+ });
+ });
+
+ it("has proper form association", () => {
+ render(
+
+ );
+
+ const hiddenInput = screen.getByDisplayValue("test-value");
+ expect(hiddenInput).toHaveAttribute("type", "radio");
+ expect(hiddenInput).toHaveAttribute("name", "test-radio");
+ expect(hiddenInput).toHaveAttribute("value", "test-value");
+ });
+});
diff --git a/tests/accessibility/unit/RadioGroup.a11y.test.jsx b/tests/accessibility/unit/RadioGroup.a11y.test.jsx
new file mode 100644
index 0000000..a4cbfaf
--- /dev/null
+++ b/tests/accessibility/unit/RadioGroup.a11y.test.jsx
@@ -0,0 +1,317 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import RadioGroup from "../../../app/components/RadioGroup";
+
+describe("RadioGroup Accessibility", () => {
+ const defaultOptions = [
+ { value: "option1", label: "Option 1" },
+ { value: "option2", label: "Option 2" },
+ { value: "option3", label: "Option 3" },
+ ];
+
+ it("has proper radiogroup role", () => {
+ render();
+
+ const radioGroup = screen.getByRole("radiogroup");
+ expect(radioGroup).toBeInTheDocument();
+ });
+
+ it("has proper ARIA attributes on radiogroup", () => {
+ render(
+
+ );
+
+ const radioGroup = screen.getByRole("radiogroup");
+ expect(radioGroup).toHaveAttribute("aria-label", "Test Radio Group");
+ });
+
+ it("has proper radio button roles", () => {
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons).toHaveLength(3);
+
+ radioButtons.forEach((button) => {
+ expect(button).toHaveAttribute("role", "radio");
+ expect(button).toHaveAttribute("aria-checked");
+ });
+ });
+
+ it("shows correct selection state", () => {
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
+ expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+ });
+
+ it("updates selection state correctly", () => {
+ const { rerender } = render(
+
+ );
+
+ let radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+
+ rerender();
+
+ radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ expect(radioButtons[2]).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("associates labels with radio buttons", () => {
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons.forEach((button, index) => {
+ const labelId = button.getAttribute("aria-labelledby");
+ expect(labelId).toBeTruthy();
+
+ const labelElement = document.getElementById(labelId);
+ expect(labelElement).toHaveTextContent(`Option ${index + 1}`);
+ });
+ });
+
+ it("uses aria-label when provided in options", () => {
+ const optionsWithAria = [
+ { value: "option1", label: "Option 1", ariaLabel: "First Option" },
+ { value: "option2", label: "Option 2", ariaLabel: "Second Option" },
+ ];
+
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons[0]).toHaveAttribute("aria-label", "First Option");
+ expect(radioButtons[1]).toHaveAttribute("aria-label", "Second Option");
+ });
+
+ it("is keyboard accessible", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+
+ // Focus first radio button
+ radioButtons[0].focus();
+ expect(radioButtons[0]).toHaveFocus();
+
+ // Navigate to second option
+ radioButtons[1].focus();
+ expect(radioButtons[1]).toHaveFocus();
+
+ // Activate with Space
+ await user.keyboard(" ");
+ expect(handleChange).toHaveBeenCalledWith({ value: "option2" });
+ });
+
+ it("handles Enter key activation", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+ await user.click(radioButtons[2]); // Focus the element first
+ await user.keyboard("Enter");
+
+ expect(handleChange).toHaveBeenCalledWith({ value: "option3" });
+ });
+
+ it("handles Space key activation", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons[1].focus();
+ await user.keyboard(" ");
+
+ expect(handleChange).toHaveBeenCalledWith({ value: "option2" });
+ });
+
+ it("ignores other keys", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons[1].focus();
+
+ await user.keyboard("a");
+ await user.keyboard("Tab");
+ await user.keyboard("Escape");
+
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+
+ it("has proper tab order", () => {
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons.forEach((button) => {
+ expect(button).toHaveAttribute("tabIndex", "0");
+ });
+ });
+
+ it("generates unique IDs for accessibility", () => {
+ render(
+
+
+
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+ const ids = radioButtons.map((button) => button.id);
+ const uniqueIds = new Set(ids);
+
+ // Should have unique IDs
+ expect(uniqueIds.size).toBe(6);
+ });
+
+ it("uses provided name for form association", () => {
+ render();
+
+ const hiddenInputs = screen.getAllByDisplayValue("option1");
+ hiddenInputs.forEach((input) => {
+ expect(input).toHaveAttribute("name", "test-group");
+ });
+ });
+
+ it("has proper form association", () => {
+ render(
+
+ );
+
+ const hiddenInputs = screen.getAllByDisplayValue("option1");
+ expect(hiddenInputs[0]).toHaveAttribute("name", "test-group");
+ expect(hiddenInputs[0]).toHaveAttribute("value", "option1");
+ expect(hiddenInputs[0]).not.toBeChecked();
+
+ const option2Inputs = screen.getAllByDisplayValue("option2");
+ expect(option2Inputs[0]).toHaveAttribute("name", "test-group");
+ expect(option2Inputs[0]).toHaveAttribute("value", "option2");
+ expect(option2Inputs[0]).toBeChecked();
+ });
+
+ it("maintains focus management", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ const { rerender } = render(
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons[1].focus();
+ expect(radioButtons[1]).toHaveFocus();
+
+ // Change selection
+ rerender(
+
+ );
+
+ // Should still be focusable
+ expect(radioButtons[1]).toHaveAttribute("tabIndex", "0");
+ });
+
+ it("supports screen reader navigation", () => {
+ render();
+
+ const radioGroup = screen.getByRole("radiogroup");
+ const radioButtons = screen.getAllByRole("radio");
+
+ // RadioGroup should be present
+ expect(radioGroup).toBeInTheDocument();
+
+ // All radio buttons should be in tab order
+ radioButtons.forEach((button) => {
+ expect(button).toHaveAttribute("tabIndex", "0");
+ expect(button).toHaveAttribute("role", "radio");
+ });
+ });
+
+ it("handles empty options gracefully", () => {
+ render();
+
+ const radioGroup = screen.getByRole("radiogroup");
+ expect(radioGroup).toBeInTheDocument();
+
+ const radioButtons = screen.queryAllByRole("radio");
+ expect(radioButtons).toHaveLength(0);
+ });
+
+ it("has proper accessible names", () => {
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons.forEach((button, index) => {
+ const labelId = button.getAttribute("aria-labelledby");
+ const labelElement = document.getElementById(labelId);
+ expect(labelElement).toHaveTextContent(`Option ${index + 1}`);
+ });
+ });
+
+ it("maintains single selection behavior", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+
+ // Click option 2 directly
+ await user.click(radioButtons[1]);
+
+ expect(handleChange).toHaveBeenCalledWith({ value: "option2" });
+
+ // Only one should be selected at a time
+ expect(handleChange).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/integration/RadioButton.integration.test.jsx b/tests/integration/RadioButton.integration.test.jsx
new file mode 100644
index 0000000..e21ced7
--- /dev/null
+++ b/tests/integration/RadioButton.integration.test.jsx
@@ -0,0 +1,367 @@
+import React, { useState } from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import RadioButton from "../../app/components/RadioButton";
+
+describe("RadioButton Integration", () => {
+ it("works in form context", async () => {
+ const user = userEvent.setup();
+ const handleSubmit = vi.fn();
+
+ function TestForm() {
+ const [value, setValue] = useState("option1");
+
+ return (
+
+ );
+ }
+
+ render();
+
+ const option1 = screen.getByText("Option 1").closest("label");
+ const option2 = screen.getByText("Option 2").closest("label");
+ const submitButton = screen.getByRole("button");
+
+ // Initially option1 should be selected
+ expect(screen.getByDisplayValue("option1")).toBeChecked();
+ expect(screen.getByDisplayValue("option2")).not.toBeChecked();
+
+ // Click option2
+ await user.click(option2);
+ expect(screen.getByDisplayValue("option2")).toBeChecked();
+ expect(screen.getByDisplayValue("option1")).not.toBeChecked();
+
+ // Submit form
+ await user.click(submitButton);
+ expect(handleSubmit).toHaveBeenCalled();
+ });
+
+ it("handles keyboard navigation", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ function KeyboardForm() {
+ const [value, setValue] = useState("option1");
+
+ return (
+
+ checked && setValue("option1")}
+ />
+ checked && setValue("option2")}
+ />
+
+ );
+ }
+
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+
+ // Focus first radio button
+ radioButtons[0].focus();
+ expect(radioButtons[0]).toHaveFocus();
+
+ // Navigate to second radio button
+ await user.tab();
+ expect(radioButtons[1]).toHaveFocus();
+
+ // Activate with Space
+ await user.keyboard(" ");
+ expect(screen.getByDisplayValue("option2")).toBeChecked();
+ });
+
+ it("handles mode switching", async () => {
+ function ModeSwitchForm() {
+ const [mode, setMode] = useState("standard");
+ const [value, setValue] = useState("option1");
+
+ return (
+
+
+ checked && setValue("option1")}
+ />
+
+ );
+ }
+
+ const user = userEvent.setup();
+ render();
+
+ const toggleButton = screen.getByRole("button");
+ const radioButton = screen.getByRole("radio");
+
+ // Initially standard mode
+ expect(radioButton).toHaveClass(
+ "outline-[var(--color-border-default-tertiary)]"
+ );
+
+ // Switch to inverse mode
+ await user.click(toggleButton);
+ expect(radioButton).toHaveClass(
+ "outline-[var(--color-border-inverse-primary)]"
+ );
+ });
+
+ it("maintains state across re-renders", () => {
+ function StateForm() {
+ const [value, setValue] = useState("option1");
+ const [count, setCount] = useState(0);
+
+ return (
+
+
+ checked && setValue("option1")}
+ />
+
+ );
+ }
+
+ const user = userEvent.setup();
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ const reRenderButton = screen.getByRole("button");
+
+ // Should be checked initially
+ expect(radioButton).toHaveAttribute("aria-checked", "true");
+
+ // Re-render should maintain state
+ user.click(reRenderButton);
+ expect(radioButton).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("works with multiple radio groups", async () => {
+ function MultiGroupForm() {
+ const [group1Value, setGroup1Value] = useState("option1");
+ const [group2Value, setGroup2Value] = useState("option1");
+
+ return (
+
+
+
Group 1
+ checked && setGroup1Value("option1")}
+ />
+ checked && setGroup1Value("option2")}
+ />
+
+
+
Group 2
+ checked && setGroup2Value("option1")}
+ />
+ checked && setGroup2Value("option2")}
+ />
+
+
+ );
+ }
+
+ const user = userEvent.setup();
+ render();
+
+ // Both groups should work independently
+ const group1OptionB = screen.getByText("Option B").closest("label");
+ const group2OptionY = screen.getByText("Option Y").closest("label");
+
+ await user.click(group1OptionB);
+ await user.click(group2OptionY);
+
+ const group1Inputs = screen.getAllByDisplayValue("option2").filter(
+ input => input.getAttribute("name") === "group1"
+ );
+ const group2Inputs = screen.getAllByDisplayValue("option2").filter(
+ input => input.getAttribute("name") === "group2"
+ );
+
+ expect(group1Inputs[0]).toBeChecked();
+ expect(group2Inputs[0]).toBeChecked();
+ });
+
+ it("handles controlled and uncontrolled scenarios", async () => {
+ function ControlledForm() {
+ const [controlledValue, setControlledValue] = useState("option1");
+ const [uncontrolledValue, setUncontrolledValue] = useState("option1");
+
+ return (
+
+
+
Controlled
+
+ checked && setControlledValue("option1")
+ }
+ />
+
+ checked && setControlledValue("option2")
+ }
+ />
+
+
+
Uncontrolled
+
+ checked && setUncontrolledValue("option1")
+ }
+ />
+
+ checked && setUncontrolledValue("option2")
+ }
+ />
+
+
+ );
+ }
+
+ const user = userEvent.setup();
+ render();
+
+ // Both should work the same way
+ const controlledOption2 = screen
+ .getByText("Controlled Option 2")
+ .closest("label");
+ const uncontrolledOption2 = screen
+ .getByText("Uncontrolled Option 2")
+ .closest("label");
+
+ await user.click(controlledOption2);
+ await user.click(uncontrolledOption2);
+
+ const controlledInputs = screen.getAllByDisplayValue("option2").filter(
+ input => input.getAttribute("name") === "controlled"
+ );
+ const uncontrolledInputs = screen.getAllByDisplayValue("option2").filter(
+ input => input.getAttribute("name") === "uncontrolled"
+ );
+
+ expect(controlledInputs[0]).toBeChecked();
+ expect(uncontrolledInputs[0]).toBeChecked();
+ });
+
+ it("handles accessibility in complex forms", () => {
+ function AccessibleForm() {
+ const [value, setValue] = useState("option1");
+
+ return (
+
+ );
+ }
+
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+
+ // Should have proper accessibility attributes
+ radioButtons.forEach((button) => {
+ expect(button).toHaveAttribute("role", "radio");
+ expect(button).toHaveAttribute("aria-checked");
+ expect(button).toHaveAttribute("tabIndex", "0");
+ });
+
+ // Should have aria-labels
+ expect(radioButtons[0]).toHaveAttribute("aria-label", "First option");
+ expect(radioButtons[1]).toHaveAttribute("aria-label", "Second option");
+ });
+});
diff --git a/tests/integration/RadioGroup.integration.test.jsx b/tests/integration/RadioGroup.integration.test.jsx
new file mode 100644
index 0000000..c17f2a7
--- /dev/null
+++ b/tests/integration/RadioGroup.integration.test.jsx
@@ -0,0 +1,419 @@
+import React, { useState } from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import RadioGroup from "../../app/components/RadioGroup";
+
+describe("RadioGroup Integration", () => {
+ const defaultOptions = [
+ { value: "option1", label: "Option 1" },
+ { value: "option2", label: "Option 2" },
+ { value: "option3", label: "Option 3" },
+ ];
+
+ it("works in form context", async () => {
+ const user = userEvent.setup();
+ const handleSubmit = vi.fn();
+
+ function TestForm() {
+ const [value, setValue] = useState("option1");
+
+ return (
+
+ );
+ }
+
+ render();
+
+ const option2 = screen.getByText("Option 2").closest("label");
+ const submitButton = screen.getByRole("button");
+
+ // Initially option1 should be selected
+ expect(screen.getByDisplayValue("option1")).toBeChecked();
+ expect(screen.getByDisplayValue("option2")).not.toBeChecked();
+
+ // Click option2
+ await user.click(option2);
+ expect(screen.getByDisplayValue("option2")).toBeChecked();
+ expect(screen.getByDisplayValue("option1")).not.toBeChecked();
+
+ // Submit form
+ await user.click(submitButton);
+ expect(handleSubmit).toHaveBeenCalled();
+ });
+
+ it("handles keyboard navigation", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ function KeyboardForm() {
+ const [value, setValue] = useState("option1");
+
+ return (
+ setValue(value)}
+ />
+ );
+ }
+
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+
+ // Focus first radio button
+ radioButtons[0].focus();
+ expect(radioButtons[0]).toHaveFocus();
+
+ // Navigate to second radio button
+ await user.tab();
+ expect(radioButtons[1]).toHaveFocus();
+
+ // Activate with Space
+ await user.keyboard(" ");
+ expect(screen.getByDisplayValue("option2")).toBeChecked();
+ });
+
+ it("handles mode switching", async () => {
+ function ModeSwitchForm() {
+ const [mode, setMode] = useState("standard");
+ const [value, setValue] = useState("option1");
+
+ return (
+
+
+ setValue(value)}
+ />
+
+ );
+ }
+
+ const user = userEvent.setup();
+ render();
+
+ const toggleButton = screen.getByRole("button");
+ const radioButtons = screen.getAllByRole("radio");
+
+ // Initially standard mode
+ radioButtons.forEach(button => {
+ expect(button).toHaveClass("outline-[var(--color-border-default-tertiary)]");
+ });
+
+ // Switch to inverse mode
+ await user.click(toggleButton);
+ radioButtons.forEach(button => {
+ expect(button).toHaveClass("outline-[var(--color-border-inverse-primary)]");
+ });
+ });
+
+ it("maintains state across re-renders", () => {
+ function StateForm() {
+ const [value, setValue] = useState("option1");
+ const [count, setCount] = useState(0);
+
+ return (
+
+
+ setValue(value)}
+ />
+
+ );
+ }
+
+ const user = userEvent.setup();
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ const reRenderButton = screen.getByRole("button");
+
+ // Should be checked initially
+ expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+
+ // Re-render should maintain state
+ user.click(reRenderButton);
+ expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("works with multiple radio groups", async () => {
+ function MultiGroupForm() {
+ const [group1Value, setGroup1Value] = useState("option1");
+ const [group2Value, setGroup2Value] = useState("option1");
+
+ return (
+
+
+
Group 1
+ setGroup1Value(value)}
+ />
+
+
+
Group 2
+ setGroup2Value(value)}
+ />
+
+
+ );
+ }
+
+ const user = userEvent.setup();
+ render();
+
+ // Both groups should work independently
+ // Find the Option 2 in group1 by filtering getAllByDisplayValue by name
+ const group1Option2Input = screen.getAllByDisplayValue("option2").find(
+ input => input.getAttribute("name") === "group1"
+ );
+ const group1Option2 = group1Option2Input.closest("label");
+
+ // Find the Option 3 in group2 by filtering getAllByDisplayValue by name
+ const group2Option3Input = screen.getAllByDisplayValue("option3").find(
+ input => input.getAttribute("name") === "group2"
+ );
+ const group2Option3 = group2Option3Input.closest("label");
+
+ await user.click(group1Option2);
+ await user.click(group2Option3);
+
+ const group1Inputs = screen.getAllByDisplayValue("option2").filter(
+ input => input.getAttribute("name") === "group1"
+ );
+ const group2Inputs = screen.getAllByDisplayValue("option3").filter(
+ input => input.getAttribute("name") === "group2"
+ );
+
+ expect(group1Inputs[0]).toBeChecked();
+ expect(group2Inputs[0]).toBeChecked();
+ });
+
+ it("handles controlled and uncontrolled scenarios", async () => {
+ function ControlledForm() {
+ const [controlledValue, setControlledValue] = useState("option1");
+ const [uncontrolledValue, setUncontrolledValue] = useState("option1");
+
+ return (
+
+
+
Controlled
+ setControlledValue(value)}
+ />
+
+
+
Uncontrolled
+ setUncontrolledValue(value)}
+ />
+
+
+ );
+ }
+
+ const user = userEvent.setup();
+ render();
+
+ // Both should work the same way
+ // Find the Option 2 in controlled group by filtering getAllByDisplayValue by name
+ const controlledOption2Input = screen.getAllByDisplayValue("option2").find(
+ input => input.getAttribute("name") === "controlled"
+ );
+ const controlledOption2 = controlledOption2Input.closest("label");
+
+ // Find the Option 2 in uncontrolled group by filtering getAllByDisplayValue by name
+ const uncontrolledOption2Input = screen.getAllByDisplayValue("option2").find(
+ input => input.getAttribute("name") === "uncontrolled"
+ );
+ const uncontrolledOption2 = uncontrolledOption2Input.closest("label");
+
+ await user.click(controlledOption2);
+ await user.click(uncontrolledOption2);
+
+ const controlledInputs = screen.getAllByDisplayValue("option2").filter(
+ input => input.getAttribute("name") === "controlled"
+ );
+ const uncontrolledInputs = screen.getAllByDisplayValue("option2").filter(
+ input => input.getAttribute("name") === "uncontrolled"
+ );
+
+ expect(controlledInputs[0]).toBeChecked();
+ expect(uncontrolledInputs[0]).toBeChecked();
+ });
+
+ it("handles accessibility in complex forms", () => {
+ function AccessibleForm() {
+ const [value, setValue] = useState("option1");
+
+ const accessibleOptions = [
+ { value: "option1", label: "Option 1", ariaLabel: "First option" },
+ { value: "option2", label: "Option 2", ariaLabel: "Second option" },
+ { value: "option3", label: "Option 3", ariaLabel: "Third option" },
+ ];
+
+ return (
+
+ );
+ }
+
+ render();
+
+ const radioGroup = screen.getByRole("radiogroup");
+ const radioButtons = screen.getAllByRole("radio");
+
+ // Should have proper accessibility attributes
+ expect(radioGroup).toHaveAttribute("aria-label", "Accessible radio group");
+
+ radioButtons.forEach(button => {
+ expect(button).toHaveAttribute("role", "radio");
+ expect(button).toHaveAttribute("aria-checked");
+ expect(button).toHaveAttribute("tabIndex", "0");
+ });
+
+ // Should have aria-labels
+ expect(radioButtons[0]).toHaveAttribute("aria-label", "First option");
+ expect(radioButtons[1]).toHaveAttribute("aria-label", "Second option");
+ expect(radioButtons[2]).toHaveAttribute("aria-label", "Third option");
+ });
+
+ it("handles dynamic options", async () => {
+ function DynamicForm() {
+ const [value, setValue] = useState("option1");
+ const [options, setOptions] = useState(defaultOptions);
+
+ return (
+
+
+ setValue(value)}
+ />
+
+ );
+ }
+
+ const user = userEvent.setup();
+ render();
+
+ const addButton = screen.getByRole("button");
+
+ // Initially 3 options
+ expect(screen.getAllByRole("radio")).toHaveLength(3);
+
+ // Add option
+ await user.click(addButton);
+ expect(screen.getAllByRole("radio")).toHaveLength(4);
+ expect(screen.getByText("Option 4")).toBeInTheDocument();
+ });
+
+ it("handles empty options gracefully", () => {
+ function EmptyForm() {
+ const [value, setValue] = useState("");
+
+ return (
+ setValue(value)}
+ />
+ );
+ }
+
+ render();
+
+ const radioGroup = screen.getByRole("radiogroup");
+ expect(radioGroup).toBeInTheDocument();
+ expect(screen.queryAllByRole("radio")).toHaveLength(0);
+ });
+
+ it("maintains single selection behavior", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ function SingleSelectionForm() {
+ const [value, setValue] = useState("option1");
+
+ return (
+ {
+ setValue(value);
+ handleChange(value);
+ }}
+ />
+ );
+ }
+
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+
+ // Initially option1 should be selected
+ expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+ expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
+ expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+
+ // Click option2
+ const option2 = screen.getByText("Option 2").closest("label");
+ await user.click(option2);
+
+ // Only option2 should be selected
+ expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
+ expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+
+ expect(handleChange).toHaveBeenCalledWith("option2");
+ });
+});
diff --git a/tests/storybook/RadioButton.interactions.test.js b/tests/storybook/RadioButton.interactions.test.js
new file mode 100644
index 0000000..5673906
--- /dev/null
+++ b/tests/storybook/RadioButton.interactions.test.js
@@ -0,0 +1,126 @@
+import { expect } from "@storybook/test";
+import { userEvent, within } from "@storybook/test";
+
+export const DefaultInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioButton = canvas.getByRole("radio");
+
+ // Should be unchecked initially
+ await expect(radioButton).toHaveAttribute("aria-checked", "false");
+
+ // Click to check
+ await userEvent.click(radioButton);
+ await expect(radioButton).toHaveAttribute("aria-checked", "true");
+
+ // Click to uncheck
+ await userEvent.click(radioButton);
+ await expect(radioButton).toHaveAttribute("aria-checked", "false");
+ },
+};
+
+export const CheckedInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioButton = canvas.getByRole("radio");
+
+ // Should be checked initially
+ await expect(radioButton).toHaveAttribute("aria-checked", "true");
+
+ // Click to uncheck
+ await userEvent.click(radioButton);
+ await expect(radioButton).toHaveAttribute("aria-checked", "false");
+
+ // Click to check again
+ await userEvent.click(radioButton);
+ await expect(radioButton).toHaveAttribute("aria-checked", "true");
+ },
+};
+
+export const StandardInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioButtons = canvas.getAllByRole("radio");
+
+ // First should be unchecked
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ // Second should be checked
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
+
+ // Click first radio button
+ await userEvent.click(radioButtons[0]);
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
+ },
+};
+
+export const InverseInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioButtons = canvas.getAllByRole("radio");
+
+ // First should be unchecked
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ // Second should be checked
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
+
+ // Click first radio button
+ await userEvent.click(radioButtons[0]);
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
+ },
+};
+
+export const KeyboardInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioButton = canvas.getByRole("radio");
+
+ // Focus the radio button
+ await userEvent.click(radioButton);
+ await expect(radioButton).toHaveFocus();
+
+ // Test Space key
+ await userEvent.keyboard(" ");
+ await expect(radioButton).toHaveAttribute("aria-checked", "true");
+
+ // Test Enter key
+ await userEvent.keyboard("Enter");
+ await expect(radioButton).toHaveAttribute("aria-checked", "false");
+ },
+};
+
+export const AccessibilityInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioButton = canvas.getByRole("radio");
+
+ // Should have proper ARIA attributes
+ await expect(radioButton).toHaveAttribute("role", "radio");
+ await expect(radioButton).toHaveAttribute("aria-checked");
+ await expect(radioButton).toHaveAttribute("tabIndex", "0");
+
+ // Should be keyboard accessible
+ await userEvent.tab();
+ await expect(radioButton).toHaveFocus();
+
+ // Should have accessible name
+ const label = canvas.getByText("Default radio button");
+ await expect(label).toBeVisible();
+ },
+};
+
+export const FormIntegration = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioButton = canvas.getByRole("radio");
+
+ // Should have hidden input for form submission
+ const hiddenInput = canvas.getByRole("radio", { hidden: true });
+ await expect(hiddenInput).toBeInTheDocument();
+
+ // Should be included in form data
+ await userEvent.click(radioButton);
+ await expect(hiddenInput).toBeChecked();
+ },
+};
diff --git a/tests/storybook/RadioButton.storybook.test.js b/tests/storybook/RadioButton.storybook.test.js
new file mode 100644
index 0000000..a792d8f
--- /dev/null
+++ b/tests/storybook/RadioButton.storybook.test.js
@@ -0,0 +1,177 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("RadioButton Storybook Tests", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(
+ "http://localhost:6006/iframe.html?id=forms-radiobutton--default"
+ );
+ });
+
+ test("renders default story", async ({ page }) => {
+ const radioButton = page.locator('[role="radio"]');
+ await expect(radioButton).toBeVisible();
+ await expect(radioButton).toHaveAttribute("aria-checked", "false");
+ });
+
+ test("renders checked story", async ({ page }) => {
+ await page.goto(
+ "http://localhost:6006/iframe.html?id=forms-radiobutton--checked"
+ );
+
+ const radioButton = page.locator('[role="radio"]');
+ await expect(radioButton).toBeVisible();
+ await expect(radioButton).toHaveAttribute("aria-checked", "true");
+ });
+
+ test("renders standard story", async ({ page }) => {
+ await page.goto(
+ "http://localhost:6006/iframe.html?id=forms-radiobutton--standard"
+ );
+
+ const radioButtons = page.locator('[role="radio"]');
+ await expect(radioButtons).toHaveCount(2);
+
+ // First should be unchecked
+ await expect(radioButtons.first()).toHaveAttribute("aria-checked", "false");
+ // Second should be checked
+ await expect(radioButtons.nth(1)).toHaveAttribute("aria-checked", "true");
+ });
+
+ test("renders inverse story", async ({ page }) => {
+ await page.goto(
+ "http://localhost:6006/iframe.html?id=forms-radiobutton--inverse"
+ );
+
+ const radioButtons = page.locator('[role="radio"]');
+ await expect(radioButtons).toHaveCount(2);
+
+ // First should be unchecked
+ await expect(radioButtons.first()).toHaveAttribute("aria-checked", "false");
+ // Second should be checked
+ await expect(radioButtons.nth(1)).toHaveAttribute("aria-checked", "true");
+ });
+
+ test("interacts with controls", async ({ page }) => {
+ // Test checked control
+ await page.check('[data-testid="checked-control"]');
+ const radioButton = page.locator('[role="radio"]');
+ await expect(radioButton).toHaveAttribute("aria-checked", "true");
+
+ await page.uncheck('[data-testid="checked-control"]');
+ await expect(radioButton).toHaveAttribute("aria-checked", "false");
+ });
+
+ test("interacts with mode control", async ({ page }) => {
+ // Test mode control
+ await page.selectOption('[data-testid="mode-control"]', "inverse");
+ const radioButton = page.locator('[role="radio"]');
+ await expect(radioButton).toHaveClass(
+ /outline-\[var\(--color-border-inverse-primary\)\]/
+ );
+
+ await page.selectOption('[data-testid="mode-control"]', "standard");
+ await expect(radioButton).toHaveClass(
+ /outline-\[var\(--color-border-default-tertiary\)\]/
+ );
+ });
+
+ test("interacts with state control", async ({ page }) => {
+ // Test state control
+ await page.selectOption('[data-testid="state-control"]', "focus");
+ const radioButton = page.locator('[role="radio"]');
+ await expect(radioButton).toHaveClass(/focus:outline/);
+
+ await page.selectOption('[data-testid="state-control"]', "hover");
+ await expect(radioButton).toHaveClass(/hover:outline/);
+ });
+
+ test("interacts with label control", async ({ page }) => {
+ // Test label control
+ await page.fill('[data-testid="label-control"]', "Custom Label");
+ await expect(page.locator('text="Custom Label"')).toBeVisible();
+ });
+
+ test("handles keyboard interaction", async ({ page }) => {
+ const radioButton = page.locator('[role="radio"]');
+ await radioButton.focus();
+ await expect(radioButton).toBeFocused();
+
+ // Test Space key
+ await page.keyboard.press("Space");
+ await expect(radioButton).toHaveAttribute("aria-checked", "true");
+
+ // Test Enter key
+ await page.keyboard.press("Enter");
+ await expect(radioButton).toHaveAttribute("aria-checked", "false");
+ });
+
+ test("has proper accessibility attributes", async ({ page }) => {
+ const radioButton = page.locator('[role="radio"]');
+
+ await expect(radioButton).toHaveAttribute("role", "radio");
+ await expect(radioButton).toHaveAttribute("aria-checked");
+ await expect(radioButton).toHaveAttribute("tabIndex", "0");
+ });
+
+ test("shows dot indicator when checked", async ({ page }) => {
+ await page.check('[data-testid="checked-control"]');
+
+ const radioButton = page.locator('[role="radio"]');
+ const dot = radioButton.locator("div").first();
+ await expect(dot).toHaveClass(/w-\[16px\]/, /h-\[16px\]/, /rounded-full/);
+ });
+
+ test("hides dot indicator when unchecked", async ({ page }) => {
+ await page.uncheck('[data-testid="checked-control"]');
+
+ const radioButton = page.locator('[role="radio"]');
+ const dot = radioButton.locator("div").first();
+ await expect(dot).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
+ });
+
+ test("maintains focus state", async ({ page }) => {
+ const radioButton = page.locator('[role="radio"]');
+ await radioButton.focus();
+ await expect(radioButton).toBeFocused();
+
+ // Should maintain focus after interaction
+ await page.keyboard.press("Space");
+ await expect(radioButton).toBeFocused();
+ });
+
+ test("handles mouse interaction", async ({ page }) => {
+ const radioButton = page.locator('[role="radio"]');
+
+ // Click to check
+ await radioButton.click();
+ await expect(radioButton).toHaveAttribute("aria-checked", "true");
+
+ // Click to uncheck
+ await radioButton.click();
+ await expect(radioButton).toHaveAttribute("aria-checked", "false");
+ });
+
+ test("shows proper styling for different modes", async ({ page }) => {
+ // Test standard mode
+ await page.selectOption('[data-testid="mode-control"]', "standard");
+ const radioButton = page.locator('[role="radio"]');
+ await expect(radioButton).toHaveClass(
+ /outline-\[var\(--color-border-default-tertiary\)\]/
+ );
+
+ // Test inverse mode
+ await page.selectOption('[data-testid="mode-control"]', "inverse");
+ await expect(radioButton).toHaveClass(
+ /outline-\[var\(--color-border-inverse-primary\)\]/
+ );
+ });
+
+ test("handles form submission", async ({ page }) => {
+ const hiddenInput = page.locator('input[type="radio"]');
+ await expect(hiddenInput).toBeVisible();
+
+ // Should be included in form data
+ await page.check('[data-testid="checked-control"]');
+ await expect(hiddenInput).toBeChecked();
+ });
+});
diff --git a/tests/storybook/RadioGroup.interactions.test.js b/tests/storybook/RadioGroup.interactions.test.js
new file mode 100644
index 0000000..5efb013
--- /dev/null
+++ b/tests/storybook/RadioGroup.interactions.test.js
@@ -0,0 +1,184 @@
+import { expect } from "@storybook/test";
+import { userEvent, within } from "@storybook/test";
+
+export const DefaultInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioGroup = canvas.getByRole("radiogroup");
+ const radioButtons = canvas.getAllByRole("radio");
+
+ // Should have radiogroup role
+ await expect(radioGroup).toBeInTheDocument();
+
+ // Should have 3 radio buttons
+ await expect(radioButtons).toHaveLength(3);
+
+ // First should be selected initially
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+ },
+};
+
+export const StandardInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioGroup = canvas.getByRole("radiogroup");
+ const radioButtons = canvas.getAllByRole("radio");
+
+ // Should have radiogroup role
+ await expect(radioGroup).toBeInTheDocument();
+
+ // Second should be selected initially
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+
+ // Click first option
+ await userEvent.click(radioButtons[0]);
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+ },
+};
+
+export const InverseInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioGroup = canvas.getByRole("radiogroup");
+ const radioButtons = canvas.getAllByRole("radio");
+
+ // Should have radiogroup role
+ await expect(radioGroup).toBeInTheDocument();
+
+ // First should be selected initially
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+
+ // Click second option
+ await userEvent.click(radioButtons[1]);
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+ },
+};
+
+export const InteractiveInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioGroup = canvas.getByRole("radiogroup");
+ const radioButtons = canvas.getAllByRole("radio");
+
+ // Should have radiogroup role
+ expect(radioGroup).toBeInTheDocument();
+
+ // Should show initial state
+ expect(canvas.getByText("Selected: option1")).toBeVisible();
+
+ // Click second option
+ userEvent.click(radioButtons[1]);
+ expect(canvas.getByText("Selected: option2")).toBeVisible();
+
+ // Click third option
+ userEvent.click(radioButtons[2]);
+ expect(canvas.getByText("Selected: option3")).toBeVisible();
+ },
+};
+
+export const KeyboardInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioButtons = canvas.getAllByRole("radio");
+
+ // Focus first radio button
+ await userEvent.click(radioButtons[0]);
+ await expect(radioButtons[0]).toHaveFocus();
+
+ // Navigate to second radio button
+ await userEvent.tab();
+ await expect(radioButtons[1]).toHaveFocus();
+
+ // Activate with Space
+ await userEvent.keyboard(" ");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+
+ // Navigate to third radio button
+ await userEvent.tab();
+ await expect(radioButtons[2]).toHaveFocus();
+
+ // Activate with Enter
+ await userEvent.keyboard("Enter");
+ await expect(radioButtons[2]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
+ },
+};
+
+export const AccessibilityInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioGroup = canvas.getByRole("radiogroup");
+ const radioButtons = canvas.getAllByRole("radio");
+
+ // Should have proper ARIA attributes
+ await expect(radioGroup).toHaveAttribute("role", "radiogroup");
+
+ radioButtons.forEach(async (button) => {
+ await expect(button).toHaveAttribute("role", "radio");
+ await expect(button).toHaveAttribute("aria-checked");
+ await expect(button).toHaveAttribute("tabIndex", "0");
+ });
+
+ // Should have accessible names
+ await expect(canvas.getByText("Option 1")).toBeVisible();
+ await expect(canvas.getByText("Option 2")).toBeVisible();
+ await expect(canvas.getByText("Option 3")).toBeVisible();
+ },
+};
+
+export const SingleSelectionInteraction = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioButtons = canvas.getAllByRole("radio");
+
+ // Initially first should be selected
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+
+ // Click second option
+ await userEvent.click(radioButtons[1]);
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+
+ // Click third option
+ await userEvent.click(radioButtons[2]);
+ await expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons[1]).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons[2]).toHaveAttribute("aria-checked", "true");
+ },
+};
+
+export const FormIntegration = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const radioGroup = canvas.getByRole("radiogroup");
+ const radioButtons = canvas.getAllByRole("radio");
+
+ // Should have hidden inputs for form submission
+ const hiddenInputs = canvas.getAllByRole("radio", { hidden: true });
+ await expect(hiddenInputs).toHaveLength(3);
+
+ // All should have the same name
+ const names = await Promise.all(
+ hiddenInputs.map((input) => input.getAttribute("name"))
+ );
+ expect(names.every((name) => name === names[0])).toBe(true);
+
+ // Should be included in form data
+ await userEvent.click(radioButtons[1]);
+ await expect(hiddenInputs[1]).toBeChecked();
+ },
+};
diff --git a/tests/storybook/RadioGroup.storybook.test.js b/tests/storybook/RadioGroup.storybook.test.js
new file mode 100644
index 0000000..ff2a5e9
--- /dev/null
+++ b/tests/storybook/RadioGroup.storybook.test.js
@@ -0,0 +1,253 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("RadioGroup Storybook Tests", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(
+ "http://localhost:6006/iframe.html?id=forms-radiogroup--default"
+ );
+ });
+
+ test("renders default story", async ({ page }) => {
+ const radioGroup = page.locator('[role="radiogroup"]');
+ await expect(radioGroup).toBeVisible();
+
+ const radioButtons = page.locator('[role="radio"]');
+ await expect(radioButtons).toHaveCount(3);
+ });
+
+ test("renders standard story", async ({ page }) => {
+ await page.goto(
+ "http://localhost:6006/iframe.html?id=forms-radiogroup--standard"
+ );
+
+ const radioGroup = page.locator('[role="radiogroup"]');
+ await expect(radioGroup).toBeVisible();
+
+ const radioButtons = page.locator('[role="radio"]');
+ await expect(radioButtons).toHaveCount(3);
+
+ // Second option should be selected
+ await expect(radioButtons.nth(1)).toHaveAttribute("aria-checked", "true");
+ });
+
+ test("renders inverse story", async ({ page }) => {
+ await page.goto(
+ "http://localhost:6006/iframe.html?id=forms-radiogroup--inverse"
+ );
+
+ const radioGroup = page.locator('[role="radiogroup"]');
+ await expect(radioGroup).toBeVisible();
+
+ const radioButtons = page.locator('[role="radio"]');
+ await expect(radioButtons).toHaveCount(3);
+
+ // First option should be selected
+ await expect(radioButtons.first()).toHaveAttribute("aria-checked", "true");
+ });
+
+ test("renders interactive story", async ({ page }) => {
+ await page.goto(
+ "http://localhost:6006/iframe.html?id=forms-radiogroup--interactive"
+ );
+
+ const radioGroup = page.locator('[role="radiogroup"]');
+ await expect(radioGroup).toBeVisible();
+
+ const radioButtons = page.locator('[role="radio"]');
+ await expect(radioButtons).toHaveCount(3);
+
+ // Should show selected value
+ await expect(page.locator('text="Selected: option1"')).toBeVisible();
+ });
+
+ test("interacts with controls", async ({ page }) => {
+ // Test mode control
+ await page.selectOption('[data-testid="mode-control"]', "inverse");
+ const radioGroup = page.locator('[role="radiogroup"]');
+ const radioButtons = page.locator('[role="radio"]');
+
+ // All radio buttons should have inverse styling
+ for (let i = 0; i < (await radioButtons.count()); i++) {
+ await expect(radioButtons.nth(i)).toHaveClass(
+ /outline-\[var\(--color-border-inverse-primary\)\]/
+ );
+ }
+
+ await page.selectOption('[data-testid="mode-control"]', "standard");
+ for (let i = 0; i < (await radioButtons.count()); i++) {
+ await expect(radioButtons.nth(i)).toHaveClass(
+ /outline-\[var\(--color-border-default-tertiary\)\]/
+ );
+ }
+ });
+
+ test("interacts with value control", async ({ page }) => {
+ // Test value control
+ await page.fill('[data-testid="value-control"]', "option2");
+
+ const radioButtons = page.locator('[role="radio"]');
+ await expect(radioButtons.nth(1)).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons.first()).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons.nth(2)).toHaveAttribute("aria-checked", "false");
+ });
+
+ test("handles keyboard navigation", async ({ page }) => {
+ const radioButtons = page.locator('[role="radio"]');
+
+ // Focus first radio button
+ await radioButtons.first().focus();
+ await expect(radioButtons.first()).toBeFocused();
+
+ // Navigate to second radio button
+ await page.keyboard.press("Tab");
+ await expect(radioButtons.nth(1)).toBeFocused();
+
+ // Navigate to third radio button
+ await page.keyboard.press("Tab");
+ await expect(radioButtons.nth(2)).toBeFocused();
+ });
+
+ test("handles keyboard activation", async ({ page }) => {
+ const radioButtons = page.locator('[role="radio"]');
+
+ // Focus second radio button
+ await radioButtons.nth(1).focus();
+
+ // Activate with Space
+ await page.keyboard.press("Space");
+ await expect(radioButtons.nth(1)).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons.first()).toHaveAttribute("aria-checked", "false");
+
+ // Activate third radio button with Enter
+ await radioButtons.nth(2).focus();
+ await page.keyboard.press("Enter");
+ await expect(radioButtons.nth(2)).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons.nth(1)).toHaveAttribute("aria-checked", "false");
+ });
+
+ test("handles mouse interaction", async ({ page }) => {
+ const radioButtons = page.locator('[role="radio"]');
+
+ // Click second option
+ await radioButtons.nth(1).click();
+ await expect(radioButtons.nth(1)).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons.first()).toHaveAttribute("aria-checked", "false");
+
+ // Click third option
+ await radioButtons.nth(2).click();
+ await expect(radioButtons.nth(2)).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons.nth(1)).toHaveAttribute("aria-checked", "false");
+ });
+
+ test("maintains single selection", async ({ page }) => {
+ const radioButtons = page.locator('[role="radio"]');
+
+ // Click first option
+ await radioButtons.first().click();
+ await expect(radioButtons.first()).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons.nth(1)).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons.nth(2)).toHaveAttribute("aria-checked", "false");
+
+ // Click second option
+ await radioButtons.nth(1).click();
+ await expect(radioButtons.first()).toHaveAttribute("aria-checked", "false");
+ await expect(radioButtons.nth(1)).toHaveAttribute("aria-checked", "true");
+ await expect(radioButtons.nth(2)).toHaveAttribute("aria-checked", "false");
+ });
+
+ test("has proper accessibility attributes", async ({ page }) => {
+ const radioGroup = page.locator('[role="radiogroup"]');
+ const radioButtons = page.locator('[role="radio"]');
+
+ await expect(radioGroup).toHaveAttribute("role", "radiogroup");
+
+ for (let i = 0; i < (await radioButtons.count()); i++) {
+ await expect(radioButtons.nth(i)).toHaveAttribute("role", "radio");
+ await expect(radioButtons.nth(i)).toHaveAttribute("aria-checked");
+ await expect(radioButtons.nth(i)).toHaveAttribute("tabIndex", "0");
+ }
+ });
+
+ test("shows proper labels", async ({ page }) => {
+ await expect(page.locator('text="Option 1"')).toBeVisible();
+ await expect(page.locator('text="Option 2"')).toBeVisible();
+ await expect(page.locator('text="Option 3"')).toBeVisible();
+ });
+
+ test("handles form submission", async ({ page }) => {
+ const hiddenInputs = page.locator('input[type="radio"]');
+ await expect(hiddenInputs).toHaveCount(3);
+
+ // All should have the same name
+ const names = await hiddenInputs.evaluateAll((inputs) =>
+ inputs.map((input) => input.getAttribute("name"))
+ );
+ expect(names.every((name) => name === names[0])).toBe(true);
+ });
+
+ test("shows dot indicators correctly", async ({ page }) => {
+ const radioButtons = page.locator('[role="radio"]');
+
+ // Initially first option should be selected
+ const firstDot = radioButtons.first().locator("div").first();
+ await expect(firstDot).toHaveClass(
+ /w-\[16px\]/,
+ /h-\[16px\]/,
+ /rounded-full/
+ );
+
+ // Click second option
+ await radioButtons.nth(1).click();
+
+ // First dot should be hidden, second should be visible
+ const secondDot = radioButtons.nth(1).locator("div").first();
+ await expect(secondDot).toHaveClass(
+ /w-\[16px\]/,
+ /h-\[16px\]/,
+ /rounded-full/
+ );
+ });
+
+ test("handles interactive story state changes", async ({ page }) => {
+ await page.goto(
+ "http://localhost:6006/iframe.html?id=forms-radiogroup--interactive"
+ );
+
+ // Should show initial state
+ await expect(page.locator('text="Selected: option1"')).toBeVisible();
+
+ // Click second option
+ const radioButtons = page.locator('[role="radio"]');
+ await radioButtons.nth(1).click();
+
+ // Should update displayed value
+ await expect(page.locator('text="Selected: option2"')).toBeVisible();
+ });
+
+ test("maintains focus state", async ({ page }) => {
+ const radioButtons = page.locator('[role="radio"]');
+
+ // Focus first radio button
+ await radioButtons.first().focus();
+ await expect(radioButtons.first()).toBeFocused();
+
+ // Should maintain focus after interaction
+ await page.keyboard.press("Space");
+ await expect(radioButtons.first()).toBeFocused();
+ });
+
+ test("handles different viewport sizes", async ({ page }) => {
+ // Test mobile viewport
+ await page.setViewportSize({ width: 375, height: 667 });
+ const radioGroup = page.locator('[role="radiogroup"]');
+ await expect(radioGroup).toBeVisible();
+
+ // Test tablet viewport
+ await page.setViewportSize({ width: 768, height: 1024 });
+ await expect(radioGroup).toBeVisible();
+
+ // Test desktop viewport
+ await page.setViewportSize({ width: 1920, height: 1080 });
+ await expect(radioGroup).toBeVisible();
+ });
+});
diff --git a/tests/unit/RadioButton.test.jsx b/tests/unit/RadioButton.test.jsx
new file mode 100644
index 0000000..1362031
--- /dev/null
+++ b/tests/unit/RadioButton.test.jsx
@@ -0,0 +1,236 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import RadioButton from "../../app/components/RadioButton";
+
+describe("RadioButton", () => {
+ it("renders with default props", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toBeInTheDocument();
+ expect(radioButton).toHaveAttribute("aria-checked", "false");
+ });
+
+ it("renders with label", () => {
+ render();
+
+ expect(screen.getByText("Test Radio")).toBeInTheDocument();
+ });
+
+ it("shows checked state", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("calls onChange when clicked", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButton = screen.getByRole("radio");
+ await user.click(radioButton);
+
+ expect(handleChange).toHaveBeenCalledWith({
+ checked: true,
+ value: undefined,
+ });
+ });
+
+ it("calls onChange with value when clicked", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButton = screen.getByRole("radio");
+ await user.click(radioButton);
+
+ expect(handleChange).toHaveBeenCalledWith({
+ checked: true,
+ value: "test-value",
+ });
+ });
+
+ it("does not call onChange when clicking already checked radio button", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButton = screen.getByRole("radio");
+ await user.click(radioButton);
+
+ // Radio buttons should not be unchecked by clicking them again
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+
+ it("handles keyboard activation", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButton = screen.getByRole("radio");
+ radioButton.focus();
+ await user.keyboard(" ");
+
+ expect(handleChange).toHaveBeenCalledWith({
+ checked: true,
+ value: undefined,
+ });
+ });
+
+ it("handles Enter key activation", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButton = screen.getByRole("radio");
+ await user.click(radioButton); // Focus the element first
+ await user.keyboard("{Enter}");
+
+ expect(handleChange).toHaveBeenCalledWith({
+ checked: true,
+ value: undefined,
+ });
+ });
+
+ it("applies standard mode classes", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveClass(
+ "outline-[var(--color-border-default-tertiary)]"
+ );
+ });
+
+ it("applies inverse mode classes", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveClass(
+ "outline-[var(--color-border-inverse-primary)]"
+ );
+ });
+
+ it("applies focus state classes", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveClass("focus:outline");
+ });
+
+ it("applies hover state classes", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveClass("hover:outline");
+ });
+
+ it("renders hidden input for form submission", () => {
+ render(
+
+ );
+
+ const hiddenInput = screen.getByDisplayValue("test-value");
+ expect(hiddenInput).toBeInTheDocument();
+ expect(hiddenInput).toHaveAttribute("type", "radio");
+ expect(hiddenInput).toHaveAttribute("name", "test-radio");
+ expect(hiddenInput).toBeChecked();
+ });
+
+ it("applies custom className", () => {
+ render();
+
+ const label = screen.getByText("Custom Radio").closest("label");
+ expect(label).toHaveClass("custom-class");
+ });
+
+ it("generates unique ID when not provided", () => {
+ render();
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons[0]).toHaveAttribute("id");
+ expect(radioButtons[1]).toHaveAttribute("id");
+ expect(radioButtons[0].id).not.toBe(radioButtons[1].id);
+ });
+
+ it("uses provided ID", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("id", "custom-id");
+ });
+
+ it("associates label with radio button for accessibility", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ const labelId = radioButton.getAttribute("aria-labelledby");
+ expect(labelId).toBeTruthy();
+
+ const labelElement = document.getElementById(labelId);
+ expect(labelElement).toHaveTextContent("Accessible Radio");
+ });
+
+ it("uses aria-label when provided", () => {
+ render();
+
+ const radioButton = screen.getByRole("radio");
+ expect(radioButton).toHaveAttribute("aria-label", "Custom Aria Label");
+ });
+
+ it("shows dot indicator when checked", () => {
+ render(
+
+ );
+
+ const dot = screen.getByRole("radio").querySelector("div");
+ expect(dot).toHaveClass("w-[16px]", "h-[16px]", "rounded-full");
+ });
+
+ it("hides dot indicator when unchecked", () => {
+ render(
+
+ );
+
+ const dot = screen.getByRole("radio").querySelector("div");
+ // Check if the dot has transparent background or no background color set
+ const computedStyle = window.getComputedStyle(dot);
+ const backgroundColor = computedStyle.backgroundColor;
+
+ // The dot should either be transparent or have no background color
+ expect(
+ backgroundColor === "transparent" ||
+ backgroundColor === "rgba(0, 0, 0, 0)" ||
+ backgroundColor === ""
+ ).toBe(true);
+ });
+});
diff --git a/tests/unit/RadioGroup.test.jsx b/tests/unit/RadioGroup.test.jsx
new file mode 100644
index 0000000..2d7540d
--- /dev/null
+++ b/tests/unit/RadioGroup.test.jsx
@@ -0,0 +1,240 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import RadioGroup from "../../app/components/RadioGroup";
+
+describe("RadioGroup", () => {
+ const defaultOptions = [
+ { value: "option1", label: "Option 1" },
+ { value: "option2", label: "Option 2" },
+ { value: "option3", label: "Option 3" },
+ ];
+
+ it("renders with default props", () => {
+ render();
+
+ const radioGroup = screen.getByRole("radiogroup");
+ expect(radioGroup).toBeInTheDocument();
+
+ const radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons).toHaveLength(3);
+ });
+
+ it("renders all options", () => {
+ render();
+
+ expect(screen.getByText("Option 1")).toBeInTheDocument();
+ expect(screen.getByText("Option 2")).toBeInTheDocument();
+ expect(screen.getByText("Option 3")).toBeInTheDocument();
+ });
+
+ it("shows selected option", () => {
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ expect(radioButtons[1]).toHaveAttribute("aria-checked", "true");
+ expect(radioButtons[2]).toHaveAttribute("aria-checked", "false");
+ });
+
+ it("calls onChange when option is selected", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const option2 = screen.getByText("Option 2").closest("label");
+ await user.click(option2);
+
+ expect(handleChange).toHaveBeenCalledWith({ value: "option2" });
+ });
+
+ it("updates selection when different option is clicked", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ // Click option 3
+ const option3 = screen.getByText("Option 3").closest("label");
+ await user.click(option3);
+
+ expect(handleChange).toHaveBeenCalledWith({ value: "option3" });
+ });
+
+ it("handles keyboard navigation", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons[1].focus();
+ await user.keyboard(" ");
+
+ expect(handleChange).toHaveBeenCalledWith({ value: "option2" });
+ });
+
+ it("handles Enter key activation", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const radioButtons = screen.getAllByRole("radio");
+ await user.click(radioButtons[2]);
+ await user.keyboard("{Enter}");
+
+ expect(handleChange).toHaveBeenCalledWith({ value: "option3" });
+ });
+
+ it("applies standard mode to all radio buttons", () => {
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons.forEach((button) => {
+ expect(button).toHaveClass(
+ "outline-[var(--color-border-default-tertiary)]"
+ );
+ });
+ });
+
+ it("applies inverse mode to all radio buttons", () => {
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons.forEach((button) => {
+ expect(button).toHaveClass(
+ "outline-[var(--color-border-inverse-primary)]"
+ );
+ });
+ });
+
+ it("applies state to all radio buttons", () => {
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ radioButtons.forEach((button) => {
+ expect(button).toHaveClass("focus:outline");
+ });
+ });
+
+ it("generates unique group name when not provided", () => {
+ render();
+ render();
+
+ const hiddenInputs = screen.getAllByRole("radio", { hidden: true });
+ const names = hiddenInputs.map((input) => input.getAttribute("name"));
+
+ // Should have unique names
+ const uniqueNames = new Set(names);
+ expect(uniqueNames.size).toBeGreaterThan(1);
+ });
+
+ it("uses provided name for all radio buttons", () => {
+ render();
+
+ const hiddenInputs = screen.getAllByDisplayValue("option1");
+ hiddenInputs.forEach((input) => {
+ expect(input).toHaveAttribute("name", "test-group");
+ });
+ });
+
+ it("applies custom className to container", () => {
+ render();
+
+ const radioGroup = screen.getByRole("radiogroup");
+ expect(radioGroup).toHaveClass("custom-group");
+ });
+
+ it("passes aria-label to radiogroup", () => {
+ render(
+
+ );
+
+ const radioGroup = screen.getByRole("radiogroup");
+ expect(radioGroup).toHaveAttribute("aria-label", "Test Radio Group");
+ });
+
+ it("handles empty options array", () => {
+ render();
+
+ const radioGroup = screen.getByRole("radiogroup");
+ expect(radioGroup).toBeInTheDocument();
+
+ const radioButtons = screen.queryAllByRole("radio");
+ expect(radioButtons).toHaveLength(0);
+ });
+
+ it("handles options with ariaLabel", () => {
+ const optionsWithAria = [
+ { value: "option1", label: "Option 1", ariaLabel: "First Option" },
+ { value: "option2", label: "Option 2", ariaLabel: "Second Option" },
+ ];
+
+ render();
+
+ const radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons[0]).toHaveAttribute("aria-label", "First Option");
+ expect(radioButtons[1]).toHaveAttribute("aria-label", "Second Option");
+ });
+
+ it("maintains selection state correctly", () => {
+ const { rerender } = render(
+
+ );
+
+ let radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
+
+ rerender();
+
+ radioButtons = screen.getAllByRole("radio");
+ expect(radioButtons[0]).toHaveAttribute("aria-checked", "false");
+ expect(radioButtons[2]).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("does not call onChange when clicking already selected option", async () => {
+ const user = userEvent.setup();
+ const handleChange = vi.fn();
+
+ render(
+
+ );
+
+ const option2 = screen.getByText("Option 2").closest("label");
+ await user.click(option2);
+
+ // Should not call onChange since it's already selected
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+});