Radio Group
ラジオボタンと呼ばれるチェック可能なボタンのセットで、一度に1つだけチェックできます。
🤖 AI 実装ガイドデモ
基本的なラジオグループ
矢印キーでナビゲートして選択します。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 ロール
| ロール | 要素 | 説明 |
|---|---|---|
radiogroup | コンテナ要素 |
ラジオボタンをグループ化します。aria-label または
aria-labelledby によるアクセシブルな名前が必須です。
|
radio | 各オプション要素 | 要素をラジオボタンとして識別します。グループ内で一度に1つのラジオのみが選択可能です。 |
この実装では、クロスブラウザでの一貫したキーボード動作のため、カスタムの role="radiogroup" と role="radio" を使用しています。ネイティブの <input type="radio"> はこれらのロールを暗黙的に提供します。
WAI-ARIA ステート
aria-checked
ラジオボタンの現在のチェック状態を示します。グループ内で1つのラジオのみが
aria-checked="true" を持つべきです。
| 値 | true | false |
| 必須 | はい(各ラジオボタンに) |
| 変更トリガー | クリック、Space、矢印キー |
aria-disabled
ラジオボタンがインタラクティブでなく、選択できないことを示します。
| 値 | true(無効時のみ) |
| 必須 | いいえ(無効時のみ) |
| 効果 | 矢印キーナビゲーション中はスキップされ、選択できません |
WAI-ARIA プロパティ
aria-orientation
ラジオグループの方向を示します。垂直がデフォルトです。
| 値 | horizontal | vertical(デフォルト) |
| 必須 | いいえ(水平方向時のみ設定) |
| 注記 | この実装では、方向に関わらずすべての矢印キーをサポートします |
キーボードサポート
| キー | アクション |
|---|---|
| Tab | グループにフォーカスを移動(選択されたラジオまたは最初のラジオへ) |
| Shift + Tab | グループからフォーカスを移動 |
| Space | フォーカスされたラジオを選択(選択解除はしない) |
| Arrow Down / Right | 次のラジオに移動して選択(最初にラップ) |
| Arrow Up / Left | 前のラジオに移動して選択(最後にラップ) |
| Home | 最初のラジオに移動して選択 |
| End | 最後のラジオに移動して選択 |
注記: チェックボックスとは異なり、矢印キーはフォーカス移動と選択変更の両方を行います。無効化されたラジオはナビゲーション中にスキップされます。
フォーカス管理(ローヴィングタブインデックス)
ラジオグループはローヴィングタブインデックスを使用してフォーカスを管理します。グループ内で一度に1つのラジオのみがタブ可能です:
- 選択されたラジオ は
tabindex="0"を持ちます - 何も選択されていない場合、最初の有効なラジオが
tabindex="0"を持ちます - 他のすべてのラジオ は
tabindex="-1"を持ちます - 無効なラジオ は常に
tabindex="-1"を持ちます
アクセシブルな名前付け
ラジオグループと個々のラジオの両方にアクセシブルな名前が必要です:
- ラジオグループ - コンテナに
aria-labelまたはaria-labelledbyを使用します - 個々のラジオ - 各ラジオは
aria-labelledbyを介して可視テキストコンテンツでラベル付けされます - ネイティブの代替 - グループのラベル付けには
<fieldset>と<legend>を使用します
ビジュアルデザイン
この実装は、状態を示すために色のみに依存しないことで WCAG 1.4.1(色の使用)に従っています:
- 塗りつぶされた円 - 選択状態を示します
- 空の円 - 未選択状態を示します
- 不透明度の低下 - 無効状態を示します
- 強制カラーモード - Windows ハイコントラストモードでのアクセシビリティのためシステムカラーを使用します
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;
} テスト
テストは、キーボード操作、ARIA属性、フォーカス管理、アクセシビリティ要件全般にわたるAPG準拠を検証します。
テストカテゴリ
高優先度: APG ARIA 属性
| テスト | 説明 |
|---|---|
role="radiogroup" | コンテナがradiogroupロールを持つ |
role="radio" | 各オプションがradioロールを持つ |
aria-checked | 選択されたラジオが aria-checked="true" を持つ |
aria-disabled | 無効なラジオが aria-disabled="true" を持つ |
aria-orientation | 水平方向時のみ設定される(垂直がデフォルト) |
accessible name | グループとラジオがアクセシブルな名前を持つ |
高優先度: APG キーボード操作
| テスト | 説明 |
|---|---|
Tab focus | Tabで選択されたラジオ(または何もなければ最初)にフォーカス |
Tab exit | Tab/Shift+Tabでグループから退出 |
Space select | Spaceでフォーカスされたラジオを選択 |
Space no unselect | Spaceは既に選択されたラジオの選択を解除しない |
ArrowDown/Right | 次へ移動して選択 |
ArrowUp/Left | 前へ移動して選択 |
Home | 最初へ移動して選択 |
End | 最後へ移動して選択 |
Arrow wrap | 最後から最初へ、またはその逆にラップ |
Disabled skip | ナビゲーション中に無効なラジオをスキップ |
高優先度: フォーカス管理(ローヴィングタブインデックス)
| テスト | 説明 |
|---|---|
tabindex="0" | 選択されたラジオがtabindex="0"を持つ |
tabindex="-1" | 非選択のラジオがtabindex="-1"を持つ |
Disabled tabindex | 無効なラジオがtabindex="-1"を持つ |
First tabbable | 何も選択されていない場合、最初の有効なラジオがタブ可能 |
Single tabbable | グループ内で常に1つのみがtabindex="0" |
中優先度: フォーム統合
| テスト | 説明 |
|---|---|
hidden input | フォーム送信用の非表示inputが存在する |
name attribute | 非表示inputが正しいnameを持つ |
value sync | 非表示inputの値が選択を反映する |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AA違反がない(jest-axeによる) |
selected axe | 選択された値での違反がない |
disabled axe | 無効なオプションでの違反がない |
低優先度: Props と動作
| テスト | 説明 |
|---|---|
onValueChange | 選択変更時にコールバックが発火する |
defaultValue | defaultValueからの初期選択 |
className | カスタムクラスがコンテナに適用される |
テストツール
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
詳細は testing-strategy.md (opens in new tab) を参照してください。
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');
});
});
});