APG Patterns
English
English

Radio Group

一度に1つだけチェックできるチェック可能なボタンのセット。

デモ

基本的な Radio Group

矢印キーを使用してナビゲートと選択を行います。Tab キーでグループの内外にフォーカスを移動します。

デフォルト値あり

defaultValue プロパティを使用した事前選択されたオプション。

無効なオプションあり

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

水平方向

orientation="horizontal" を指定した水平レイアウト。

デモのみ表示 →

ネイティブ HTML

Use Native HTML First

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

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

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

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

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

アクセシビリティ

WAI-ARIA Roles

Role Element Description
radiogroup Container element Groups radio buttons together. Must have an accessible name via aria-label or aria-labelledby.
radio Each option element Identifies the element as a radio button. Only one radio in a group can be checked at a time.

This implementation uses custom role="radiogroup" and role="radio" for consistent cross-browser keyboard behavior. Native <input type="radio"> provides these roles implicitly.

WAI-ARIA States

aria-checked

Indicates the current checked state of the radio button. Only one radio in a group should have aria-checked="true".

Values true | false
Required Yes (on each radio)
Change Trigger Click, Space, Arrow keys

aria-disabled

Indicates that the radio button is not interactive and cannot be selected.

Values true (only when disabled)
Required No (only when disabled)
Effect Skipped during arrow key navigation, cannot be selected

WAI-ARIA Properties

aria-orientation

Indicates the orientation of the radio group. Vertical is the default.

Values horizontal | vertical (default)
Required No (only set when horizontal)
Note This implementation supports all arrow keys regardless of orientation

Keyboard Support

Key Action
Tab Move focus into the group (to selected or first radio)
Shift + Tab Move focus out of the group
Space Select the focused radio (does not unselect)
Arrow Down / Right Move to next radio and select (wraps to first)
Arrow Up / Left Move to previous radio and select (wraps to last)
Home Move to first radio and select
End Move to last radio and select

Note: Unlike Checkbox, arrow keys both move focus AND change selection. Disabled radios are skipped during navigation.

Focus Management (Roving Tabindex)

Radio groups use roving tabindex to manage focus. Only one radio in the group is tabbable at any time:

  • Selected radio has tabindex="0"
  • If none selected, first enabled radio has tabindex="0"
  • All other radios have tabindex="-1"
  • Disabled radios always have tabindex="-1"

Accessible Naming

Both the radio group and individual radios must have accessible names:

  • Radio group - Use aria-label or aria-labelledby on the container
  • Individual radios - Each radio is labeled by its visible text content via aria-labelledby
  • Native alternative - Use <fieldset> with <legend> for group labeling

Visual Design

This implementation follows WCAG 1.4.1 (Use of Color) by not relying solely on color to indicate state:

  • Filled circle - Indicates selected state
  • Empty circle - Indicates unselected state
  • Reduced opacity - Indicates disabled state
  • Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode

References

ソースコード

RadioGroup.astro
---
/**
 * APG Radio Group Pattern - Astro Implementation
 *
 * A set of checkable buttons where only one can be checked at a time.
 * Uses Web Components for keyboard navigation and focus management.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/radio/
 */

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

export interface Props {
  /** Radio options */
  options: RadioOption[];
  /** Group name for form submission */
  name: string;
  /** Accessible label for the group */
  'aria-label'?: string;
  /** Reference to external label */
  'aria-labelledby'?: string;
  /** Initially selected value */
  defaultValue?: string;
  /** Orientation of the group */
  orientation?: 'horizontal' | 'vertical';
  /** Additional CSS class */
  class?: string;
}

const {
  options,
  name,
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  defaultValue = '',
  orientation = 'vertical',
  class: className = '',
} = Astro.props;

// Generate unique ID
const instanceId = `radio-group-${Math.random().toString(36).slice(2, 9)}`;

// Find initial selected value
const getInitialValue = () => {
  if (defaultValue) {
    const option = options.find((opt) => opt.value === defaultValue);
    if (option && !option.disabled) {
      return defaultValue;
    }
  }
  return '';
};

const initialValue = getInitialValue();

// Get tabbable value
const getTabbableValue = () => {
  if (initialValue) {
    return initialValue;
  }
  const firstEnabled = options.find((opt) => !opt.disabled);
  return firstEnabled?.value || '';
};

const tabbableValue = getTabbableValue();
---

<apg-radio-group
  class={`apg-radio-group ${className}`.trim()}
  role="radiogroup"
  aria-label={ariaLabel}
  aria-labelledby={ariaLabelledby}
  aria-orientation={orientation === 'horizontal' ? 'horizontal' : undefined}
  data-name={name}
  data-value={initialValue}
