Radio Group
一度に1つだけチェックできるチェック可能なボタンのセット。
🤖 AI Implementation Guideデモ
基本的な Radio Group
矢印キーを使用してナビゲートと選択を行います。Tab キーでグループの内外にフォーカスを移動します。
デフォルト値あり
defaultValue プロパティを使用した事前選択されたオプション。
無効なオプションあり
無効なオプションはキーボードナビゲーション中にスキップされます。
水平方向
orientation="horizontal" を指定した水平レイアウト。
ネイティブ 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
ソースコード
---
/**
* 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
Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
options | RadioOption[] | required | ラジオオプションの配列 |
name | string | required | フォーム送信用のグループ名 |
aria-label | string | - | グループのアクセシブルラベル |
aria-labelledby | string | - | ラベル要素の ID |
defaultValue | string | "" | 初期選択値 |
orientation | 'horizontal' | 'vertical' | 'vertical' | レイアウトの方向 |
class | string | - | 追加の CSS クラス |
カスタムイベント
| イベント | Detail | 説明 |
|---|---|---|
valuechange | { value: string } | 選択が変更されたときに発行される |
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) を参照してください。
リソース
- 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