APG Patterns
English
English

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ハイコントラストモードでのアクセシビリティのためにシステムカラーを使用

参考資料

ソースコード

RadioGroup.astro
---
/**
 * 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>

使い方

Example
---
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).

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

テストツール

詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。

リソース