>
  <!-- Hidden input for form submission -->
  <input type="hidden" name={name} value={initialValue} />

  {
    options.map((option) => {
      const isSelected = initialValue === option.value;
      const isTabbable = option.value === tabbableValue && !option.disabled;
      const tabIndex = option.disabled ? -1 : isTabbable ? 0 : -1;

      return (
        <div
          role="radio"
          aria-checked={isSelected ? 'true' : 'false'}
          aria-disabled={option.disabled ? 'true' : undefined}
          aria-labelledby={`${instanceId}-label-${option.id}`}
          tabindex={tabIndex}
          data-value={option.value}
          data-disabled={option.disabled ? 'true' : undefined}
          class={`apg-radio ${isSelected ? 'apg-radio--selected' : ''} ${option.disabled ? 'apg-radio--disabled' : ''}`}
        >
          <span class="apg-radio-control" aria-hidden="true">
            <span class="apg-radio-indicator" />
          </span>
          <span id={`${instanceId}-label-${option.id}`} class="apg-radio-label">
            {option.label}
          </span>
        </div>
      );
    })
  }
</apg-radio-group>

<script>
  class ApgRadioGroup extends HTMLElement {
    private radios: HTMLElement[] = [];
    private hiddenInput: HTMLInputElement | null = null;
    private rafId: number | null = null;

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.radios = Array.from(this.querySelectorAll('[role="radio"]'));
      this.hiddenInput = this.querySelector('input[type="hidden"]');

      this.radios.forEach((radio) => {
        radio.addEventListener('click', this.handleClick);
        radio.addEventListener('keydown', this.handleKeyDown);
      });
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      this.radios.forEach((radio) => {
        radio.removeEventListener('click', this.handleClick);
        radio.removeEventListener('keydown', this.handleKeyDown);
      });
      this.radios = [];
      this.hiddenInput = null;
    }

    private getEnabledRadios(): HTMLElement[] {
      return this.radios.filter((radio) => radio.dataset.disabled !== 'true');
    }

    private selectRadio(radio: HTMLElement) {
      if (radio.dataset.disabled === 'true') return;

      const value = radio.dataset.value || '';

      // Update all radios
      this.radios.forEach((r) => {
        const isSelected = r === radio;
        r.setAttribute('aria-checked', isSelected ? 'true' : 'false');
        r.classList.toggle('apg-radio--selected', isSelected);

        // Update tabindex (roving)
        if (r.dataset.disabled !== 'true') {
          r.setAttribute('tabindex', isSelected ? '0' : '-1');
        }
      });

      // Update hidden input
      if (this.hiddenInput) {
        this.hiddenInput.value = value;
      }

      // Dispatch event
      this.dispatchEvent(
        new CustomEvent('valuechange', {
          detail: { value },
          bubbles: true,
        })
      );
    }

    private focusRadio(radio: HTMLElement) {
      radio.focus();
    }

    private navigateAndSelect(direction: 'next' | 'prev' | 'first' | 'last') {
      const enabledRadios = this.getEnabledRadios();
      if (enabledRadios.length === 0) return;

      const currentIndex = enabledRadios.findIndex((r) => r === document.activeElement);

      let targetIndex: number;
      switch (direction) {
        case 'next':
          targetIndex = currentIndex >= 0 ? (currentIndex + 1) % enabledRadios.length : 0;
          break;
        case 'prev':
          targetIndex =
            currentIndex >= 0
              ? (currentIndex - 1 + enabledRadios.length) % enabledRadios.length
              : enabledRadios.length - 1;
          break;
        case 'first':
          targetIndex = 0;
          break;
        case 'last':
          targetIndex = enabledRadios.length - 1;
          break;
      }

      const targetRadio = enabledRadios[targetIndex];
      if (targetRadio) {
        this.focusRadio(targetRadio);
        this.selectRadio(targetRadio);
      }
    }

    private handleClick = (event: Event) => {
      const radio = event.currentTarget as HTMLElement;
      if (radio.dataset.disabled !== 'true') {
        this.focusRadio(radio);
        this.selectRadio(radio);
      }
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      const radio = event.currentTarget as HTMLElement;
      const { key } = event;

      switch (key) {
        case 'ArrowDown':
        case 'ArrowRight':
          event.preventDefault();
          this.navigateAndSelect('next');
          break;

        case 'ArrowUp':
        case 'ArrowLeft':
          event.preventDefault();
          this.navigateAndSelect('prev');
          break;

        case 'Home':
          event.preventDefault();
          this.navigateAndSelect('first');
          break;

        case 'End':
          event.preventDefault();
          this.navigateAndSelect('last');
          break;

        case ' ':
          event.preventDefault();
          this.selectRadio(radio);
          break;
      }
    };
  }

  if (!customElements.get('apg-radio-group')) {
    customElements.define('apg-radio-group', ApgRadioGroup);
  }
