Radio button and group component with storybook and testing

This commit is contained in:
adilallo
2025-10-09 14:57:51 -06:00
parent 0b9e918fd0
commit 04783d3f62
16 changed files with 3053 additions and 43 deletions
@@ -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(<RadioButton label="Test Radio" />);
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(
<RadioButton checked={false} label="Test Radio" />
);
let radioButton = screen.getByRole("radio");
expect(radioButton).toHaveAttribute("aria-checked", "false");
rerender(<RadioButton checked={true} label="Test Radio" />);
radioButton = screen.getByRole("radio");
expect(radioButton).toHaveAttribute("aria-checked", "true");
});
it("associates label with radio button", () => {
render(<RadioButton label="Accessible Radio" />);
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(<RadioButton ariaLabel="Custom Aria Label" />);
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(<RadioButton label="Visible Label" ariaLabel="Hidden Aria Label" />);
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(<RadioButton onChange={handleChange} label="Keyboard Radio" />);
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(<RadioButton onChange={handleChange} label="Enter Radio" />);
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(<RadioButton onChange={handleChange} label="Space Radio" />);
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(<RadioButton onChange={handleChange} label="Other Keys Radio" />);
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(
<div>
<RadioButton label="First Radio" />
<RadioButton label="Second Radio" />
<RadioButton label="Third Radio" />
</div>
);
const radioButtons = screen.getAllByRole("radio");
radioButtons.forEach((button) => {
expect(button).toHaveAttribute("tabIndex", "0");
});
});
it("generates unique IDs for accessibility", () => {
render(
<div>
<RadioButton label="Radio 1" />
<RadioButton label="Radio 2" />
<RadioButton label="Radio 3" />
</div>
);
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(<RadioButton id="custom-radio-id" label="Custom ID Radio" />);
const radioButton = screen.getByRole("radio");
expect(radioButton).toHaveAttribute("id", "custom-radio-id");
});
it("has accessible name from label", () => {
render(<RadioButton label="Accessible Name Radio" />);
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(<RadioButton ariaLabel="Aria Label Name" />);
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(
<RadioButton
checked={false}
onChange={handleChange}
label="Focus Radio"
/>
);
const radioButton = screen.getByRole("radio");
radioButton.focus();
expect(radioButton).toHaveFocus();
// Change checked state
rerender(
<RadioButton checked={true} onChange={handleChange} label="Focus Radio" />
);
// Should still be focusable
expect(radioButton).toHaveAttribute("tabIndex", "0");
});
it("has proper role and state", () => {
render(<RadioButton checked={true} label="State Radio" />);
const radioButton = screen.getByRole("radio");
expect(radioButton).toHaveAttribute("role", "radio");
expect(radioButton).toHaveAttribute("aria-checked", "true");
});
it("supports screen reader navigation", () => {
render(
<div>
<RadioButton label="First Option" />
<RadioButton label="Second Option" />
<RadioButton label="Third Option" />
</div>
);
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(
<RadioButton name="test-radio" value="test-value" label="Form Radio" />
);
const hiddenInput = screen.getByDisplayValue("test-value");
expect(hiddenInput).toHaveAttribute("type", "radio");
expect(hiddenInput).toHaveAttribute("name", "test-radio");
expect(hiddenInput).toHaveAttribute("value", "test-value");
});
});
@@ -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(<RadioGroup options={defaultOptions} />);
const radioGroup = screen.getByRole("radiogroup");
expect(radioGroup).toBeInTheDocument();
});
it("has proper ARIA attributes on radiogroup", () => {
render(
<RadioGroup options={defaultOptions} aria-label="Test Radio Group" />
);
const radioGroup = screen.getByRole("radiogroup");
expect(radioGroup).toHaveAttribute("aria-label", "Test Radio Group");
});
it("has proper radio button roles", () => {
render(<RadioGroup options={defaultOptions} />);
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(<RadioGroup options={defaultOptions} value="option2" />);
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(
<RadioGroup options={defaultOptions} value="option1" />
);
let radioButtons = screen.getAllByRole("radio");
expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
rerender(<RadioGroup options={defaultOptions} value="option3" />);
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(<RadioGroup options={defaultOptions} />);
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(<RadioGroup options={optionsWithAria} />);
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(
<RadioGroup
options={defaultOptions}
value="option1"
onChange={handleChange}
/>
);
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(
<RadioGroup
options={defaultOptions}
value="option1"
onChange={handleChange}
/>
);
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(
<RadioGroup
options={defaultOptions}
value="option1"
onChange={handleChange}
/>
);
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(
<RadioGroup
options={defaultOptions}
value="option1"
onChange={handleChange}
/>
);
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(<RadioGroup options={defaultOptions} />);
const radioButtons = screen.getAllByRole("radio");
radioButtons.forEach((button) => {
expect(button).toHaveAttribute("tabIndex", "0");
});
});
it("generates unique IDs for accessibility", () => {
render(
<div>
<RadioGroup options={defaultOptions} />
<RadioGroup options={defaultOptions} />
</div>
);
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(<RadioGroup options={defaultOptions} name="test-group" />);
const hiddenInputs = screen.getAllByDisplayValue("option1");
hiddenInputs.forEach((input) => {
expect(input).toHaveAttribute("name", "test-group");
});
});
it("has proper form association", () => {
render(
<RadioGroup options={defaultOptions} name="test-group" value="option2" />
);
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(
<RadioGroup
options={defaultOptions}
value="option1"
onChange={handleChange}
/>
);
const radioButtons = screen.getAllByRole("radio");
radioButtons[1].focus();
expect(radioButtons[1]).toHaveFocus();
// Change selection
rerender(
<RadioGroup
options={defaultOptions}
value="option2"
onChange={handleChange}
/>
);
// Should still be focusable
expect(radioButtons[1]).toHaveAttribute("tabIndex", "0");
});
it("supports screen reader navigation", () => {
render(<RadioGroup options={defaultOptions} />);
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(<RadioGroup options={[]} />);
const radioGroup = screen.getByRole("radiogroup");
expect(radioGroup).toBeInTheDocument();
const radioButtons = screen.queryAllByRole("radio");
expect(radioButtons).toHaveLength(0);
});
it("has proper accessible names", () => {
render(<RadioGroup options={defaultOptions} />);
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(
<RadioGroup
options={defaultOptions}
value="option1"
onChange={handleChange}
/>
);
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);
});
});
@@ -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 (
<form onSubmit={handleSubmit}>
<RadioButton
label="Option 1"
name="test-radio"
value="option1"
checked={value === "option1"}
onChange={({ checked }) => checked && setValue("option1")}
/>
<RadioButton
label="Option 2"
name="test-radio"
value="option2"
checked={value === "option2"}
onChange={({ checked }) => checked && setValue("option2")}
/>
<button type="submit">Submit</button>
</form>
);
}
render(<TestForm />);
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 (
<div>
<RadioButton
label="Option 1"
name="keyboard-radio"
value="option1"
checked={value === "option1"}
onChange={({ checked }) => checked && setValue("option1")}
/>
<RadioButton
label="Option 2"
name="keyboard-radio"
value="option2"
checked={value === "option2"}
onChange={({ checked }) => checked && setValue("option2")}
/>
</div>
);
}
render(<KeyboardForm />);
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 (
<div>
<button
onClick={() =>
setMode(mode === "standard" ? "inverse" : "standard")
}
>
Toggle Mode
</button>
<RadioButton
label="Test Radio"
name="mode-radio"
value="option1"
checked={value === "option1"}
mode={mode}
onChange={({ checked }) => checked && setValue("option1")}
/>
</div>
);
}
const user = userEvent.setup();
render(<ModeSwitchForm />);
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 (
<div>
<button onClick={() => setCount(count + 1)}>
Re-render ({count})
</button>
<RadioButton
label="Test Radio"
name="state-radio"
value="option1"
checked={value === "option1"}
onChange={({ checked }) => checked && setValue("option1")}
/>
</div>
);
}
const user = userEvent.setup();
render(<StateForm />);
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 (
<div>
<div>
<h3>Group 1</h3>
<RadioButton
label="Option A"
name="group1"
value="option1"
checked={group1Value === "option1"}
onChange={({ checked }) => checked && setGroup1Value("option1")}
/>
<RadioButton
label="Option B"
name="group1"
value="option2"
checked={group1Value === "option2"}
onChange={({ checked }) => checked && setGroup1Value("option2")}
/>
</div>
<div>
<h3>Group 2</h3>
<RadioButton
label="Option X"
name="group2"
value="option1"
checked={group2Value === "option1"}
onChange={({ checked }) => checked && setGroup2Value("option1")}
/>
<RadioButton
label="Option Y"
name="group2"
value="option2"
checked={group2Value === "option2"}
onChange={({ checked }) => checked && setGroup2Value("option2")}
/>
</div>
</div>
);
}
const user = userEvent.setup();
render(<MultiGroupForm />);
// 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 (
<div>
<div>
<h3>Controlled</h3>
<RadioButton
label="Controlled Option 1"
name="controlled"
value="option1"
checked={controlledValue === "option1"}
onChange={({ checked }) =>
checked && setControlledValue("option1")
}
/>
<RadioButton
label="Controlled Option 2"
name="controlled"
value="option2"
checked={controlledValue === "option2"}
onChange={({ checked }) =>
checked && setControlledValue("option2")
}
/>
</div>
<div>
<h3>Uncontrolled</h3>
<RadioButton
label="Uncontrolled Option 1"
name="uncontrolled"
value="option1"
checked={uncontrolledValue === "option1"}
onChange={({ checked }) =>
checked && setUncontrolledValue("option1")
}
/>
<RadioButton
label="Uncontrolled Option 2"
name="uncontrolled"
value="option2"
checked={uncontrolledValue === "option2"}
onChange={({ checked }) =>
checked && setUncontrolledValue("option2")
}
/>
</div>
</div>
);
}
const user = userEvent.setup();
render(<ControlledForm />);
// 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 (
<form>
<fieldset>
<legend>Choose an option</legend>
<RadioButton
label="Option 1"
name="accessible-radio"
value="option1"
checked={value === "option1"}
onChange={({ checked }) => checked && setValue("option1")}
ariaLabel="First option"
/>
<RadioButton
label="Option 2"
name="accessible-radio"
value="option2"
checked={value === "option2"}
onChange={({ checked }) => checked && setValue("option2")}
ariaLabel="Second option"
/>
</fieldset>
</form>
);
}
render(<AccessibleForm />);
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");
});
});
@@ -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 (
<form onSubmit={handleSubmit}>
<RadioGroup
name="test-radio-group"
value={value}
options={defaultOptions}
onChange={({ value }) => setValue(value)}
/>
<button type="submit">Submit</button>
</form>
);
}
render(<TestForm />);
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 (
<RadioGroup
name="keyboard-radio-group"
value={value}
options={defaultOptions}
onChange={({ value }) => setValue(value)}
/>
);
}
render(<KeyboardForm />);
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 (
<div>
<button onClick={() => setMode(mode === "standard" ? "inverse" : "standard")}>
Toggle Mode
</button>
<RadioGroup
name="mode-radio-group"
value={value}
mode={mode}
options={defaultOptions}
onChange={({ value }) => setValue(value)}
/>
</div>
);
}
const user = userEvent.setup();
render(<ModeSwitchForm />);
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 (
<div>
<button onClick={() => setCount(count + 1)}>
Re-render ({count})
</button>
<RadioGroup
name="state-radio-group"
value={value}
options={defaultOptions}
onChange={({ value }) => setValue(value)}
/>
</div>
);
}
const user = userEvent.setup();
render(<StateForm />);
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 (
<div>
<div>
<h3>Group 1</h3>
<RadioGroup
name="group1"
value={group1Value}
options={defaultOptions}
onChange={({ value }) => setGroup1Value(value)}
/>
</div>
<div>
<h3>Group 2</h3>
<RadioGroup
name="group2"
value={group2Value}
options={defaultOptions}
onChange={({ value }) => setGroup2Value(value)}
/>
</div>
</div>
);
}
const user = userEvent.setup();
render(<MultiGroupForm />);
// 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 (
<div>
<div>
<h3>Controlled</h3>
<RadioGroup
name="controlled"
value={controlledValue}
options={defaultOptions}
onChange={({ value }) => setControlledValue(value)}
/>
</div>
<div>
<h3>Uncontrolled</h3>
<RadioGroup
name="uncontrolled"
value={uncontrolledValue}
options={defaultOptions}
onChange={({ value }) => setUncontrolledValue(value)}
/>
</div>
</div>
);
}
const user = userEvent.setup();
render(<ControlledForm />);
// 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 (
<form>
<fieldset>
<legend>Choose an option</legend>
<RadioGroup
name="accessible-radio-group"
value={value}
options={accessibleOptions}
onChange={({ value }) => setValue(value)}
aria-label="Accessible radio group"
/>
</fieldset>
</form>
);
}
render(<AccessibleForm />);
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 (
<div>
<button onClick={() => setOptions([...options, { value: "option4", label: "Option 4" }])}>
Add Option
</button>
<RadioGroup
name="dynamic-radio-group"
value={value}
options={options}
onChange={({ value }) => setValue(value)}
/>
</div>
);
}
const user = userEvent.setup();
render(<DynamicForm />);
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 (
<RadioGroup
name="empty-radio-group"
value={value}
options={[]}
onChange={({ value }) => setValue(value)}
/>
);
}
render(<EmptyForm />);
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 (
<RadioGroup
name="single-selection-radio-group"
value={value}
options={defaultOptions}
onChange={({ value }) => {
setValue(value);
handleChange(value);
}}
/>
);
}
render(<SingleSelectionForm />);
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");
});
});
@@ -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();
},
};
@@ -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();
});
});
@@ -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();
},
};
@@ -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();
});
});
+236
View File
@@ -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(<RadioButton />);
const radioButton = screen.getByRole("radio");
expect(radioButton).toBeInTheDocument();
expect(radioButton).toHaveAttribute("aria-checked", "false");
});
it("renders with label", () => {
render(<RadioButton label="Test Radio" />);
expect(screen.getByText("Test Radio")).toBeInTheDocument();
});
it("shows checked state", () => {
render(<RadioButton checked={true} label="Checked Radio" />);
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(
<RadioButton checked={false} onChange={handleChange} label="Test Radio" />
);
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(
<RadioButton
checked={false}
value="test-value"
onChange={handleChange}
label="Test Radio"
/>
);
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(
<RadioButton checked={true} onChange={handleChange} label="Test Radio" />
);
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(
<RadioButton checked={false} onChange={handleChange} label="Test Radio" />
);
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(
<RadioButton checked={false} onChange={handleChange} label="Test Radio" />
);
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(<RadioButton mode="standard" label="Standard Radio" />);
const radioButton = screen.getByRole("radio");
expect(radioButton).toHaveClass(
"outline-[var(--color-border-default-tertiary)]"
);
});
it("applies inverse mode classes", () => {
render(<RadioButton mode="inverse" label="Inverse Radio" />);
const radioButton = screen.getByRole("radio");
expect(radioButton).toHaveClass(
"outline-[var(--color-border-inverse-primary)]"
);
});
it("applies focus state classes", () => {
render(<RadioButton state="focus" label="Focus Radio" />);
const radioButton = screen.getByRole("radio");
expect(radioButton).toHaveClass("focus:outline");
});
it("applies hover state classes", () => {
render(<RadioButton state="hover" label="Hover Radio" />);
const radioButton = screen.getByRole("radio");
expect(radioButton).toHaveClass("hover:outline");
});
it("renders hidden input for form submission", () => {
render(
<RadioButton
name="test-radio"
value="test-value"
checked={true}
label="Test Radio"
/>
);
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(<RadioButton className="custom-class" label="Custom Radio" />);
const label = screen.getByText("Custom Radio").closest("label");
expect(label).toHaveClass("custom-class");
});
it("generates unique ID when not provided", () => {
render(<RadioButton label="Radio 1" />);
render(<RadioButton label="Radio 2" />);
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(<RadioButton id="custom-id" label="Custom ID Radio" />);
const radioButton = screen.getByRole("radio");
expect(radioButton).toHaveAttribute("id", "custom-id");
});
it("associates label with radio button for accessibility", () => {
render(<RadioButton label="Accessible Radio" />);
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(<RadioButton ariaLabel="Custom Aria Label" />);
const radioButton = screen.getByRole("radio");
expect(radioButton).toHaveAttribute("aria-label", "Custom Aria Label");
});
it("shows dot indicator when checked", () => {
render(
<RadioButton checked={true} mode="standard" label="Checked Radio" />
);
const dot = screen.getByRole("radio").querySelector("div");
expect(dot).toHaveClass("w-[16px]", "h-[16px]", "rounded-full");
});
it("hides dot indicator when unchecked", () => {
render(
<RadioButton checked={false} mode="standard" label="Unchecked Radio" />
);
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);
});
});
+240
View File
@@ -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(<RadioGroup options={defaultOptions} />);
const radioGroup = screen.getByRole("radiogroup");
expect(radioGroup).toBeInTheDocument();
const radioButtons = screen.getAllByRole("radio");
expect(radioButtons).toHaveLength(3);
});
it("renders all options", () => {
render(<RadioGroup options={defaultOptions} />);
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Option 2")).toBeInTheDocument();
expect(screen.getByText("Option 3")).toBeInTheDocument();
});
it("shows selected option", () => {
render(<RadioGroup options={defaultOptions} value="option2" />);
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(
<RadioGroup
options={defaultOptions}
value="option1"
onChange={handleChange}
/>
);
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(
<RadioGroup
options={defaultOptions}
value="option1"
onChange={handleChange}
/>
);
// 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(
<RadioGroup
options={defaultOptions}
value="option1"
onChange={handleChange}
/>
);
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(
<RadioGroup
options={defaultOptions}
value="option1"
onChange={handleChange}
/>
);
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(<RadioGroup options={defaultOptions} mode="standard" />);
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(<RadioGroup options={defaultOptions} mode="inverse" />);
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(<RadioGroup options={defaultOptions} state="focus" />);
const radioButtons = screen.getAllByRole("radio");
radioButtons.forEach((button) => {
expect(button).toHaveClass("focus:outline");
});
});
it("generates unique group name when not provided", () => {
render(<RadioGroup options={defaultOptions} />);
render(<RadioGroup options={defaultOptions} />);
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(<RadioGroup options={defaultOptions} name="test-group" />);
const hiddenInputs = screen.getAllByDisplayValue("option1");
hiddenInputs.forEach((input) => {
expect(input).toHaveAttribute("name", "test-group");
});
});
it("applies custom className to container", () => {
render(<RadioGroup options={defaultOptions} className="custom-group" />);
const radioGroup = screen.getByRole("radiogroup");
expect(radioGroup).toHaveClass("custom-group");
});
it("passes aria-label to radiogroup", () => {
render(
<RadioGroup options={defaultOptions} aria-label="Test Radio Group" />
);
const radioGroup = screen.getByRole("radiogroup");
expect(radioGroup).toHaveAttribute("aria-label", "Test Radio Group");
});
it("handles empty options array", () => {
render(<RadioGroup options={[]} />);
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(<RadioGroup options={optionsWithAria} />);
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(
<RadioGroup options={defaultOptions} value="option1" />
);
let radioButtons = screen.getAllByRole("radio");
expect(radioButtons[0]).toHaveAttribute("aria-checked", "true");
rerender(<RadioGroup options={defaultOptions} value="option3" />);
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(
<RadioGroup
options={defaultOptions}
value="option2"
onChange={handleChange}
/>
);
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();
});
});