Radio Group
ラジオボタンと呼ばれるチェック可能なボタンのセットで、一度に1つだけチェックできます。
デモ
基本的なラジオグループ
矢印キーで移動・選択します。Tabでグループの内外にフォーカスを移動します。
デフォルト値あり
defaultValue propで事前に選択されたオプション。
無効なオプションを含む場合
無効なオプションはキーボード操作時にスキップされます。
水平方向
orientation="horizontal" による水平レイアウト。
ネイティブ HTML
ネイティブ HTML を優先
このカスタムコンポーネントを使用する前に、ネイティブの <input type="radio"> 要素と <fieldset>、<legend> の使用を検討してください。 組み込みのアクセシビリティを提供し、JavaScript なしで動作し、ARIA 属性は不要です。
<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> 一貫したクロスブラウザキーボード動作や、ネイティブ要素では実現できないカスタムスタイリングが必要な場合にカスタム実装を使用してください。
| ユースケース | ネイティブ HTML | カスタム実装 |
|---|---|---|
| 基本的なフォーム入力 | 推奨 | 不要 |
| JavaScript 無効時のサポート | ネイティブで動作 | フォールバックが必要 |
| 矢印キーナビゲーション | ブラウザ依存* | 一貫した動作 |
| カスタムスタイリング | 限定的(ブラウザ依存) | 完全な制御 |
| フォーム送信 | 組み込み | 隠し input が必要 |
*ネイティブラジオのキーボード動作はブラウザによって異なります。一部のブラウザでは、APG のキーボード操作(Home/End など)がデフォルトでサポートされていない場合があります。
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
radiogroup | コンテナ要素 | ラジオボタンをグループ化します。aria-labelまたはaria-labelledbyでアクセシブルな名前を持つ必要があります。 |
radio | 各オプション要素 | 要素をラジオボタンとして識別します。グループ内で一度にチェックできるのは1つのラジオのみです。 |
WAI-ARIA プロパティ
aria-orientation
ラジオグループの方向を示します。デフォルトは縦方向です。横方向の場合のみ設定します。
- 値
- horizontal | vertical
- 必須
- いいえ
aria-label
ラジオグループのアクセシブルな名前
- 値
- String
- 必須
- はい(またはaria-labelledby)
aria-labelledby
aria-labelの代替
- 値
- ID参照
- 必須
- はい(またはaria-label)
WAI-ARIA ステート
aria-checked
- 対象要素
- 各ラジオ
- 値
- true | false
- 必須
- はい
- 変更トリガー
- Click、Space、矢印キー
aria-disabled
- 対象要素
- 無効化されたラジオ
- 値
- true
- 必須
- いいえ
キーボードサポート
| キー | アクション |
|---|---|
| Tab | グループにフォーカスを移動(選択された、または最初のラジオへ) |
| Shift + Tab | グループからフォーカスを移動 |
| Space | フォーカスされたラジオを選択(選択解除はしない) |
| ArrowDown / ArrowRight | 次のラジオに移動して選択(最初に戻る) |
| ArrowUp / ArrowLeft | 前のラジオに移動して選択(最後に戻る) |
| Home | 最初のラジオに移動して選択 |
| End | 最後のラジオに移動して選択 |
- Checkboxとは異なり、矢印キーはフォーカスの移動と選択の変更の両方を行います。
- 無効化されたラジオはナビゲーション中にスキップされます。
- この実装は、一貫したクロスブラウザキーボード動作のためにカスタムrole=“radiogroup”とrole=“radio”を使用します。ネイティブ
<input type="radio">はこれらのロールを暗黙的に提供します。
アクセシブルな名前
ラジオグループと個々のラジオの両方にアクセシブルな名前が必要です:
- ラジオグループ — コンテナに
aria-labelまたはaria-labelledbyを使用 - 個々のラジオ — 各ラジオは
aria-labelledbyを介して表示テキストでラベル付け - ネイティブの代替 — グループラベル付けに
<fieldset>と<legend>を使用
フォーカス管理
| イベント | 振る舞い |
|---|---|
| Roving tabindex | グループ内で一度に1つのラジオのみがTab可能 |
| 選択されたラジオ | tabindex="0"を持つ |
| 選択がない場合 | 最初の有効なラジオがtabindex="0"を持つ |
| 他のすべてのラジオ | tabindex="-1"を持つ |
| 無効化されたラジオ | 常にtabindex="-1"を持つ |
ビジュアルデザイン
この実装は、状態を示すために色だけに依存しないことでWCAG 1.4.1(色の使用)に準拠しています:
- 選択済み — 塗りつぶされた円
- 未選択 — 空の円
- 無効化 — 透明度低下
- 強制カラーモード — Windowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用
参考資料
ソースコード
---
/**
* APG Radio Group Pattern - Astro Implementation
*
* A set of checkable buttons where only one can be checked at a time.
* Uses Web Components for keyboard navigation and focus management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/radio/
*/
export interface RadioOption {
id: string;
label: string;
value: string;
disabled?: boolean;
}
export interface Props {
/** 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';
/** Additional CSS class */
class?: string;
}
const {
options,
name,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
defaultValue = '',
orientation = 'vertical',
class: className = '',
} = Astro.props;
// Generate unique ID
const instanceId = `radio-group-${Math.random().toString(36).slice(2, 9)}`;
// Find initial selected value
const getInitialValue = () => {
if (defaultValue) {
const option = options.find((opt) => opt.value === defaultValue);
if (option && !option.disabled) {
return defaultValue;
}
}
return '';
};
const initialValue = getInitialValue();
// Get tabbable value
const getTabbableValue = () => {
if (initialValue) {
return initialValue;
}
const firstEnabled = options.find((opt) => !opt.disabled);
return firstEnabled?.value || '';
};
const tabbableValue = getTabbableValue();
---
<apg-radio-group
class={`apg-radio-group ${className}`.trim()}
role="radiogroup"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-orientation={orientation === 'horizontal' ? 'horizontal' : undefined}
data-name={name}
data-value={initialValue}
>
<!-- Hidden input for form submission -->
<input type="hidden" name={name} value={initialValue} />
{
options.map((option) => {
const isSelected = initialValue === option.value;
const isTabbable = option.value === tabbableValue && !option.disabled;
const tabIndex = option.disabled ? -1 : isTabbable ? 0 : -1;
return (
<div
role="radio"
aria-checked={isSelected ? 'true' : 'false'}
aria-disabled={option.disabled ? 'true' : undefined}
aria-labelledby={`${instanceId}-label-${option.id}`}
tabindex={tabIndex}
data-value={option.value}
data-disabled={option.disabled ? 'true' : undefined}
class={`apg-radio ${isSelected ? 'apg-radio--selected' : ''} ${option.disabled ? 'apg-radio--disabled' : ''}`}
>
<span class="apg-radio-control" aria-hidden="true">
<span class="apg-radio-indicator" />
</span>
<span id={`${instanceId}-label-${option.id}`} class="apg-radio-label">
{option.label}
</span>
</div>
);
})
}
</apg-radio-group>
<script>
class ApgRadioGroup extends HTMLElement {
private radios: HTMLElement[] = [];
private hiddenInput: HTMLInputElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.radios = Array.from(this.querySelectorAll('[role="radio"]'));
this.hiddenInput = this.querySelector('input[type="hidden"]');
this.radios.forEach((radio) => {
radio.addEventListener('click', this.handleClick);
radio.addEventListener('keydown', this.handleKeyDown);
});
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.radios.forEach((radio) => {
radio.removeEventListener('click', this.handleClick);
radio.removeEventListener('keydown', this.handleKeyDown);
});
this.radios = [];
this.hiddenInput = null;
}
private getEnabledRadios(): HTMLElement[] {
return this.radios.filter((radio) => radio.dataset.disabled !== 'true');
}
private selectRadio(radio: HTMLElement) {
if (radio.dataset.disabled === 'true') return;
const value = radio.dataset.value || '';
// Update all radios
this.radios.forEach((r) => {
const isSelected = r === radio;
r.setAttribute('aria-checked', isSelected ? 'true' : 'false');
r.classList.toggle('apg-radio--selected', isSelected);
// Update tabindex (roving)
if (r.dataset.disabled !== 'true') {
r.setAttribute('tabindex', isSelected ? '0' : '-1');
}
});
// Update hidden input
if (this.hiddenInput) {
this.hiddenInput.value = value;
}
// Dispatch event
this.dispatchEvent(
new CustomEvent('valuechange', {
detail: { value },
bubbles: true,
})
);
}
private focusRadio(radio: HTMLElement) {
radio.focus();
}
private navigateAndSelect(direction: 'next' | 'prev' | 'first' | 'last') {
const enabledRadios = this.getEnabledRadios();
if (enabledRadios.length === 0) return;
const currentIndex = enabledRadios.findIndex((r) => r === document.activeElement);
let targetIndex: number;
switch (direction) {
case 'next':
targetIndex = currentIndex >= 0 ? (currentIndex + 1) % enabledRadios.length : 0;
break;
case 'prev':
targetIndex =
currentIndex >= 0
? (currentIndex - 1 + enabledRadios.length) % enabledRadios.length
: enabledRadios.length - 1;
break;
case 'first':
targetIndex = 0;
break;
case 'last':
targetIndex = enabledRadios.length - 1;
break;
}
const targetRadio = enabledRadios[targetIndex];
if (targetRadio) {
this.focusRadio(targetRadio);
this.selectRadio(targetRadio);
}
}
private handleClick = (event: Event) => {
const radio = event.currentTarget as HTMLElement;
if (radio.dataset.disabled !== 'true') {
this.focusRadio(radio);
this.selectRadio(radio);
}
};
private handleKeyDown = (event: KeyboardEvent) => {
const radio = event.currentTarget as HTMLElement;
const { key } = event;
switch (key) {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault();
this.navigateAndSelect('next');
break;
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault();
this.navigateAndSelect('prev');
break;
case 'Home':
event.preventDefault();
this.navigateAndSelect('first');
break;
case 'End':
event.preventDefault();
this.navigateAndSelect('last');
break;
case ' ':
event.preventDefault();
this.selectRadio(radio);
break;
}
};
}
if (!customElements.get('apg-radio-group')) {
customElements.define('apg-radio-group', ApgRadioGroup);
}
</script> 使い方
---
import RadioGroup from './RadioGroup.astro';
const options = [
{ id: 'red', label: 'Red', value: 'red' },
{ id: 'blue', label: 'Blue', value: 'blue' },
{ id: 'green', label: 'Green', value: 'green' },
];
---
<RadioGroup
options={options}
name="color"
aria-label="Favorite color"
defaultValue="blue"
/>
<script>
// Listen for value changes
document.querySelector('apg-radio-group')?.addEventListener('valuechange', (e) => {
console.log('Selected:', e.detail.value);
});
</script> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
options | RadioOption[] | required | ラジオオプションの配列 |
name | string | required | フォーム送信用のグループ名 |
aria-label | string | - | グループのアクセシブルラベル |
aria-labelledby | string | - | ラベル要素の ID |
defaultValue | string | "" | 初期選択値 |
orientation | 'horizontal' | 'vertical' | 'vertical' | レイアウトの方向 |
class | string | - | 追加の CSS クラス |
Custom Events
| イベント | Detail | 説明 |
|---|---|---|
valuechange | { value: string } | 選択が変更されたときに発行される |
テスト
テストは、キーボード操作、ARIA属性、フォーカス管理、アクセシビリティ要件におけるAPG準拠を検証します。Radio Group コンポーネントは2層のテスト戦略を使用しています。
テスト戦略
ユニットテスト(Testing Library)
フレームワーク固有のテストライブラリを使用して、コンポーネントの出力を検証します。正しいHTML構造とARIA属性を確認します。
- ARIA属性(aria-checked、aria-disabled、aria-orientation)
- キーボード操作(矢印キー、Home、End、Space)
- Roving tabindex動作
- jest-axeによるアクセシビリティ
E2Eテスト(Playwright)
実際のブラウザ環境で全フレームワークのコンポーネント動作を検証します。インタラクションとクロスフレームワークの一貫性をカバーします。
- クリック操作
- ループ付き矢印キーナビゲーション
- スペースキー選択
- ライブブラウザでのARIA構造検証
- axe-coreアクセシビリティスキャン
- クロスフレームワーク一貫性チェック
テストカテゴリ
高優先度 : APG ARIA属性(Unit + E2E)
| テスト | 説明 |
|---|---|
role="radiogroup" | コンテナがradiogroupロールを持つ |
role="radio" | 各オプションがradioロールを持つ |
aria-checked | 選択されたラジオがaria-checked="true"を持つ |
aria-disabled | 無効化ラジオがaria-disabled="true"を持つ |
aria-orientation | 横方向の場合のみ設定(縦方向がデフォルト) |
accessible name | グループとラジオがアクセシブルな名前を持つ |
高優先度 : APGキーボード操作(Unit + E2E)
| テスト | 説明 |
|---|---|
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 | ナビゲーション中に無効化ラジオをスキップ |
高優先度 : クリック操作(Unit + E2E)
| テスト | 説明 |
|---|---|
Click selects | クリックでラジオを選択 |
Click changes | 別のラジオをクリックで選択変更 |
Disabled no click | 無効化ラジオをクリックしても選択されない |
高優先度 : フォーカス管理 - Roving Tabindex(Unit + E2E)
| テスト | 説明 |
|---|---|
tabindex="0" | 選択されたラジオがtabindex="0"を持つ |
tabindex="-1" | 非選択ラジオがtabindex="-1"を持つ |
Disabled tabindex | 無効化ラジオがtabindex="-1"を持つ |
First tabbable | 選択がない時、最初の有効なラジオがTab可能 |
Single tabbable | 常にグループ内で1つのみtabindex="0" |
中優先度 : アクセシビリティ(Unit + E2E)
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AA違反なし(jest-axe/axe-core経由) |
selected axe | 選択値ありで違反なし |
disabled axe | 無効化オプションありで違反なし |
低優先度 : クロスフレームワーク一貫性(E2E)
| テスト | 説明 |
|---|---|
All frameworks render | React、Vue、Svelte、Astroがすべてラジオグループをレンダリング |
Consistent click | すべてのフレームワークがクリック選択をサポート |
Consistent ARIA | すべてのフレームワークが一貫したARIA構造を持つ |
Consistent keyboard | すべてのフレームワークがキーボードナビゲーションをサポート |
テストコード例
以下は実際の E2E テストファイルです (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');
}
});
}); テストの実行
# Radio Groupのユニットテストを実行
npm run test -- radio-group
# Radio GroupのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=radio-group
# 特定のフレームワークでE2Eテストを実行
npm run test:e2e:react:pattern --pattern=radio-group テストツール
- Vitest (opens in new tab) - ユニットテストランナー
- Testing Library (opens in new tab) - フレームワーク別テストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。
リソース
- 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