Radio Group
A set of checkable buttons where only one can be checked at a time.
Demo
Basic Radio Group
Use arrow keys to navigate and select. Tab moves focus in/out of the group.
With Default Value
Pre-selected option using the defaultValue prop.
With Disabled Option
Disabled options are skipped during keyboard navigation.
Horizontal Orientation
Horizontal layout with 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.
Accessibility Features
WAI-ARIA Roles
| Role | Target 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. |
WAI-ARIA Properties
aria-orientation
Indicates the orientation of the radio group. Vertical is the default. Only set when horizontal.
- Values
- horizontal | vertical
- Required
- No
aria-label
Accessible name for the radio group
- Values
- String
- Required
- Yes (or aria-labelledby)
aria-labelledby
Alternative to aria-label
- Values
- ID reference
- Required
- Yes (or aria-label)
WAI-ARIA States
aria-checked
- Target Element
- Each radio
- Values
- true | false
- Required
- Yes
- Change Trigger
- Click, Space, Arrow keys
aria-disabled
- Target Element
- Disabled radio
- Values
- true
- Required
- No
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) |
| ArrowDown / ArrowRight | Move to next radio and select (wraps to first) |
| ArrowUp / ArrowLeft | Move to previous radio and select (wraps to last) |
| Home | Move to first radio and select |
| End | Move to last radio and select |
- Unlike Checkbox, arrow keys both move focus AND change selection.
- Disabled radios are skipped during navigation.
- This implementation uses custom role=“radiogroup” and role=“radio” for consistent cross-browser keyboard behavior. Native
<input type="radio">provides these roles implicitly.
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
Focus Management
| Event | Behavior |
|---|---|
| Roving tabindex | 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" |
Visual Design
This implementation follows WCAG 1.4.1 (Use of Color) by not relying solely on color to indicate state:
- Selected — Filled circle
- Unselected — Empty circle
- Disabled — Reduced opacity
- Forced colors mode — Uses system colors for accessibility in Windows High Contrast Mode
References
Source Code
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; Usage
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
| Prop | Type | Default | Description |
|---|---|---|---|
options | RadioOption[] | required | Array of radio options |
name | string | required | Group name for form submission |
aria-label | string | - | Accessible label for the group |
aria-labelledby | string | - | ID of labeling element |
defaultValue | string | "" | Initially selected value |
orientation | 'horizontal' | 'vertical' | 'vertical' | Layout orientation |
onValueChange | (value: string) => void | - | Callback when selection changes |
className | string | - | Additional CSS class |
Testing
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 : 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 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');
});
});
}); Resources
- WAI-ARIA APG: Radio Group Pattern (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