Radio Group
ラジオボタンと呼ばれるチェック可能なボタンのセットで、一度に1つだけチェックできます。
デモ
基本的なラジオグループ
矢印キーでナビゲートして選択します。Tabキーでグループへのフォーカスの出入りを移動します。
デフォルト値の設定
defaultValue プロップを使用して事前に選択されたオプション。
無効化されたオプション
無効化されたオプションはキーボードナビゲーション中にスキップされます。
水平方向の配置
orientation="horizontal" を使用した水平レイアウト。
Native HTML
Use Native HTML First
Before using this custom component, consider using native <input type="radio"> elements with <fieldset> and <legend>.
They provide built-in accessibility, work without JavaScript, and require no ARIA attributes.
<fieldset>
<legend>Favorite color</legend>
<label><input type="radio" name="color" value="red" /> Red</label>
<label><input type="radio" name="color" value="blue" /> Blue</label>
<label><input type="radio" name="color" value="green" /> Green</label>
</fieldset> Use custom implementations when you need consistent cross-browser keyboard behavior or custom styling that native elements cannot provide.
| Use Case | Native HTML | Custom Implementation |
|---|---|---|
| Basic form input | Recommended | Not needed |
| JavaScript disabled support | Works natively | Requires fallback |
| Arrow key navigation | Browser-dependent* | Consistent behavior |
| Custom styling | Limited (browser-dependent) | Full control |
| Form submission | Built-in | Requires hidden input |
*Native radio keyboard behavior varies between browsers. Some browsers may not support all APG keyboard interactions (like Home/End) out of the box.
アクセシビリティ
WAI-ARIA Roles
| Role | Element | Description |
|---|---|---|
radiogroup | Container element |
Groups radio buttons together. Must have an accessible name via aria-label or
aria-labelledby.
|
radio | Each option element | Identifies the element as a radio button. Only one radio in a group can be checked at a time. |
This implementation uses custom role="radiogroup" and role="radio" for consistent
cross-browser keyboard behavior. Native <input type="radio"> provides these roles
implicitly.
WAI-ARIA States
aria-checked
Indicates the current checked state of the radio button. Only one radio in a group should have
aria-checked="true".
| Values | true | false |
| Required | Yes (on each radio) |
| Change Trigger | Click, Space, Arrow keys |
aria-disabled
Indicates that the radio button is not interactive and cannot be selected.
| Values | true (only when disabled) |
| Required | No (only when disabled) |
| Effect | Skipped during arrow key navigation, cannot be selected |
WAI-ARIA Properties
aria-orientation
Indicates the orientation of the radio group. Vertical is the default.
| Values | horizontal | vertical (default) |
| Required | No (only set when horizontal) |
| Note | This implementation supports all arrow keys regardless of orientation |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus into the group (to selected or first radio) |
| Shift + Tab | Move focus out of the group |
| Space | Select the focused radio (does not unselect) |
| Arrow Down / Right | Move to next radio and select (wraps to first) |
| Arrow Up / Left | Move to previous radio and select (wraps to last) |
| Home | Move to first radio and select |
| End | Move to last radio and select |
Note: Unlike Checkbox, arrow keys both move focus AND change selection. Disabled radios are skipped during navigation.
Focus Management (Roving Tabindex)
Radio groups use roving tabindex to manage focus. Only one radio in the group is tabbable at any time:
- Selected radio has
tabindex="0" - If none selected, first enabled radio has
tabindex="0" - All other radios have
tabindex="-1" - Disabled radios always have
tabindex="-1"
Accessible Naming
Both the radio group and individual radios must have accessible names:
- Radio group - Use
aria-labeloraria-labelledbyon the container - Individual radios - Each radio is labeled by its visible text content via
aria-labelledby - Native alternative - Use
<fieldset>with<legend>for group labeling
Visual Design
This implementation follows WCAG 1.4.1 (Use of Color) by not relying solely on color to indicate state:
- Filled circle - Indicates selected state
- Empty circle - Indicates unselected state
- Reduced opacity - Indicates disabled state
- Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode
References
ソースコード
import { cn } from '@/lib/utils';
import { useCallback, useId, useMemo, useRef, useState } from 'react';
export interface RadioOption {
id: string;
label: string;
value: string;
disabled?: boolean;
}
export interface RadioGroupProps {
/** Radio options */
options: RadioOption[];
/** Group name for form submission */
name: string;
/** Accessible label for the group */
'aria-label'?: string;
/** Reference to external label */
'aria-labelledby'?: string;
/** Initially selected value */
defaultValue?: string;
/** Orientation of the group */
orientation?: 'horizontal' | 'vertical';
/** Callback when selection changes */
onValueChange?: (value: string) => void;
/** Additional CSS class */
className?: string;
}
export function RadioGroup({
options,
name,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
defaultValue,
orientation = 'vertical',
onValueChange,
className,
}: RadioGroupProps): React.ReactElement {
const instanceId = useId();
// Filter enabled options for navigation
const enabledOptions = useMemo(() => options.filter((opt) => !opt.disabled), [options]);
// Find initial selected value
const initialValue = useMemo(() => {
if (defaultValue) {
const option = options.find((opt) => opt.value === defaultValue);
if (option && !option.disabled) {
return defaultValue;
}
}
return '';
}, [defaultValue, options]);
const [selectedValue, setSelectedValue] = useState(initialValue);
// Refs for focus management
const radioRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Get the index of an option in the enabled options list
const getEnabledIndex = useCallback(
(value: string) => enabledOptions.findIndex((opt) => opt.value === value),
[enabledOptions]
);
// Get the tabbable radio: selected one, or first enabled one
const getTabbableValue = useCallback(() => {
if (selectedValue && getEnabledIndex(selectedValue) >= 0) {
return selectedValue;
}
return enabledOptions[0]?.value || '';
}, [selectedValue, enabledOptions, getEnabledIndex]);
// Focus a radio by value
const focusRadio = useCallback((value: string) => {
const radioEl = radioRefs.current.get(value);
radioEl?.focus();
}, []);
// Select a radio
const selectRadio = useCallback(
(value: string) => {
const option = options.find((opt) => opt.value === value);
if (option && !option.disabled) {
setSelectedValue(value);
onValueChange?.(value);
}
},
[options, onValueChange]
);
// Navigate to next/previous enabled option with wrapping
const navigateAndSelect = useCallback(
(direction: 'next' | 'prev' | 'first' | 'last', currentValue: string) => {
if (enabledOptions.length === 0) return;
let targetIndex: number;
const currentIndex = getEnabledIndex(currentValue);
switch (direction) {
case 'next':
targetIndex = currentIndex >= 0 ? (currentIndex + 1) % enabledOptions.length : 0;
break;
case 'prev':
targetIndex =
currentIndex >= 0
? (currentIndex - 1 + enabledOptions.length) % enabledOptions.length
: enabledOptions.length - 1;
break;
case 'first':
targetIndex = 0;
break;
case 'last':
targetIndex = enabledOptions.length - 1;
break;
}
const targetOption = enabledOptions[targetIndex];
if (targetOption) {
focusRadio(targetOption.value);
selectRadio(targetOption.value);
}
},
[enabledOptions, getEnabledIndex, focusRadio, selectRadio]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent, optionValue: string) => {
const { key } = event;
switch (key) {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault();
navigateAndSelect('next', optionValue);
break;
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault();
navigateAndSelect('prev', optionValue);
break;
case 'Home':
event.preventDefault();
navigateAndSelect('first', optionValue);
break;
case 'End':
event.preventDefault();
navigateAndSelect('last', optionValue);
break;
case ' ':
event.preventDefault();
selectRadio(optionValue);
break;
}
},
[navigateAndSelect, selectRadio]
);
const handleClick = useCallback(
(optionValue: string) => {
const option = options.find((opt) => opt.value === optionValue);
if (option && !option.disabled) {
focusRadio(optionValue);
selectRadio(optionValue);
}
},
[options, focusRadio, selectRadio]
);
const tabbableValue = getTabbableValue();
return (
<div
role="radiogroup"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-orientation={orientation === 'horizontal' ? 'horizontal' : undefined}
className={cn('apg-radio-group', className)}
>
{/* Hidden input for form submission */}
<input type="hidden" name={name} value={selectedValue} />
{options.map((option) => {
const isSelected = selectedValue === option.value;
const isTabbable = option.value === tabbableValue && !option.disabled;
const tabIndex = option.disabled ? -1 : isTabbable ? 0 : -1;
const labelId = `${instanceId}-label-${option.id}`;
return (
<div
key={option.id}
ref={(el) => {
if (el) {
radioRefs.current.set(option.value, el);
} else {
radioRefs.current.delete(option.value);
}
}}
role="radio"
aria-checked={isSelected}
aria-disabled={option.disabled || undefined}
aria-labelledby={labelId}
tabIndex={tabIndex}
className={cn(
'apg-radio',
isSelected && 'apg-radio--selected',
option.disabled && 'apg-radio--disabled'
)}
onClick={() => handleClick(option.value)}
onKeyDown={(e) => handleKeyDown(e, option.value)}
>
<span className="apg-radio-control" aria-hidden="true">
<span className="apg-radio-indicator" />
</span>
<span id={labelId} className="apg-radio-label">
{option.label}
</span>
</div>
);
})}
</div>
);
}
export default RadioGroup; 使い方
import { RadioGroup } from './RadioGroup';
const options = [
{ id: 'red', label: 'Red', value: 'red' },
{ id: 'blue', label: 'Blue', value: 'blue' },
{ id: 'green', label: 'Green', value: 'green' },
];
function App() {
return (
<RadioGroup
options={options}
name="color"
aria-label="Favorite color"
defaultValue="blue"
onValueChange={(value) => console.log('Selected:', value)}
/>
);
} API
RadioGroupProps
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
options | RadioOption[] | 必須 | ラジオオプションの配列 |
name | string | 必須 | フォーム送信用のグループ名 |
aria-label | string | - | グループのアクセシブルなラベル |
aria-labelledby | string | - | ラベル要素のID |
defaultValue | string | "" | 初期選択値 |
orientation | 'horizontal' | 'vertical' | 'vertical' | レイアウトの向き |
onValueChange | (value: string) => void | - | 選択変更時のコールバック |
className | string | - | 追加のCSSクラス |
RadioOption
interface RadioOption {
id: string;
label: string;
value: string;
disabled?: boolean;
} テスト
Tests verify APG compliance across keyboard interaction, ARIA attributes, focus management, and accessibility requirements. The Radio Group component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library)
Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.
- ARIA attributes (aria-checked, aria-disabled, aria-orientation)
- Keyboard interaction (Arrow keys, Home, End, Space)
- Roving tabindex behavior
- Accessibility via jest-axe
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.
- Click interactions
- Arrow key navigation with looping
- Space key selection
- ARIA structure validation in live browser
- axe-core accessibility scanning
- Cross-framework consistency checks
Test Categories
High Priority: APG ARIA Attributes (Unit + E2E)
| Test | Description |
|---|---|
role="radiogroup" | Container has radiogroup role |
role="radio" | Each option has radio role |
aria-checked | Selected radio has aria-checked="true" |
aria-disabled | Disabled radios have aria-disabled="true" |
aria-orientation | Only set when horizontal (vertical is default) |
accessible name | Group and radios have accessible names |
High Priority: APG Keyboard Interaction (Unit + E2E)
| Test | Description |
|---|---|
Tab focus | Tab focuses selected radio (or first if none) |
Tab exit | Tab/Shift+Tab exits the group |
Space select | Space selects focused radio |
Space no unselect | Space does not unselect already selected radio |
ArrowDown/Right | Moves to next and selects |
ArrowUp/Left | Moves to previous and selects |
Home | Moves to first and selects |
End | Moves to last and selects |
Arrow wrap | Wraps from last to first and vice versa |
Disabled skip | Disabled radios skipped during navigation |
High Priority: Click Interaction (Unit + E2E)
| Test | Description |
|---|---|
Click selects | Clicking radio selects it |
Click changes | Clicking different radio changes selection |
Disabled no click | Clicking disabled radio does not select it |
High Priority: Focus Management - Roving Tabindex (Unit + E2E)
| Test | Description |
|---|---|
tabindex="0" | Selected radio has tabindex="0" |
tabindex="-1" | Non-selected radios have tabindex="-1" |
Disabled tabindex | Disabled radios have tabindex="-1" |
First tabbable | First enabled radio tabbable when none selected |
Single tabbable | Only one tabindex="0" in group at any time |
Medium Priority: Form Integration (Unit)
| Test | Description |
|---|---|
hidden input | Hidden input exists for form submission |
name attribute | Hidden input has correct name |
value sync | Hidden input value reflects selection |
Medium Priority: Accessibility (Unit + E2E)
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe/axe-core) |
selected axe | No violations with selected value |
disabled axe | No violations with disabled option |
Low Priority: Cross-framework Consistency (E2E)
| Test | Description |
|---|---|
All frameworks render | React, Vue, Svelte, Astro all render radio groups |
Consistent click | All frameworks support click to select |
Consistent ARIA | All frameworks have consistent ARIA structure |
Consistent keyboard | All frameworks support keyboard navigation |
Example Test Code
The following is the actual E2E test file (e2e/radio-group.spec.ts).
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Radio Group Pattern
*
* A set of checkable buttons where only one can be checked at a time.
* Uses roving tabindex for focus management.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/radio/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// ============================================
// Helper Functions
// ============================================
const getRadioGroup = (page: import('@playwright/test').Page) => {
return page.getByRole('radiogroup');
};
const getRadios = (page: import('@playwright/test').Page) => {
return page.getByRole('radio');
};
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`Radio Group (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/radio-group/${framework}/demo/`);
await getRadioGroup(page).first().waitFor();
});
// ------------------------------------------
// 🔴 High Priority: APG ARIA Structure
// ------------------------------------------
test.describe('APG: ARIA Structure', () => {
test('container has role="radiogroup"', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
await expect(radiogroup).toHaveRole('radiogroup');
});
test('has multiple radio groups', async ({ page }) => {
const radiogroups = getRadioGroup(page);
const count = await radiogroups.count();
expect(count).toBeGreaterThan(1);
});
test('each option has role="radio"', async ({ page }) => {
const radios = getRadios(page);
const count = await radios.count();
expect(count).toBeGreaterThan(0);
// Verify first few radios have correct role
for (let i = 0; i < Math.min(3, count); i++) {
await expect(radios.nth(i)).toHaveRole('radio');
}
});
test('radiogroup has accessible name', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const ariaLabel = await radiogroup.getAttribute('aria-label');
const ariaLabelledby = await radiogroup.getAttribute('aria-labelledby');
// Must have either aria-label or aria-labelledby
expect(ariaLabel || ariaLabelledby).toBeTruthy();
});
test('each radio has accessible name via aria-labelledby', async ({ page }) => {
const radios = getRadios(page);
const count = await radios.count();
for (let i = 0; i < Math.min(3, count); i++) {
const radio = radios.nth(i);
const labelledby = await radio.getAttribute('aria-labelledby');
expect(labelledby).toBeTruthy();
// Verify the referenced element exists
// Use CSS.escape for IDs that may contain special characters
if (labelledby) {
const labelElement = page.locator(`[id="${labelledby}"]`);
await expect(labelElement).toBeVisible();
}
}
});
test('selected radio has aria-checked="true"', async ({ page }) => {
// Use the group with default value
const radiogroup = getRadioGroup(page).nth(1); // "With Default Value" group
const radios = radiogroup.getByRole('radio');
// Find the selected radio
let selectedCount = 0;
const count = await radios.count();
for (let i = 0; i < count; i++) {
const checked = await radios.nth(i).getAttribute('aria-checked');
if (checked === 'true') {
selectedCount++;
}
}
// Should have exactly one selected
expect(selectedCount).toBe(1);
});
test('non-selected radios have aria-checked="false"', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const count = await radios.count();
// Click first to ensure one is selected
await radios.first().click();
// Check non-selected radios
for (let i = 1; i < count; i++) {
const checked = await radios.nth(i).getAttribute('aria-checked');
expect(checked).toBe('false');
}
});
test('disabled radio has aria-disabled="true"', async ({ page }) => {
// Use the group with disabled option
const radiogroup = getRadioGroup(page).nth(2); // "With Disabled Option" group
const radios = radiogroup.getByRole('radio');
const count = await radios.count();
let foundDisabled = false;
for (let i = 0; i < count; i++) {
const disabled = await radios.nth(i).getAttribute('aria-disabled');
if (disabled === 'true') {
foundDisabled = true;
break;
}
}
expect(foundDisabled).toBe(true);
});
test('aria-orientation is only set when horizontal', async ({ page }) => {
// First group (vertical) - should NOT have aria-orientation
const verticalGroup = getRadioGroup(page).first();
const verticalOrientation = await verticalGroup.getAttribute('aria-orientation');
expect(verticalOrientation).toBeNull();
// Horizontal group - should have aria-orientation="horizontal"
const horizontalGroup = getRadioGroup(page).nth(3); // "Horizontal Orientation" group
const horizontalOrientation = await horizontalGroup.getAttribute('aria-orientation');
expect(horizontalOrientation).toBe('horizontal');
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Interaction
// ------------------------------------------
test.describe('APG: Keyboard Interaction', () => {
test('Tab focuses first radio when none selected', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const firstRadio = radiogroup.getByRole('radio').first();
// Focus the page first
await page.keyboard.press('Tab');
// Find and verify focus is on first radio
await expect(firstRadio).toBeFocused();
});
test('Tab focuses selected radio', async ({ page }) => {
// Use group with default value
const radiogroup = getRadioGroup(page).nth(1);
const radios = radiogroup.getByRole('radio');
// Find the pre-selected radio (Medium)
const mediumRadio = radios.filter({ hasText: 'Medium' });
// Tab to the group
await page.keyboard.press('Tab'); // First group
await page.keyboard.press('Tab'); // Second group (with default)
await expect(mediumRadio).toBeFocused();
});
test('ArrowDown moves to next and selects', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const firstRadio = radios.first();
const secondRadio = radios.nth(1);
await firstRadio.click();
await expect(firstRadio).toBeFocused();
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
await firstRadio.press('ArrowDown');
await expect(secondRadio).toBeFocused();
await expect(secondRadio).toHaveAttribute('aria-checked', 'true');
await expect(firstRadio).toHaveAttribute('aria-checked', 'false');
});
test('ArrowRight moves to next and selects', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const firstRadio = radios.first();
const secondRadio = radios.nth(1);
await firstRadio.click();
await expect(firstRadio).toBeFocused();
await firstRadio.press('ArrowRight');
await expect(secondRadio).toBeFocused();
await expect(secondRadio).toHaveAttribute('aria-checked', 'true');
});
test('ArrowUp moves to previous and selects', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const secondRadio = radios.nth(1);
const firstRadio = radios.first();
await secondRadio.click();
await expect(secondRadio).toBeFocused();
await expect(secondRadio).toHaveAttribute('aria-checked', 'true');
await secondRadio.press('ArrowUp');
await expect(firstRadio).toBeFocused();
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
});
test('ArrowLeft moves to previous and selects', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const secondRadio = radios.nth(1);
const firstRadio = radios.first();
await secondRadio.click();
await expect(secondRadio).toBeFocused();
await secondRadio.press('ArrowLeft');
await expect(firstRadio).toBeFocused();
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
});
test('Arrow keys wrap from last to first', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const lastRadio = radios.last();
const firstRadio = radios.first();
await lastRadio.click();
await expect(lastRadio).toHaveAttribute('aria-checked', 'true');
await expect(lastRadio).toBeFocused();
await lastRadio.press('ArrowDown');
await expect(firstRadio).toBeFocused();
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
});
test('Arrow keys wrap from first to last', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const firstRadio = radios.first();
const lastRadio = radios.last();
await firstRadio.click();
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
await expect(firstRadio).toBeFocused();
await firstRadio.press('ArrowUp');
await expect(lastRadio).toBeFocused();
await expect(lastRadio).toHaveAttribute('aria-checked', 'true');
});
test('Home moves to first and selects', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const lastRadio = radios.last();
const firstRadio = radios.first();
await lastRadio.click();
await expect(lastRadio).toBeFocused();
await lastRadio.press('Home');
await expect(firstRadio).toBeFocused();
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
});
test('End moves to last and selects', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const firstRadio = radios.first();
const lastRadio = radios.last();
await firstRadio.click();
await expect(firstRadio).toBeFocused();
await firstRadio.press('End');
await expect(lastRadio).toBeFocused();
await expect(lastRadio).toHaveAttribute('aria-checked', 'true');
});
test('Space selects focused radio', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const firstRadio = radios.first();
const secondRadio = radios.nth(1);
// Click first to select and focus
await firstRadio.click();
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
// Move to second with arrow (focus without selecting in manual mode would need manual mode)
// In automatic mode, arrow already selects, so test Space on already selected
await firstRadio.press('ArrowDown');
await expect(secondRadio).toBeFocused();
// Press Space - should keep it selected (confirms Space works)
await secondRadio.press('Space');
await expect(secondRadio).toHaveAttribute('aria-checked', 'true');
});
test('Space does not unselect already selected radio', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const firstRadio = radios.first();
// Select first radio
await firstRadio.click();
await expect(firstRadio).toBeFocused();
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
// Press Space again - should stay selected
await firstRadio.press('Space');
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
});
test('Arrow keys skip disabled radios', async ({ page }) => {
// Use group with disabled option
const radiogroup = getRadioGroup(page).nth(2);
const radios = radiogroup.getByRole('radio');
// Find enabled radios
const enabledRadios: import('@playwright/test').Locator[] = [];
const count = await radios.count();
for (let i = 0; i < count; i++) {
const disabled = await radios.nth(i).getAttribute('aria-disabled');
if (disabled !== 'true') {
enabledRadios.push(radios.nth(i));
}
}
// Start from first enabled radio
await enabledRadios[0].click();
await expect(enabledRadios[0]).toBeFocused();
await expect(enabledRadios[0]).toHaveAttribute('aria-checked', 'true');
// Press ArrowDown - should skip disabled and go to next enabled
await enabledRadios[0].press('ArrowDown');
// Should be on next enabled radio (skipping disabled)
const focusedElement = page.locator(':focus');
const focusedDisabled = await focusedElement.getAttribute('aria-disabled');
expect(focusedDisabled).not.toBe('true');
});
});
// ------------------------------------------
// 🔴 High Priority: Click Interaction
// ------------------------------------------
test.describe('APG: Click Interaction', () => {
test('clicking radio selects it', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const secondRadio = radios.nth(1);
await secondRadio.click();
await expect(secondRadio).toHaveAttribute('aria-checked', 'true');
await expect(secondRadio).toBeFocused();
});
test('clicking different radio changes selection', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const firstRadio = radios.first();
const secondRadio = radios.nth(1);
await firstRadio.click();
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
await secondRadio.click();
await expect(secondRadio).toHaveAttribute('aria-checked', 'true');
await expect(firstRadio).toHaveAttribute('aria-checked', 'false');
});
test('clicking disabled radio does not select it', async ({ page }) => {
// Use group with disabled option
const radiogroup = getRadioGroup(page).nth(2);
const radios = radiogroup.getByRole('radio');
// Find disabled radio
let disabledRadio: import('@playwright/test').Locator | null = null;
const count = await radios.count();
for (let i = 0; i < count; i++) {
const disabled = await radios.nth(i).getAttribute('aria-disabled');
if (disabled === 'true') {
disabledRadio = radios.nth(i);
break;
}
}
expect(disabledRadio).not.toBeNull();
// Click disabled radio (force: true to bypass disabled check)
await disabledRadio!.click({ force: true });
// Should still be unchecked
await expect(disabledRadio!).toHaveAttribute('aria-checked', 'false');
});
});
// ------------------------------------------
// 🔴 High Priority: Focus Management (Roving Tabindex)
// ------------------------------------------
test.describe('APG: Roving Tabindex', () => {
test('selected radio has tabindex="0"', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const secondRadio = radios.nth(1);
await secondRadio.click();
await expect(secondRadio).toHaveAttribute('tabindex', '0');
});
test('non-selected radios have tabindex="-1"', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const count = await radios.count();
// Click first to select it
await radios.first().click();
// Check non-selected radios
for (let i = 1; i < count; i++) {
const radio = radios.nth(i);
const disabled = await radio.getAttribute('aria-disabled');
// Only enabled non-selected radios should have tabindex="-1"
if (disabled !== 'true') {
await expect(radio).toHaveAttribute('tabindex', '-1');
}
}
});
test('only one radio has tabindex="0" in group', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const count = await radios.count();
// Click first to ensure selection
await radios.first().click();
// Count radios with tabindex="0"
let tabbableCount = 0;
for (let i = 0; i < count; i++) {
const tabindex = await radios.nth(i).getAttribute('tabindex');
if (tabindex === '0') {
tabbableCount++;
}
}
expect(tabbableCount).toBe(1);
});
test('disabled radios always have tabindex="-1"', async ({ page }) => {
// Use group with disabled option
const radiogroup = getRadioGroup(page).nth(2);
const radios = radiogroup.getByRole('radio');
const count = await radios.count();
for (let i = 0; i < count; i++) {
const radio = radios.nth(i);
const disabled = await radio.getAttribute('aria-disabled');
if (disabled === 'true') {
await expect(radio).toHaveAttribute('tabindex', '-1');
}
}
});
test('first enabled radio is tabbable when none selected', async ({ page }) => {
const radiogroup = getRadioGroup(page).first();
const firstRadio = radiogroup.getByRole('radio').first();
// First radio should be tabbable initially
await expect(firstRadio).toHaveAttribute('tabindex', '0');
});
});
// ------------------------------------------
// 🟢 Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
await getRadioGroup(page).first().waitFor();
const results = await new AxeBuilder({ page })
.include('[role="radiogroup"]')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('Radio Group - Cross-framework Consistency', () => {
test('all frameworks render radio groups', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/radio-group/${framework}/demo/`);
await getRadioGroup(page).first().waitFor();
const radiogroups = getRadioGroup(page);
const count = await radiogroups.count();
expect(count).toBeGreaterThan(0);
}
});
test('all frameworks support click to select', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/radio-group/${framework}/demo/`);
await getRadioGroup(page).first().waitFor();
const radiogroup = getRadioGroup(page).first();
const secondRadio = radiogroup.getByRole('radio').nth(1);
await secondRadio.click();
await expect(secondRadio).toHaveAttribute('aria-checked', 'true');
}
});
test('all frameworks have consistent ARIA structure', async ({ page }) => {
test.setTimeout(60000);
for (const framework of frameworks) {
await page.goto(`patterns/radio-group/${framework}/demo/`);
await getRadioGroup(page).first().waitFor();
// Check radiogroup role
const radiogroup = getRadioGroup(page).first();
await expect(radiogroup).toHaveRole('radiogroup');
// Check radio role
const radios = radiogroup.getByRole('radio');
const count = await radios.count();
expect(count).toBeGreaterThan(0);
// Check aria-checked attribute exists
const firstRadio = radios.first();
const ariaChecked = await firstRadio.getAttribute('aria-checked');
expect(ariaChecked === 'true' || ariaChecked === 'false').toBe(true);
}
});
test('all frameworks support keyboard navigation', async ({ page }) => {
test.setTimeout(60000);
for (const framework of frameworks) {
await page.goto(`patterns/radio-group/${framework}/demo/`);
await getRadioGroup(page).first().waitFor();
const radiogroup = getRadioGroup(page).first();
const radios = radiogroup.getByRole('radio');
const firstRadio = radios.first();
const secondRadio = radios.nth(1);
// Click first to focus
await firstRadio.click();
await expect(firstRadio).toHaveAttribute('aria-checked', 'true');
await expect(firstRadio).toBeFocused();
// Arrow down should select second
await firstRadio.press('ArrowDown');
await expect(secondRadio).toBeFocused();
await expect(secondRadio).toHaveAttribute('aria-checked', 'true');
}
});
}); Running Tests
# Run unit tests for Radio Group
npm run test -- radio-group
# Run E2E tests for Radio Group (all frameworks)
npm run test:e2e:pattern --pattern=radio-group
# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=radio-group
npm run test:e2e:vue:pattern --pattern=radio-group
npm run test:e2e:svelte:pattern --pattern=radio-group
npm run test:e2e:astro:pattern --pattern=radio-group Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { RadioGroup } from './RadioGroup';
const defaultOptions = [
{ id: 'red', label: 'Red', value: 'red' },
{ id: 'blue', label: 'Blue', value: 'blue' },
{ id: 'green', label: 'Green', value: 'green' },
];
const optionsWithDisabled = [
{ id: 'red', label: 'Red', value: 'red' },
{ id: 'blue', label: 'Blue', value: 'blue', disabled: true },
{ id: 'green', label: 'Green', value: 'green' },
];
describe('RadioGroup', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG ARIA Attributes', () => {
it('has role="radiogroup" on container', () => {
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
});
it('has role="radio" on each option', () => {
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
const radios = screen.getAllByRole('radio');
expect(radios).toHaveLength(3);
});
it('has aria-checked attribute on radios', () => {
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
const radios = screen.getAllByRole('radio');
radios.forEach((radio) => {
expect(radio).toHaveAttribute('aria-checked');
});
});
it('sets aria-checked="true" on selected radio', () => {
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="blue"
/>
);
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'false');
expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('aria-checked', 'false');
});
it('sets accessible name on radiogroup via aria-label', () => {
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
expect(screen.getByRole('radiogroup', { name: 'Favorite color' })).toBeInTheDocument();
});
it('sets accessible name on radiogroup via aria-labelledby', () => {
render(
<>
<span id="color-label">Choose a color</span>
<RadioGroup options={defaultOptions} name="color" aria-labelledby="color-label" />
</>
);
expect(screen.getByRole('radiogroup', { name: 'Choose a color' })).toBeInTheDocument();
});
it('sets accessible name on each radio', () => {
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
expect(screen.getByRole('radio', { name: 'Red' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Blue' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Green' })).toBeInTheDocument();
});
it('sets aria-disabled="true" on disabled radio', () => {
render(<RadioGroup options={optionsWithDisabled} name="color" aria-label="Favorite color" />);
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('aria-disabled', 'true');
});
it('sets aria-orientation="horizontal" only when orientation is horizontal', () => {
const { rerender } = render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
orientation="horizontal"
/>
);
expect(screen.getByRole('radiogroup')).toHaveAttribute('aria-orientation', 'horizontal');
rerender(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
orientation="vertical"
/>
);
expect(screen.getByRole('radiogroup')).not.toHaveAttribute('aria-orientation');
rerender(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
expect(screen.getByRole('radiogroup')).not.toHaveAttribute('aria-orientation');
});
});
// 🔴 High Priority: APG Keyboard Interaction
describe('APG Keyboard Interaction', () => {
it('focuses selected radio on Tab when one is selected', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="blue"
/>
</>
);
await user.tab();
expect(screen.getByText('Before')).toHaveFocus();
await user.tab();
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveFocus();
});
it('focuses first radio on Tab when none is selected', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />
</>
);
await user.tab();
await user.tab();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
});
it('exits group on Tab from focused radio', async () => {
const user = userEvent.setup();
render(
<>
<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />
<button>After</button>
</>
);
await user.tab();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
await user.tab();
expect(screen.getByText('After')).toHaveFocus();
});
it('exits group on Shift+Tab from focused radio', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />
</>
);
await user.tab();
expect(screen.getByText('Before')).toHaveFocus();
await user.tab();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
await user.tab({ shift: true });
expect(screen.getByText('Before')).toHaveFocus();
});
it('selects focused radio on Space', async () => {
const user = userEvent.setup();
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
await user.tab();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
await user.keyboard(' ');
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'true');
});
it('does not unselect radio on Space when already selected', async () => {
const user = userEvent.setup();
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="red"
/>
);
await user.tab();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
await user.keyboard(' ');
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'true');
});
it('moves to next radio and selects on ArrowDown', async () => {
const user = userEvent.setup();
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
await user.tab();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('aria-checked', 'true');
});
it('moves to next radio and selects on ArrowRight', async () => {
const user = userEvent.setup();
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
await user.tab();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('aria-checked', 'true');
});
it('moves to previous radio and selects on ArrowUp', async () => {
const user = userEvent.setup();
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="blue"
/>
);
await user.tab();
await user.keyboard('{ArrowUp}');
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'true');
});
it('moves to previous radio and selects on ArrowLeft', async () => {
const user = userEvent.setup();
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="blue"
/>
);
await user.tab();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'true');
});
it('moves to first radio and selects on Home', async () => {
const user = userEvent.setup();
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="green"
/>
);
await user.tab();
await user.keyboard('{Home}');
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'true');
});
it('moves to last radio and selects on End', async () => {
const user = userEvent.setup();
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
await user.tab();
await user.keyboard('{End}');
expect(screen.getByRole('radio', { name: 'Green' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('aria-checked', 'true');
});
it('wraps from last to first on ArrowDown', async () => {
const user = userEvent.setup();
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="green"
/>
);
await user.tab();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'true');
});
it('wraps from first to last on ArrowUp', async () => {
const user = userEvent.setup();
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
await user.tab();
await user.keyboard('{ArrowUp}');
expect(screen.getByRole('radio', { name: 'Green' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('aria-checked', 'true');
});
it('skips disabled radio on ArrowDown', async () => {
const user = userEvent.setup();
render(<RadioGroup options={optionsWithDisabled} name="color" aria-label="Favorite color" />);
await user.tab();
await user.keyboard('{ArrowDown}');
// Should skip Blue (disabled) and go to Green
expect(screen.getByRole('radio', { name: 'Green' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('aria-checked', 'true');
});
it('skips disabled radio on ArrowUp', async () => {
const user = userEvent.setup();
render(
<RadioGroup
options={optionsWithDisabled}
name="color"
aria-label="Favorite color"
defaultValue="green"
/>
);
await user.tab();
await user.keyboard('{ArrowUp}');
// Should skip Blue (disabled) and go to Red
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'true');
});
it('skips disabled radio on ArrowLeft', async () => {
const user = userEvent.setup();
render(
<RadioGroup
options={optionsWithDisabled}
name="color"
aria-label="Favorite color"
defaultValue="green"
/>
);
await user.tab();
await user.keyboard('{ArrowLeft}');
// Should skip Blue (disabled) and go to Red
expect(screen.getByRole('radio', { name: 'Red' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'true');
});
it('skips disabled radio on ArrowRight', async () => {
const user = userEvent.setup();
render(<RadioGroup options={optionsWithDisabled} name="color" aria-label="Favorite color" />);
await user.tab();
await user.keyboard('{ArrowRight}');
// Should skip Blue (disabled) and go to Green
expect(screen.getByRole('radio', { name: 'Green' })).toHaveFocus();
expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('aria-checked', 'true');
});
it('does not select disabled radio on Space', async () => {
const user = userEvent.setup();
render(<RadioGroup options={optionsWithDisabled} name="color" aria-label="Favorite color" />);
const blueRadio = screen.getByRole('radio', { name: 'Blue' });
blueRadio.focus();
await user.keyboard(' ');
expect(blueRadio).toHaveAttribute('aria-checked', 'false');
});
it('does not select disabled radio on click', async () => {
const user = userEvent.setup();
render(<RadioGroup options={optionsWithDisabled} name="color" aria-label="Favorite color" />);
const blueRadio = screen.getByRole('radio', { name: 'Blue' });
await user.click(blueRadio);
expect(blueRadio).toHaveAttribute('aria-checked', 'false');
});
});
// 🔴 High Priority: Focus Management (Roving Tabindex)
describe('Focus Management (Roving Tabindex)', () => {
it('sets tabindex="0" on selected radio', () => {
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="blue"
/>
);
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('tabIndex', '0');
});
it('sets tabindex="-1" on non-selected radios', () => {
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="blue"
/>
);
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('tabIndex', '-1');
expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('tabIndex', '-1');
});
it('sets tabindex="-1" on disabled radios', () => {
render(<RadioGroup options={optionsWithDisabled} name="color" aria-label="Favorite color" />);
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('tabIndex', '-1');
});
it('sets tabindex="0" on first enabled radio when none selected', () => {
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('tabIndex', '0');
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('tabIndex', '-1');
expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('tabIndex', '-1');
});
it('sets tabindex="0" on first non-disabled radio when first is disabled', () => {
const options = [
{ id: 'red', label: 'Red', value: 'red', disabled: true },
{ id: 'blue', label: 'Blue', value: 'blue' },
{ id: 'green', label: 'Green', value: 'green' },
];
render(<RadioGroup options={options} name="color" aria-label="Favorite color" />);
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('tabIndex', '-1');
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('tabIndex', '0');
});
it('has only one tabindex="0" in the group', () => {
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="blue"
/>
);
const radios = screen.getAllByRole('radio');
const tabbableRadios = radios.filter((radio) => radio.getAttribute('tabIndex') === '0');
expect(tabbableRadios).toHaveLength(1);
});
});
// 🔴 High Priority: Selection Behavior
describe('Selection Behavior', () => {
it('selects radio on click', async () => {
const user = userEvent.setup();
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
await user.click(screen.getByRole('radio', { name: 'Blue' }));
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('aria-checked', 'true');
});
it('deselects previous radio when clicking another', async () => {
const user = userEvent.setup();
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="red"
/>
);
await user.click(screen.getByRole('radio', { name: 'Blue' }));
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'false');
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('aria-checked', 'true');
});
it('updates aria-checked on keyboard selection', async () => {
const user = userEvent.setup();
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
await user.tab();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('radio', { name: 'Blue' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('radio', { name: 'Red' })).toHaveAttribute('aria-checked', 'false');
});
});
// 🟡 Medium Priority: Form Integration
describe('Form Integration', () => {
it('has hidden input for form submission', () => {
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
const hiddenInput = document.querySelector('input[type="hidden"][name="color"]');
expect(hiddenInput).toBeInTheDocument();
});
it('hidden input has correct name attribute', () => {
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
const hiddenInput = document.querySelector('input[type="hidden"]');
expect(hiddenInput).toHaveAttribute('name', 'color');
});
it('hidden input value reflects selected value', async () => {
const user = userEvent.setup();
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
const hiddenInput = document.querySelector('input[type="hidden"]') as HTMLInputElement;
expect(hiddenInput.value).toBe('');
await user.click(screen.getByRole('radio', { name: 'Blue' }));
expect(hiddenInput.value).toBe('blue');
});
it('hidden input has defaultValue on initial render', () => {
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="green"
/>
);
const hiddenInput = document.querySelector('input[type="hidden"]') as HTMLInputElement;
expect(hiddenInput.value).toBe('green');
});
it('hidden input value updates on keyboard selection', async () => {
const user = userEvent.setup();
render(<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />);
const hiddenInput = document.querySelector('input[type="hidden"]') as HTMLInputElement;
expect(hiddenInput.value).toBe('');
await user.tab();
await user.keyboard('{ArrowDown}');
expect(hiddenInput.value).toBe('blue');
await user.keyboard('{ArrowDown}');
expect(hiddenInput.value).toBe('green');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(
<RadioGroup options={defaultOptions} name="color" aria-label="Favorite color" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with selected value', async () => {
const { container } = render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="blue"
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with disabled option', async () => {
const { container } = render(
<RadioGroup options={optionsWithDisabled} name="color" aria-label="Favorite color" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with horizontal orientation', async () => {
const { container } = render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
orientation="horizontal"
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props & Behavior
describe('Props & Behavior', () => {
it('calls onValueChange when selection changes', async () => {
const handleValueChange = vi.fn();
const user = userEvent.setup();
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
onValueChange={handleValueChange}
/>
);
await user.click(screen.getByRole('radio', { name: 'Blue' }));
expect(handleValueChange).toHaveBeenCalledWith('blue');
});
it('applies className to container', () => {
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
className="custom-class"
/>
);
expect(screen.getByRole('radiogroup')).toHaveClass('custom-class');
});
it('renders with defaultValue', () => {
render(
<RadioGroup
options={defaultOptions}
name="color"
aria-label="Favorite color"
defaultValue="green"
/>
);
expect(screen.getByRole('radio', { name: 'Green' })).toHaveAttribute('aria-checked', 'true');
});
});
}); リソース
- WAI-ARIA APG: Radio Group パターン (opens in new tab)
- MDN: <input type="radio"> (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist