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
+2 -2
View File
@@ -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;
+148
View File
@@ -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 (
<label
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${
disabled ? "opacity-60 cursor-not-allowed" : ""
} ${className}`}
onMouseDown={(e) => e.preventDefault()}
onClick={handleToggle}
>
<span
onKeyDown={(e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleToggle(e);
}
}}
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`}
style={{
backgroundColor: backgroundWhenChecked,
}}
tabIndex={0}
role="radio"
aria-checked={checked ? "true" : "false"}
{...(disabled && { "aria-disabled": "true" })}
{...(ariaLabel && { "aria-label": ariaLabel })}
{...(label && !ariaLabel && { "aria-labelledby": `${radioId}-label` })}
id={radioId}
>
{/* Radio dot */}
<div
className="w-[16px] h-[16px] rounded-full transition-all duration-200"
style={{
backgroundColor: dotColor,
}}
/>
</span>
{label && (
<span
id={`${radioId}-label`}
className="font-inter text-[14px] leading-[18px]"
style={{ color: labelColor }}
>
{label}
</span>
)}
{/* Hidden input for form submission */}
<input
type="radio"
name={name}
value={value}
checked={checked}
onChange={() => {}}
disabled={disabled}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
{...props}
/>
</label>
);
};
RadioButton.displayName = "RadioButton";
export default memo(RadioButton);
+65
View File
@@ -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 (
<div
className={`space-y-[8px] ${className}`}
role="radiogroup"
aria-label={props["aria-label"]}
{...props}
>
{options.map((option, index) => {
const isSelected = value === option.value;
return (
<RadioButton
key={option.value}
checked={isSelected}
mode={mode}
state={state}
disabled={disabled}
label={option.label}
name={groupId}
value={option.value}
ariaLabel={option.ariaLabel}
onChange={({ checked }) => {
if (checked) {
handleChange(option.value);
}
}}
/>
);
})}
</div>
);
};
RadioGroup.displayName = "RadioGroup";
export default memo(RadioGroup);
+62 -41
View File
@@ -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 (
<div className="p-[24px] space-y-[24px]">
<h1 className="font-bricolage text-[24px]">
Forms Playground Checkbox
</h1>
<h1 className="font-bricolage text-[24px]">Forms Playground</h1>
<section className="space-y-[12px]">
<h2 className="font-space text-[18px]">Interactive examples</h2>
<h2 className="font-space text-[18px]">Checkbox Examples</h2>
<div className="flex flex-col gap-[12px] max-w-[520px]">
<Checkbox
label="Standard (controlled)"
@@ -43,34 +37,61 @@ export default function FormsPlayground() {
</section>
<section className="space-y-[12px]">
<h2 className="font-space text-[18px]">Static states</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[16px]">
{variations.map((v) => (
<div
key={`${v.mode}-${v.state}`}
className="border border-[color:var(--border-color-default-tertiary)] rounded-[8px] p-[12px]"
>
<div className="text-[12px] mb-[8px] opacity-70">{v.title}</div>
<div>
<div className="flex items-center gap-[12px]">
<Checkbox
checked={false}
mode={v.mode}
state={v.state}
label="Unchecked"
onChange={() => {}}
/>
<Checkbox
checked
mode={v.mode}
state={v.state}
label="Checked"
onChange={() => {}}
/>
</div>
</div>
</div>
))}
<h2 className="font-space text-[18px]">Radio Button Examples</h2>
<div className="flex flex-col gap-[12px] max-w-[520px]">
<RadioButton
label="Standard (controlled)"
checked={radioValue === "option1"}
mode="standard"
state="default"
value="option1"
onChange={({ checked }) => checked && setRadioValue("option1")}
/>
<RadioButton
label="Inverse (controlled)"
checked={radioValue === "option2"}
mode="inverse"
state="default"
value="option2"
onChange={({ checked }) => checked && setRadioValue("option2")}
/>
</div>
</section>
<section className="space-y-[12px]">
<h2 className="font-space text-[18px]">Radio Group</h2>
<div className="max-w-[520px] space-y-[16px]">
<div>
<h3 className="font-space text-[14px] mb-[8px]">Standard Mode</h3>
<RadioGroup
name="standard-radio"
value={standardRadioValue}
mode="standard"
state="default"
onChange={({ value }) => setStandardRadioValue(value)}
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>
</div>
<div>
<h3 className="font-space text-[14px] mb-[8px]">Inverse Mode</h3>
<RadioGroup
name="inverse-radio"
value={inverseRadioValue}
mode="inverse"
state="default"
onChange={({ value }) => setInverseRadioValue(value)}
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>
</div>
</div>
</section>
</div>
+93
View File
@@ -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: () => (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-white font-medium">Standard Mode</h3>
<div className="flex flex-col gap-2">
<RadioButton label="Unchecked" checked={false} mode="standard" />
<RadioButton label="Checked" checked={true} mode="standard" />
</div>
</div>
</div>
),
play: StandardInteraction.play,
};
export const Inverse = {
render: () => (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-white font-medium">Inverse Mode</h3>
<div className="flex flex-col gap-2">
<RadioButton label="Unchecked" checked={false} mode="inverse" />
<RadioButton label="Checked" checked={true} mode="inverse" />
</div>
</div>
</div>
),
play: InverseInteraction.play,
};
+133
View File
@@ -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: () => (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-white font-medium">Standard Mode</h3>
<RadioGroup
name="standard-example"
value="option2"
mode="standard"
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
onChange={() => {}}
/>
</div>
</div>
),
play: StandardInteraction.play,
};
export const Inverse = {
render: () => (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-white font-medium">Inverse Mode</h3>
<RadioGroup
name="inverse-example"
value="option1"
mode="inverse"
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
onChange={() => {}}
/>
</div>
</div>
),
play: InverseInteraction.play,
};
export const Interactive = {
render: () => {
const [value, setValue] = React.useState("option1");
return (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-white font-medium">Interactive Example</h3>
<p className="text-gray-400 text-sm">Selected: {value}</p>
<RadioGroup
name="interactive-example"
value={value}
mode="standard"
options={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
onChange={({ value }) => setValue(value)}
/>
</div>
</div>
);
},
play: InteractiveInteraction.play,
};
@@ -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();
});
});