APG Patterns
English GitHub
English GitHub

Radio Group

ラジオボタンと呼ばれるチェック可能なボタンのセットで、一度に1つだけチェックできます。

🤖 AI 実装ガイド

デモ

基本的なラジオグループ

矢印キーでナビゲートして選択します。Tabキーでグループへのフォーカスの出入りを移動します。

デフォルト値の設定

defaultValue プロップを使用して事前に選択されたオプション。

無効化されたオプション

無効化されたオプションはキーボードナビゲーション中にスキップされます。

水平方向の配置

orientation="horizontal" を使用した水平レイアウト。

Native HTML

Use Native HTML First

Before using this custom component, consider using native <input type="radio"> elements with <fieldset> and <legend>. They provide built-in accessibility, work without JavaScript, and require no ARIA attributes.

<fieldset>
  <legend>Favorite color</legend>
  <label><input type="radio" name="color" value="red" /> Red</label>
  <label><input type="radio" name="color" value="blue" /> Blue</label>
  <label><input type="radio" name="color" value="green" /> Green</label>
</fieldset>

Use custom implementations when you need consistent cross-browser keyboard behavior or custom styling that native elements cannot provide.

Use Case Native HTML Custom Implementation
Basic form input Recommended Not needed
JavaScript disabled support Works natively Requires fallback
Arrow key navigation Browser-dependent* Consistent behavior
Custom styling Limited (browser-dependent) Full control
Form submission Built-in Requires hidden input

*Native radio keyboard behavior varies between browsers. Some browsers may not support all APG keyboard interactions (like Home/End) out of the box.

アクセシビリティ

WAI-ARIA ロール

ロール 要素 説明
radiogroup コンテナ要素 ラジオボタンをグループ化します。aria-label または aria-labelledby によるアクセシブルな名前が必須です。
radio 各オプション要素 要素をラジオボタンとして識別します。グループ内で一度に1つのラジオのみが選択可能です。

この実装では、クロスブラウザでの一貫したキーボード動作のため、カスタムの role="radiogroup"role="radio" を使用しています。ネイティブの <input type="radio"> はこれらのロールを暗黙的に提供します。

WAI-ARIA ステート

aria-checked

ラジオボタンの現在のチェック状態を示します。グループ内で1つのラジオのみが aria-checked="true" を持つべきです。

true | false
必須 はい(各ラジオボタンに)
変更トリガー クリック、Space、矢印キー

aria-disabled

ラジオボタンがインタラクティブでなく、選択できないことを示します。

true(無効時のみ)
必須 いいえ(無効時のみ)
効果 矢印キーナビゲーション中はスキップされ、選択できません

WAI-ARIA プロパティ

aria-orientation

ラジオグループの方向を示します。垂直がデフォルトです。

horizontal | vertical(デフォルト)
必須 いいえ(水平方向時のみ設定)
注記 この実装では、方向に関わらずすべての矢印キーをサポートします

キーボードサポート

キー アクション
Tab グループにフォーカスを移動(選択されたラジオまたは最初のラジオへ)
Shift + Tab グループからフォーカスを移動
Space フォーカスされたラジオを選択(選択解除はしない)
Arrow Down / Right 次のラジオに移動して選択(最初にラップ)
Arrow Up / Left 前のラジオに移動して選択(最後にラップ)
Home 最初のラジオに移動して選択
End 最後のラジオに移動して選択

注記: チェックボックスとは異なり、矢印キーはフォーカス移動と選択変更の両方を行います。無効化されたラジオはナビゲーション中にスキップされます。

フォーカス管理(ローヴィングタブインデックス)

ラジオグループはローヴィングタブインデックスを使用してフォーカスを管理します。グループ内で一度に1つのラジオのみがタブ可能です:

  • 選択されたラジオtabindex="0" を持ちます
  • 何も選択されていない場合、最初の有効なラジオが tabindex="0" を持ちます
  • 他のすべてのラジオtabindex="-1" を持ちます
  • 無効なラジオ は常に tabindex="-1" を持ちます

アクセシブルな名前付け

ラジオグループと個々のラジオの両方にアクセシブルな名前が必要です:

  • ラジオグループ - コンテナに aria-label または aria-labelledby を使用します
  • 個々のラジオ - 各ラジオは aria-labelledby を介して可視テキストコンテンツでラベル付けされます
  • ネイティブの代替 - グループのラベル付けには <fieldset><legend> を使用します

ビジュアルデザイン

この実装は、状態を示すために色のみに依存しないことで WCAG 1.4.1(色の使用)に従っています:

  • 塗りつぶされた円 - 選択状態を示します
  • 空の円 - 未選択状態を示します
  • 不透明度の低下 - 無効状態を示します
  • 強制カラーモード - Windows ハイコントラストモードでのアクセシビリティのためシステムカラーを使用します

References

ソースコード

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

RadioGroupProps

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

RadioOption

Types
interface RadioOption {
  id: string;
  label: string;
  value: string;
  disabled?: boolean;
}

テスト

テストは、キーボード操作、ARIA属性、フォーカス管理、アクセシビリティ要件全般にわたるAPG準拠を検証します。

テストカテゴリ

高優先度: APG ARIA 属性

テスト 説明
role="radiogroup" コンテナがradiogroupロールを持つ
role="radio" 各オプションがradioロールを持つ
aria-checked 選択されたラジオが aria-checked="true" を持つ
aria-disabled 無効なラジオが aria-disabled="true" を持つ
aria-orientation 水平方向時のみ設定される(垂直がデフォルト)
accessible name グループとラジオがアクセシブルな名前を持つ

高優先度: APG キーボード操作

テスト 説明
Tab focus Tabで選択されたラジオ(または何もなければ最初)にフォーカス
Tab exit Tab/Shift+Tabでグループから退出
Space select Spaceでフォーカスされたラジオを選択
Space no unselect Spaceは既に選択されたラジオの選択を解除しない
ArrowDown/Right 次へ移動して選択
ArrowUp/Left 前へ移動して選択
Home 最初へ移動して選択
End 最後へ移動して選択
Arrow wrap 最後から最初へ、またはその逆にラップ
Disabled skip ナビゲーション中に無効なラジオをスキップ

高優先度: フォーカス管理(ローヴィングタブインデックス)

テスト 説明
tabindex="0" 選択されたラジオがtabindex="0"を持つ
tabindex="-1" 非選択のラジオがtabindex="-1"を持つ
Disabled tabindex 無効なラジオがtabindex="-1"を持つ
First tabbable 何も選択されていない場合、最初の有効なラジオがタブ可能
Single tabbable グループ内で常に1つのみがtabindex="0"

中優先度: フォーム統合

テスト 説明
hidden input フォーム送信用の非表示inputが存在する
name attribute 非表示inputが正しいnameを持つ
value sync 非表示inputの値が選択を反映する

中優先度: アクセシビリティ

テスト 説明
axe violations WCAG 2.1 AA違反がない(jest-axeによる)
selected axe 選択された値での違反がない
disabled axe 無効なオプションでの違反がない

低優先度: Props と動作

テスト 説明
onValueChange 選択変更時にコールバックが発火する
defaultValue defaultValueからの初期選択
className カスタムクラスがコンテナに適用される

テストツール

詳細は 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');
    });
  });
});

リソース