Radio button and group component with storybook and testing
This commit is contained in:
@@ -69,9 +69,9 @@ const Checkbox = memo(
|
|||||||
const conditionalHoverOutlineClass =
|
const conditionalHoverOutlineClass =
|
||||||
"hover:outline hover:outline-1 hover:outline-[var(--color-border-default-brand-primary)]";
|
"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 =
|
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) => {
|
const handleToggle = (e) => {
|
||||||
if (disabled) return;
|
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 React, { useState } from "react";
|
||||||
import Checkbox from "../components/Checkbox";
|
import Checkbox from "../components/Checkbox";
|
||||||
|
import RadioButton from "../components/RadioButton";
|
||||||
|
import RadioGroup from "../components/RadioGroup";
|
||||||
|
|
||||||
export default function FormsPlayground() {
|
export default function FormsPlayground() {
|
||||||
const [standardChecked, setStandardChecked] = useState(false);
|
const [standardChecked, setStandardChecked] = useState(false);
|
||||||
const [inverseChecked, setInverseChecked] = useState(true);
|
const [inverseChecked, setInverseChecked] = useState(true);
|
||||||
|
const [radioValue, setRadioValue] = useState("option1");
|
||||||
const variations = [
|
const [standardRadioValue, setStandardRadioValue] = useState("option1");
|
||||||
{ title: "Standard / Default", mode: "standard", state: "default" },
|
const [inverseRadioValue, setInverseRadioValue] = useState("option2");
|
||||||
{ 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-[24px] space-y-[24px]">
|
<div className="p-[24px] space-y-[24px]">
|
||||||
<h1 className="font-bricolage text-[24px]">
|
<h1 className="font-bricolage text-[24px]">Forms Playground</h1>
|
||||||
Forms Playground — Checkbox
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<section className="space-y-[12px]">
|
<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]">
|
<div className="flex flex-col gap-[12px] max-w-[520px]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Standard (controlled)"
|
label="Standard (controlled)"
|
||||||
@@ -43,34 +37,61 @@ export default function FormsPlayground() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-[12px]">
|
<section className="space-y-[12px]">
|
||||||
<h2 className="font-space text-[18px]">Static states</h2>
|
<h2 className="font-space text-[18px]">Radio Button Examples</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[16px]">
|
<div className="flex flex-col gap-[12px] max-w-[520px]">
|
||||||
{variations.map((v) => (
|
<RadioButton
|
||||||
<div
|
label="Standard (controlled)"
|
||||||
key={`${v.mode}-${v.state}`}
|
checked={radioValue === "option1"}
|
||||||
className="border border-[color:var(--border-color-default-tertiary)] rounded-[8px] p-[12px]"
|
mode="standard"
|
||||||
>
|
state="default"
|
||||||
<div className="text-[12px] mb-[8px] opacity-70">{v.title}</div>
|
value="option1"
|
||||||
<div>
|
onChange={({ checked }) => checked && setRadioValue("option1")}
|
||||||
<div className="flex items-center gap-[12px]">
|
/>
|
||||||
<Checkbox
|
<RadioButton
|
||||||
checked={false}
|
label="Inverse (controlled)"
|
||||||
mode={v.mode}
|
checked={radioValue === "option2"}
|
||||||
state={v.state}
|
mode="inverse"
|
||||||
label="Unchecked"
|
state="default"
|
||||||
onChange={() => {}}
|
value="option2"
|
||||||
/>
|
onChange={({ checked }) => checked && setRadioValue("option2")}
|
||||||
<Checkbox
|
/>
|
||||||
checked
|
</div>
|
||||||
mode={v.mode}
|
</section>
|
||||||
state={v.state}
|
|
||||||
label="Checked"
|
<section className="space-y-[12px]">
|
||||||
onChange={() => {}}
|
<h2 className="font-space text-[18px]">Radio Group</h2>
|
||||||
/>
|
<div className="max-w-[520px] space-y-[16px]">
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<h3 className="font-space text-[14px] mb-[8px]">Standard Mode</h3>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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