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.tsx
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;

使い方

Example
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

プロパティ デフォルト 説明
options RadioOption[] required ラジオオプションの配列
name string required フォーム送信用のグループ名
aria-label string - グループのアクセシブルなラベル
aria-labelledby string - ラベル要素のID
defaultValue string "" 初期選択値
orientation 'horizontal' | 'vertical' 'vertical' レイアウトの向き
onValueChange (value: string) => void - 選択変更時のコールバック
className string - 追加のCSSクラス

テスト

テストは、キーボード操作、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) を参照してください。

RadioGroup.test.tsx
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');
    });
  });
});

リソース