APG Patterns
日本語 GitHub
日本語 GitHub

Radio Group

A set of checkable buttons where only one can be checked at a time.

🤖 AI Implementation Guide

Demo

Basic Radio Group

Use arrow keys to navigate and select. Tab moves focus in/out of the group.

With Default Value

Pre-selected option using the defaultValue prop.

With Disabled Option

Disabled options are skipped during keyboard navigation.

Horizontal Orientation

Horizontal layout with 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.

Accessibility Features

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

Source Code

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;

Usage

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

Prop Type Default Description
options RadioOption[] required Array of radio options
name string required Group name for form submission
aria-label string - Accessible label for the group
aria-labelledby string - ID of labeling element
defaultValue string "" Initially selected value
orientation 'horizontal' | 'vertical' 'vertical' Layout orientation
onValueChange (value: string) => void - Callback when selection changes
className string - Additional CSS class

RadioOption

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

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, focus management, and accessibility requirements.

Test Categories

High Priority: APG ARIA Attributes

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

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: Focus Management (Roving Tabindex)

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

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

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

Low Priority: Props & Behavior

Test Description
onValueChange Callback fires on selection change
defaultValue Initial selection from defaultValue
className Custom class applied to container

Testing Tools

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

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

Resources