</script>

使い方

Example
---
import RadioGroup from './RadioGroup.astro';

const options = [
  { id: 'red', label: 'Red', value: 'red' },
  { id: 'blue', label: 'Blue', value: 'blue' },
  { id: 'green', label: 'Green', value: 'green' },
];
---

<RadioGroup
  options={options}
  name="color"
  aria-label="Favorite color"
  defaultValue="blue"
/>

<script>
  // Listen for value changes
  document.querySelector('apg-radio-group')?.addEventListener('valuechange', (e) => {
    console.log('Selected:', e.detail.value);
  });
</script>

API

Props

プロパティ デフォルト 説明
options RadioOption[] required ラジオオプションの配列
name string required フォーム送信用のグループ名
aria-label string - グループのアクセシブルラベル
aria-labelledby string - ラベル要素の ID
defaultValue string "" 初期選択値
orientation 'horizontal' | 'vertical' 'vertical' レイアウトの方向
class string - 追加の CSS クラス

カスタムイベント

イベント Detail 説明
valuechange { value: string } 選択が変更されたときに発行される

RadioOption

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

テスト

Tests verify APG compliance across keyboard interaction, ARIA attributes, focus management, and accessibility requirements. The Radio Group component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.

  • ARIA attributes (aria-checked, aria-disabled, aria-orientation)
  • Keyboard interaction (Arrow keys, Home, End, Space)
  • Roving tabindex behavior
  • Accessibility via jest-axe

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.

  • Click interactions
  • Arrow key navigation with looping
  • Space key selection
  • ARIA structure validation in live browser
  • axe-core accessibility scanning
  • Cross-framework consistency checks

Test Categories

High Priority: APG ARIA Attributes (Unit + E2E)

Test Description
role="radiogroup" Container has radiogroup role
role="radio" Each option has radio role
aria-checked Selected radio has aria-checked="true"
aria-disabled Disabled radios have aria-disabled="true"
aria-orientation Only set when horizontal (vertical is default)
accessible name Group and radios have accessible names

High Priority: APG Keyboard Interaction (Unit + E2E)

Test Description
Tab focus Tab focuses selected radio (or first if none)
Tab exit Tab/Shift+Tab exits the group
Space select Space selects focused radio
Space no unselect Space does not unselect already selected radio
ArrowDown/Right Moves to next and selects
ArrowUp/Left Moves to previous and selects
Home Moves to first and selects
End Moves to last and selects
Arrow wrap Wraps from last to first and vice versa
Disabled skip Disabled radios skipped during navigation

High Priority: Click Interaction (Unit + E2E)

Test Description
Click selects Clicking radio selects it
Click changes Clicking different radio changes selection
Disabled no click Clicking disabled radio does not select it

High Priority: Focus Management - Roving Tabindex (Unit + E2E)

Test Description
tabindex="0" Selected radio has tabindex="0"
tabindex="-1" Non-selected radios have tabindex="-1"
Disabled tabindex Disabled radios have tabindex="-1"
First tabbable First enabled radio tabbable when none selected
Single tabbable Only one tabindex="0" in group at any time

Medium Priority: Form Integration (Unit)

Test Description
hidden input Hidden input exists for form submission
name attribute Hidden input has correct name
value sync Hidden input value reflects selection

Medium Priority: Accessibility (Unit + E2E)

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe/axe-core)
selected axe No violations with selected value
disabled axe No violations with disabled option

Low Priority: Cross-framework Consistency (E2E)

Test Description
All frameworks render React, Vue, Svelte, Astro all render radio groups
Consistent click All frameworks support click to select
Consistent ARIA All frameworks have consistent ARIA structure
Consistent keyboard All frameworks support keyboard navigation

Example Test Code

The following is the actual E2E test file (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');
    }
  });
});

Running Tests

# Run unit tests for Radio Group
npm run test -- radio-group

# Run E2E tests for Radio Group (all frameworks)
npm run test:e2e:pattern --pattern=radio-group

# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=radio-group
npm run test:e2e:vue:pattern --pattern=radio-group
npm run test:e2e:svelte:pattern --pattern=radio-group
npm run test:e2e:astro:pattern --pattern=radio-group

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

リソース