Radio button and group component with storybook and testing
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user