APG Patterns
日本語
日本語

Listbox

A widget that allows the user to select one or more items from a list of choices.

Demo

Single-Select (Default)

Selection follows focus. Use arrow keys to navigate and select.

  • Apple
  • Banana
  • Cherry
  • Date
  • Elderberry
  • Fig
  • Grape

Selected: None

Multi-Select

Focus and selection are independent. Use Space to toggle, Shift+Arrow to extend selection.

  • Red
  • Orange
  • Yellow
  • Green
  • Blue
  • Indigo
  • Purple

Selected: None

Tip: Use Space to toggle, Shift+Arrow to extend selection, Ctrl+A to select all

Horizontal Orientation

Use Left/Right arrow keys for navigation.

  • Apple
  • Banana
  • Cherry
  • Date
  • Elderberry

Selected: None

Open demo only →

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
listboxContainer (<ul>)Widget for selecting one or more items from a list
optionEach item (<li>)Selectable option within the listbox

WAI-ARIA Properties

aria-label

Accessible name for the listbox

Values
String
Required
Yes*

aria-labelledby

References the labeling element

Values
ID reference
Required
Yes*

aria-multiselectable

Enables multi-select mode

Values
true
Required
No

aria-orientation

Navigation direction (default: vertical)

Values
vertical | horizontal
Required
No

WAI-ARIA States

aria-selected

Target Element
option
Values
true | false
Required
Yes
Change Trigger

Click, Arrow keys (single-select), Space (multi-select)

aria-disabled

Target Element
option
Values
true
Required
No
Change Trigger
When disabled

Keyboard Support

Common Navigation

KeyAction
Down Arrow / Up ArrowMove focus (vertical orientation)
Right Arrow / Left ArrowMove focus (horizontal orientation)
HomeMove focus to first option
EndMove focus to last option
Type characterType-ahead: focus option starting with typed character(s)

Single-Select (Selection Follows Focus)

KeyAction
Arrow keysMove focus and selection simultaneously
Space / EnterConfirm current selection

Multi-Select

KeyAction
Arrow keysMove focus only (selection unchanged)
SpaceToggle selection of focused option
Shift + ArrowMove focus and extend selection range
Shift + HomeSelect from anchor to first option
Shift + EndSelect from anchor to last option
Ctrl + ASelect all options
  • Single-select: Selection follows focus (arrow keys change selection)
  • Multi-select: Focus and selection are independent (Space toggles selection)

Focus Management

EventBehavior
Focused optionOnly one option has tabindex="0" at a time (Roving Tabindex)
Non-focused optionsOther options have tabindex="-1"
Arrow navigationArrow keys move focus between options
Disabled optionsDisabled options are skipped during navigation
Edge behaviorFocus does not wrap (stops at edges)

References

Source Code

Listbox.vue
<template>
  <ul
    ref="listboxRef"
    role="listbox"
    :aria-multiselectable="multiselectable || undefined"
    :aria-orientation="orientation"
    :aria-label="ariaLabel"
    :aria-labelledby="ariaLabelledby"
    :tabindex="listboxTabIndex"
    :class="containerClass"
    @keydown="handleKeyDown"
  >
    <li
      v-for="option in options"
      :key="option.id"
      :ref="(el) => setOptionRef(option.id, el)"
      role="option"
      :id="getOptionId(option.id)"
      :aria-selected="selectedIds.has(option.id)"
      :aria-disabled="option.disabled || undefined"
      :tabindex="getTabIndex(option)"
      :class="getOptionClass(option)"
      @click="!option.disabled && handleOptionClick(option.id)"
    >
      <span class="apg-listbox-option-icon" aria-hidden="true">
        <svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
          <path
            d="M10.28 2.28a.75.75 0 00-1.06-1.06L4.5 5.94 2.78 4.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l5.25-5.25z"
            fill="currentColor"
          />
        </svg>
      </span>
      {{ option.label }}
    </li>
  </ul>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';

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

export interface ListboxProps {
  options: ListboxOption[];
  multiselectable?: boolean;
  orientation?: 'vertical' | 'horizontal';
  defaultSelectedIds?: string[];
  ariaLabel?: string;
  ariaLabelledby?: string;
  typeAheadTimeout?: number;
}

const props = withDefaults(defineProps<ListboxProps>(), {
  multiselectable: false,
  orientation: 'vertical',
  defaultSelectedIds: () => [],
  typeAheadTimeout: 500,
});

const emit = defineEmits<{
  selectionChange: [selectedIds: string[]];
}>();

const listboxRef = ref<HTMLElement>();
const optionRefs = ref<Record<string, HTMLLIElement>>({});
const instanceId = ref('');
const selectedIds = ref<Set<string>>(new Set());
const focusedIndex = ref(0);
const selectionAnchor = ref(0);
const typeAheadBuffer = ref('');
const typeAheadTimeoutId = ref<number | null>(null);

const availableOptions = computed(() => props.options.filter((opt) => !opt.disabled));

// Map of option id to index in availableOptions for O(1) lookup
const availableIndexMap = computed(() => {
  const map = new Map<string, number>();
  availableOptions.value.forEach(({ id }, index) => map.set(id, index));
  return map;
});

onMounted(() => {
  instanceId.value = `listbox-${Math.random().toString(36).slice(2, 11)}`;

  // Initialize selection
  if (props.defaultSelectedIds.length > 0) {
    selectedIds.value = new Set(props.defaultSelectedIds);
  } else if (availableOptions.value.length > 0) {
    // Single-select mode: select first available option by default
    if (!props.multiselectable) {
      selectedIds.value = new Set([availableOptions.value[0].id]);
    }
  }

  // Initialize focused index and sync anchor
  const firstSelectedId = [...selectedIds.value][0];
  if (firstSelectedId) {
    const index = availableOptions.value.findIndex((opt) => opt.id === firstSelectedId);
    if (index >= 0) {
      focusedIndex.value = index;
      selectionAnchor.value = index;
    }
  }
});

const setOptionRef = (id: string, el: unknown) => {
  if (el instanceof HTMLLIElement) {
    optionRefs.value[id] = el;
  } else if (el === null) {
    // Clean up ref when element is unmounted
    delete optionRefs.value[id];
  }
};

// If no available options, listbox itself needs tabIndex for keyboard access
const listboxTabIndex = computed(() => (availableOptions.value.length === 0 ? 0 : undefined));

const getOptionId = (optionId: string) => `${instanceId.value}-option-${optionId}`;

const containerClass = computed(() => {
  const classes = ['apg-listbox'];
  if (props.orientation === 'horizontal') {
    classes.push('apg-listbox--horizontal');
  }
  return classes.join(' ');
});

const getOptionClass = (option: ListboxOption) => {
  const classes = ['apg-listbox-option'];
  if (selectedIds.value.has(option.id)) {
    classes.push('apg-listbox-option--selected');
  }
  if (option.disabled) {
    classes.push('apg-listbox-option--disabled');
  }
  return classes.join(' ');
};

const getTabIndex = (option: ListboxOption): number => {
  if (option.disabled) return -1;
  const availableIndex = availableIndexMap.value.get(option.id) ?? -1;
  return availableIndex === focusedIndex.value ? 0 : -1;
};

const updateSelection = (newSelectedIds: Set<string>) => {
  selectedIds.value = newSelectedIds;
  emit('selectionChange', [...newSelectedIds]);
};

const focusOption = async (index: number) => {
  const option = availableOptions.value[index];
  if (option) {
    focusedIndex.value = index;
    await nextTick();
    optionRefs.value[option.id]?.focus();
  }
};

const selectOption = (optionId: string) => {
  if (props.multiselectable) {
    const newSelected = new Set(selectedIds.value);
    if (newSelected.has(optionId)) {
      newSelected.delete(optionId);
    } else {
      newSelected.add(optionId);
    }
    updateSelection(newSelected);
  } else {
    updateSelection(new Set([optionId]));
  }
};

const selectRange = (fromIndex: number, toIndex: number) => {
  const start = Math.min(fromIndex, toIndex);
  const end = Math.max(fromIndex, toIndex);
  const newSelected = new Set(selectedIds.value);

  for (let i = start; i <= end; i++) {
    const option = availableOptions.value[i];
    if (option) {
      newSelected.add(option.id);
    }
  }

  updateSelection(newSelected);
};

const selectAll = () => {
  const allIds = new Set(availableOptions.value.map((opt) => opt.id));
  updateSelection(allIds);
};

const handleTypeAhead = (char: string) => {
  // Guard: no options to search
  if (availableOptions.value.length === 0) return;

  if (typeAheadTimeoutId.value !== null) {
    clearTimeout(typeAheadTimeoutId.value);
  }

  typeAheadBuffer.value += char.toLowerCase();

  const buffer = typeAheadBuffer.value;
  const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);

  let startIndex = focusedIndex.value;

  if (isSameChar) {
    typeAheadBuffer.value = buffer[0];
    startIndex = (focusedIndex.value + 1) % availableOptions.value.length;
  }

  for (let i = 0; i < availableOptions.value.length; i++) {
    const index = (startIndex + i) % availableOptions.value.length;
    const option = availableOptions.value[index];
    const searchStr = isSameChar ? buffer[0] : typeAheadBuffer.value;
    if (option.label.toLowerCase().startsWith(searchStr)) {
      focusOption(index);
      // Update anchor for shift-selection
      selectionAnchor.value = index;
      if (!props.multiselectable) {
        updateSelection(new Set([option.id]));
      }
      break;
    }
  }

  typeAheadTimeoutId.value = window.setTimeout(() => {
    typeAheadBuffer.value = '';
    typeAheadTimeoutId.value = null;
  }, props.typeAheadTimeout);
};

const handleOptionClick = (optionId: string) => {
  const index = availableIndexMap.value.get(optionId) ?? -1;
  focusOption(index);
  selectOption(optionId);
  selectionAnchor.value = index;
};

const handleKeyDown = async (event: KeyboardEvent) => {
  // Guard: no options to navigate
  if (availableOptions.value.length === 0) return;

  const { key, shiftKey, ctrlKey, metaKey } = event;

  const nextKey = props.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
  const prevKey = props.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';

  if (props.orientation === 'vertical' && (key === 'ArrowLeft' || key === 'ArrowRight')) {
    return;
  }
  if (props.orientation === 'horizontal' && (key === 'ArrowUp' || key === 'ArrowDown')) {
    return;
  }

  let newIndex = focusedIndex.value;
  let shouldPreventDefault = false;

  switch (key) {
    case nextKey:
      if (focusedIndex.value < availableOptions.value.length - 1) {
        newIndex = focusedIndex.value + 1;
      }
      shouldPreventDefault = true;

      if (props.multiselectable && shiftKey) {
        await focusOption(newIndex);
        selectRange(selectionAnchor.value, newIndex);
        event.preventDefault();
        return;
      }
      break;

    case prevKey:
      if (focusedIndex.value > 0) {
        newIndex = focusedIndex.value - 1;
      }
      shouldPreventDefault = true;

      if (props.multiselectable && shiftKey) {
        await focusOption(newIndex);
        selectRange(selectionAnchor.value, newIndex);
        event.preventDefault();
        return;
      }
      break;

    case 'Home':
      newIndex = 0;
      shouldPreventDefault = true;

      if (props.multiselectable && shiftKey) {
        await focusOption(newIndex);
        selectRange(selectionAnchor.value, newIndex);
        event.preventDefault();
        return;
      }
      break;

    case 'End':
      newIndex = availableOptions.value.length - 1;
      shouldPreventDefault = true;

      if (props.multiselectable && shiftKey) {
        await focusOption(newIndex);
        selectRange(selectionAnchor.value, newIndex);
        event.preventDefault();
        return;
      }
      break;

    case ' ':
      shouldPreventDefault = true;
      if (props.multiselectable) {
        const focusedOption = availableOptions.value[focusedIndex.value];
        if (focusedOption) {
          selectOption(focusedOption.id);
          selectionAnchor.value = focusedIndex.value;
        }
      }
      event.preventDefault();
      return;

    case 'Enter':
      shouldPreventDefault = true;
      event.preventDefault();
      return;

    case 'a':
    case 'A':
      if ((ctrlKey || metaKey) && props.multiselectable) {
        shouldPreventDefault = true;
        selectAll();
        event.preventDefault();
        return;
      }
      break;
  }

  if (shouldPreventDefault) {
    event.preventDefault();

    if (newIndex !== focusedIndex.value) {
      await focusOption(newIndex);

      if (!props.multiselectable) {
        const newOption = availableOptions.value[newIndex];
        if (newOption) {
          updateSelection(new Set([newOption.id]));
        }
      } else {
        selectionAnchor.value = newIndex;
      }
    }
    return;
  }

  if (key.length === 1 && !ctrlKey && !metaKey) {
    event.preventDefault();
    handleTypeAhead(key);
  }
};
</script>

Usage

Example
<script setup>
import Listbox from './Listbox.vue';

const options = [
  { id: 'apple', label: 'Apple' },
  { id: 'banana', label: 'Banana' },
  { id: 'cherry', label: 'Cherry' },
];

const handleSelectionChange = (ids) => {
  console.log('Selected:', ids);
};
</script>

<template>
  <!-- Single-select -->
  <Listbox
    :options="options"
    aria-label="Choose a fruit"
    @selection-change="handleSelectionChange"
  />

  <!-- Multi-select -->
  <Listbox
    :options="options"
    multiselectable
    aria-label="Choose fruits"
    @selection-change="handleSelectionChange"
  />
</template>

API

Prop Type Default Description
options ListboxOption[] required Array of options
multiselectable boolean false Enable multi-select mode
orientation 'vertical' | 'horizontal' 'vertical' Listbox orientation
defaultSelectedIds string[] [] Initially selected option IDs

Custom Events

Event Detail Description
selection-change string[] Emitted when selection changes

Testing

Tests verify APG compliance for ARIA attributes, keyboard interactions, selection behavior, and accessibility requirements. The Listbox 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 (role, aria-selected, aria-multiselectable, etc.)
  • Keyboard interaction (Arrow keys, Space, Home/End, etc.)
  • Selection behavior (single-select, multi-select)
  • 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.

  • Keyboard navigation (single-select, multi-select, horizontal)
  • Mouse interactions (click selection, toggle)
  • ARIA structure in live browser
  • Focus management with roving tabindex
  • Type-ahead character navigation
  • axe-core accessibility scanning
  • Cross-framework consistency checks

Test Categories

High Priority: APG Keyboard Interaction (Unit + E2E)

Test Description
ArrowDown/Up Moves focus between options (vertical orientation)
ArrowRight/Left Moves focus between options (horizontal orientation)
Home/End Moves focus to first/last option
Disabled skip Skips disabled options during navigation
Selection follows focus Single-select: arrow keys change selection
Space toggle Multi-select: Space toggles option selection
Shift+Arrow Multi-select: extends selection range
Shift+Home/End Multi-select: selects from anchor to first/last
Ctrl+A Multi-select: selects all options
Type-ahead Character input focuses matching option
Type-ahead cycle Repeated same character cycles through matches

High Priority: APG ARIA Attributes (Unit + E2E)

Test Description
role="listbox" Container has listbox role
role="option" Each option has option role
aria-selected Selected options have `aria-selected="true"`
aria-multiselectable Listbox has attribute when multi-select enabled
aria-orientation Reflects horizontal/vertical orientation
aria-disabled Disabled options have `aria-disabled="true"`
aria-label/labelledby Listbox has accessible name

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

Test Description
tabIndex=0 Focused option has tabIndex=0
tabIndex=-1 Non-focused options have tabIndex=-1
Disabled tabIndex Disabled options have tabIndex=-1
Focus restoration Focus returns to correct option on re-entry

Medium Priority: Accessibility (Unit + E2E)

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe/axe-core)

Medium Priority: Mouse Interaction (E2E)

Test Description
Click option Selects option on click (single-select)
Click toggle Toggles selection on click (multi-select)
Click disabled Disabled options cannot be selected

Low Priority: Cross-framework Consistency (E2E)

Test Description
All frameworks have listbox React, Vue, Svelte, Astro all render listbox elements
Consistent ARIA All frameworks have consistent ARIA structure
Select on click All frameworks select correctly on click
Keyboard navigation All frameworks respond to keyboard navigation consistently

Example Test Code

The following is the actual E2E test file (e2e/listbox.spec.ts).

e2e/listbox.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Listbox Pattern
 *
 * A widget that allows the user to select one or more items from a list of choices.
 * Supports single-select (selection follows focus) and multi-select modes.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// Helper to get all listboxes
const getListboxes = (page: import('@playwright/test').Page) => {
  return page.locator('[role="listbox"]');
};

// Helper to get listbox by index (0=single-select, 1=multi-select, 2=horizontal)
const getListboxByIndex = (page: import('@playwright/test').Page, index: number) => {
  return page.locator('[role="listbox"]').nth(index);
};

// Helper to get available (non-disabled) options in a listbox
const getAvailableOptions = (listbox: import('@playwright/test').Locator) => {
  return listbox.locator('[role="option"]:not([aria-disabled="true"])');
};

// Helper to get selected options in a listbox
const getSelectedOptions = (listbox: import('@playwright/test').Locator) => {
  return listbox.locator('[role="option"][aria-selected="true"]');
};

for (const framework of frameworks) {
  test.describe(`Listbox (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/listbox/${framework}/demo/`);
      await page.waitForLoadState('networkidle');
    });

    // =========================================================================
    // High Priority: ARIA Structure
    // =========================================================================
    test.describe('APG: ARIA Structure', () => {
      test('has role="listbox" on container', async ({ page }) => {
        const listboxes = getListboxes(page);
        const count = await listboxes.count();
        expect(count).toBe(3); // single-select, multi-select, horizontal

        for (let i = 0; i < count; i++) {
          await expect(listboxes.nth(i)).toHaveAttribute('role', 'listbox');
        }
      });

      test('options have role="option"', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const options = listbox.locator('[role="option"]');
        const count = await options.count();
        expect(count).toBeGreaterThan(0);

        for (let i = 0; i < count; i++) {
          await expect(options.nth(i)).toHaveAttribute('role', 'option');
        }
      });

      test('has accessible name via aria-labelledby', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const ariaLabelledby = await listbox.getAttribute('aria-labelledby');
        expect(ariaLabelledby).toBeTruthy();

        const label = page.locator(`#${ariaLabelledby}`);
        const labelText = await label.textContent();
        expect(labelText?.trim().length).toBeGreaterThan(0);
      });

      test('single-select listbox does not have aria-multiselectable', async ({ page }) => {
        const singleSelectListbox = getListboxByIndex(page, 0);
        const ariaMultiselectable = await singleSelectListbox.getAttribute('aria-multiselectable');
        expect(ariaMultiselectable).toBeFalsy();
      });

      test('multi-select listbox has aria-multiselectable="true"', async ({ page }) => {
        const multiSelectListbox = getListboxByIndex(page, 1);
        await expect(multiSelectListbox).toHaveAttribute('aria-multiselectable', 'true');
      });

      test('horizontal listbox has aria-orientation="horizontal"', async ({ page }) => {
        const horizontalListbox = getListboxByIndex(page, 2);
        await expect(horizontalListbox).toHaveAttribute('aria-orientation', 'horizontal');
      });

      test('selected options have aria-selected="true"', async ({ page }) => {
        const singleSelectListbox = getListboxByIndex(page, 0);
        const selectedOptions = getSelectedOptions(singleSelectListbox);
        const count = await selectedOptions.count();
        expect(count).toBeGreaterThan(0);

        for (let i = 0; i < count; i++) {
          await expect(selectedOptions.nth(i)).toHaveAttribute('aria-selected', 'true');
        }
      });

      test('disabled options have aria-disabled="true"', async ({ page }) => {
        const multiSelectListbox = getListboxByIndex(page, 1);
        const disabledOptions = multiSelectListbox.locator('[role="option"][aria-disabled="true"]');
        const count = await disabledOptions.count();
        expect(count).toBeGreaterThan(0);
      });
    });

    // =========================================================================
    // High Priority: Single-Select Keyboard Navigation
    // =========================================================================
    test.describe('APG: Single-Select Keyboard Navigation', () => {
      test('ArrowDown moves focus and selection to next option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const secondOption = options.nth(1);

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');

        await firstOption.press('ArrowDown');
        await expect(secondOption).toHaveAttribute('tabindex', '0');
        await expect(secondOption).toHaveAttribute('aria-selected', 'true');
        await expect(firstOption).toHaveAttribute('aria-selected', 'false');
      });

      test('ArrowUp moves focus and selection to previous option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const secondOption = options.nth(1);

        // Click to set initial state, then navigate down to second option
        await firstOption.click();
        await expect(firstOption).toBeFocused();
        await firstOption.press('ArrowDown');
        await expect(secondOption).toHaveAttribute('tabindex', '0');
        await expect(secondOption).toHaveAttribute('aria-selected', 'true');

        // Now navigate up
        await expect(secondOption).toBeFocused();
        await secondOption.press('ArrowUp');
        await expect(firstOption).toHaveAttribute('tabindex', '0');
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');
      });

      test('Home moves focus and selection to first option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('ArrowDown');
        const secondOption = options.nth(1);
        await expect(secondOption).toBeFocused();
        await secondOption.press('ArrowDown');
        const thirdOption = options.nth(2);

        await expect(thirdOption).toBeFocused();
        await thirdOption.press('Home');
        await expect(firstOption).toHaveAttribute('tabindex', '0');
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');
      });

      test('End moves focus and selection to last option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const lastOption = options.last();

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('End');
        await expect(lastOption).toHaveAttribute('tabindex', '0');
        await expect(lastOption).toHaveAttribute('aria-selected', 'true');
      });

      test('focus does not wrap at boundaries', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const options = getAvailableOptions(listbox);
        const lastOption = options.last();

        await lastOption.focus();
        await expect(lastOption).toBeFocused();
        await lastOption.press('End'); // Ensure we're at the end

        await expect(lastOption).toBeFocused();
        await lastOption.press('ArrowDown');

        // Should still be on last option
        await expect(lastOption).toHaveAttribute('tabindex', '0');
      });

      // Note: disabled option skip test is in Multi-Select section since the multi-select
      // listbox has disabled options (Green) while single-select doesn't
    });

    // =========================================================================
    // High Priority: Multi-Select Keyboard Navigation
    // =========================================================================
    test.describe('APG: Multi-Select Keyboard Navigation', () => {
      test('ArrowDown moves focus only (no selection change)', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const secondOption = options.nth(1);

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        // Initially no selection in multi-select
        const initialSelected = await getSelectedOptions(listbox).count();

        await firstOption.press('ArrowDown');
        await expect(secondOption).toHaveAttribute('tabindex', '0');

        // Selection should not have changed
        const afterSelected = await getSelectedOptions(listbox).count();
        expect(afterSelected).toBe(initialSelected);
      });

      test('ArrowUp moves focus only (no selection change)', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const secondOption = options.nth(1);

        // Click to set initial state, then navigate down to second option
        await firstOption.click();
        await expect(firstOption).toBeFocused();
        await firstOption.press('ArrowDown');
        await expect(secondOption).toHaveAttribute('tabindex', '0');

        // Navigate up should move focus but not change selection
        await expect(secondOption).toBeFocused();
        await secondOption.press('ArrowUp');
        await expect(firstOption).toHaveAttribute('tabindex', '0');
      });

      test('Space toggles selection of focused option (select)', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const firstOption = getAvailableOptions(listbox).first();

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await expect(firstOption).not.toHaveAttribute('aria-selected', 'true');

        await firstOption.press('Space');
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');
      });

      test('Space toggles selection of focused option (deselect)', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const firstOption = getAvailableOptions(listbox).first();

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('Space'); // Select
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');

        await expect(firstOption).toBeFocused();
        await firstOption.press('Space'); // Deselect
        await expect(firstOption).toHaveAttribute('aria-selected', 'false');
      });

      test('Shift+ArrowDown extends selection range', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const secondOption = options.nth(1);

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('Space'); // Select first as anchor
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');

        await expect(firstOption).toBeFocused();
        await firstOption.press('Shift+ArrowDown');
        await expect(secondOption).toHaveAttribute('aria-selected', 'true');
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');
      });

      test('Shift+ArrowUp extends selection range', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const secondOption = options.nth(1);

        // Click second option to set it as anchor (click toggles selection and sets anchor)
        await secondOption.click();
        await expect(secondOption).toBeFocused();
        await expect(secondOption).toHaveAttribute('aria-selected', 'true');

        await secondOption.press('Shift+ArrowUp');
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');
        await expect(secondOption).toHaveAttribute('aria-selected', 'true');
      });

      test('Shift+Home selects from anchor to first option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const thirdOption = options.nth(2);

        // Focus third option, select it as anchor
        await thirdOption.focus();
        await expect(thirdOption).toBeFocused();
        await thirdOption.press('Space'); // Select third as anchor

        await expect(thirdOption).toBeFocused();
        await thirdOption.press('Shift+Home');

        // All options from first to anchor should be selected
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');
      });

      test('Shift+End selects from anchor to last option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const lastOption = options.last();

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('Space'); // Select first as anchor

        await expect(firstOption).toBeFocused();
        await firstOption.press('Shift+End');

        // All options from anchor to last should be selected
        await expect(lastOption).toHaveAttribute('aria-selected', 'true');
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');
      });

      test('Ctrl+A selects all available options', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const availableOptions = getAvailableOptions(listbox);
        const firstOption = availableOptions.first();

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('Control+a');

        const count = await availableOptions.count();
        for (let i = 0; i < count; i++) {
          await expect(availableOptions.nth(i)).toHaveAttribute('aria-selected', 'true');
        }
      });

      test('disabled options are skipped during navigation', async ({ page }) => {
        // Multi-select listbox has disabled options (Green at index 3)
        const listbox = getListboxByIndex(page, 1);
        const availableOptions = getAvailableOptions(listbox);

        // Get Yellow (index 2 in available options) and Blue (index 3 after skip)
        const yellowOption = availableOptions.nth(2); // Red, Orange, Yellow
        const blueOption = availableOptions.nth(3); // Blue (Green is skipped)

        // Click to focus Yellow first (ensures proper component state)
        await yellowOption.click();
        await expect(yellowOption).toBeFocused();
        await yellowOption.press('ArrowDown');

        // Should skip Green and land on Blue
        await expect(blueOption).toHaveAttribute('tabindex', '0');
      });
    });

    // =========================================================================
    // High Priority: Horizontal Listbox
    // =========================================================================
    test.describe('APG: Horizontal Listbox', () => {
      test('ArrowRight moves to next option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 2);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const secondOption = options.nth(1);

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('ArrowRight');

        await expect(secondOption).toHaveAttribute('tabindex', '0');
        await expect(secondOption).toHaveAttribute('aria-selected', 'true');
      });

      test('ArrowLeft moves to previous option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 2);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const secondOption = options.nth(1);

        // Click to set initial state, then navigate right to second option
        await firstOption.click();
        await expect(firstOption).toBeFocused();
        await firstOption.press('ArrowRight');
        await expect(secondOption).toHaveAttribute('tabindex', '0');

        // Now navigate left
        await expect(secondOption).toBeFocused();
        await secondOption.press('ArrowLeft');
        await expect(firstOption).toHaveAttribute('tabindex', '0');
      });

      test('ArrowUp/ArrowDown are ignored in horizontal mode', async ({ page }) => {
        const listbox = getListboxByIndex(page, 2);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();

        await firstOption.focus();
        await expect(firstOption).toBeFocused();

        await firstOption.press('ArrowDown');
        // Should still be on first option
        await expect(firstOption).toHaveAttribute('tabindex', '0');

        await expect(firstOption).toBeFocused();
        await firstOption.press('ArrowUp');
        await expect(firstOption).toHaveAttribute('tabindex', '0');
      });

      test('Home moves to first option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 2);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('ArrowRight');
        const secondOption = options.nth(1);
        await expect(secondOption).toBeFocused();
        await secondOption.press('ArrowRight');
        const thirdOption = options.nth(2);

        await expect(thirdOption).toBeFocused();
        await thirdOption.press('Home');
        await expect(firstOption).toHaveAttribute('tabindex', '0');
      });

      test('End moves to last option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 2);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const lastOption = options.last();

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('End');

        await expect(lastOption).toHaveAttribute('tabindex', '0');
      });
    });

    // =========================================================================
    // High Priority: Focus Management (Roving Tabindex)
    // =========================================================================
    test.describe('APG: Focus Management', () => {
      test('focused option has tabindex="0"', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const focusedOption = listbox.locator('[role="option"][tabindex="0"]');
        const count = await focusedOption.count();
        expect(count).toBe(1);
      });

      test('other options have tabindex="-1"', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const allOptions = listbox.locator('[role="option"]');
        const count = await allOptions.count();

        let tabindexZeroCount = 0;
        for (let i = 0; i < count; i++) {
          const tabindex = await allOptions.nth(i).getAttribute('tabindex');
          if (tabindex === '0') tabindexZeroCount++;
        }
        expect(tabindexZeroCount).toBe(1);
      });

      test('tabindex updates on navigation', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const secondOption = options.nth(1);

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await expect(firstOption).toHaveAttribute('tabindex', '0');
        await expect(secondOption).toHaveAttribute('tabindex', '-1');

        await firstOption.press('ArrowDown');
        await expect(firstOption).toHaveAttribute('tabindex', '-1');
        await expect(secondOption).toHaveAttribute('tabindex', '0');
      });

      test('Tab exits listbox', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const firstOption = listbox.locator('[role="option"][tabindex="0"]');

        await firstOption.focus();
        await page.keyboard.press('Tab');

        // Focus should have moved out of listbox
        const focusedElement = page.locator(':focus');
        const isInListbox = await focusedElement.evaluate(
          (el, listboxEl) => listboxEl?.contains(el),
          await listbox.elementHandle()
        );
        expect(isInListbox).toBeFalsy();
      });

      test('focus returns to last focused option on re-entry', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const options = getAvailableOptions(listbox);
        const firstOption = options.first();
        const thirdOption = options.nth(2);

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('ArrowDown');
        const secondOption = options.nth(1);
        await expect(secondOption).toBeFocused();
        await secondOption.press('ArrowDown');
        await expect(thirdOption).toHaveAttribute('tabindex', '0');

        // Tab out and back (page-level navigation)
        await page.keyboard.press('Tab');
        await page.keyboard.press('Shift+Tab');

        // Should return to the third option
        await expect(thirdOption).toHaveAttribute('tabindex', '0');
      });
    });

    // =========================================================================
    // High Priority: Type-ahead
    // =========================================================================
    test.describe('APG: Type-ahead', () => {
      test('single character focuses matching option', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const grapeOption = listbox.locator('[role="option"]', { hasText: 'Grape' });
        const firstOption = listbox.locator('[role="option"][tabindex="0"]');

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('g');

        await expect(grapeOption).toHaveAttribute('tabindex', '0');
      });

      test('multiple characters match prefix', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const cherryOption = listbox.locator('[role="option"]', { hasText: 'Cherry' });
        const firstOption = listbox.locator('[role="option"][tabindex="0"]');

        await firstOption.focus();
        await page.keyboard.type('ch', { delay: 50 });

        await expect(cherryOption).toHaveAttribute('tabindex', '0');
      });

      test('repeated same character cycles through matches', async ({ page }) => {
        // With fruit options: Apple, Apricot, Banana, Cherry, Date, Elderberry, Fig, Grape
        // Apple and Apricot both start with 'a', so we can test cycling
        const listbox = getListboxByIndex(page, 0);
        const firstOption = listbox.locator('[role="option"][tabindex="0"]');
        await firstOption.click();
        await expect(firstOption).toBeFocused();

        // Use id attribute pattern (works across frameworks: id ends with -option-{id} or data-option-id)
        const appleOption = listbox.locator(
          '[role="option"][id$="-option-apple"], [role="option"][data-option-id="apple"]'
        );
        const apricotOption = listbox.locator(
          '[role="option"][id$="-option-apricot"], [role="option"][data-option-id="apricot"]'
        );

        // Press 'a' - should stay on Apple (first match)
        await firstOption.press('a');
        await expect(appleOption).toHaveAttribute('tabindex', '0');

        // Press 'a' again - should cycle to Apricot (next match)
        await expect(appleOption).toBeFocused();
        await appleOption.press('a');
        await expect(apricotOption).toHaveAttribute('tabindex', '0');

        // Press 'a' again - should cycle back to Apple
        await expect(apricotOption).toBeFocused();
        await apricotOption.press('a');
        await expect(appleOption).toHaveAttribute('tabindex', '0');
      });

      test('type-ahead buffer clears after timeout', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const firstOption = listbox.locator('[role="option"][tabindex="0"]');
        const cherryOption = listbox.locator('[role="option"]', { hasText: 'Cherry' });
        const dateOption = listbox.locator('[role="option"]', { hasText: 'Date' });

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('c'); // Focus Cherry
        await expect(cherryOption).toHaveAttribute('tabindex', '0');

        // Wait for buffer to clear (default 500ms + margin)
        await page.waitForTimeout(600);

        await expect(cherryOption).toBeFocused();
        await cherryOption.press('d'); // Should focus Date, not search for "cd"
        await expect(dateOption).toHaveAttribute('tabindex', '0');
      });

      test('type-ahead updates selection in single-select', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const grapeOption = listbox.locator('[role="option"]', { hasText: 'Grape' });
        const firstOption = listbox.locator('[role="option"][tabindex="0"]');

        await firstOption.focus();
        await expect(firstOption).toBeFocused();
        await firstOption.press('g');

        // In single-select, selection follows focus
        await expect(grapeOption).toHaveAttribute('aria-selected', 'true');
      });
    });

    // =========================================================================
    // Medium Priority: Mouse Interaction
    // =========================================================================
    test.describe('Mouse Interaction', () => {
      test('clicking option selects it (single-select)', async ({ page }) => {
        const listbox = getListboxByIndex(page, 0);
        const secondOption = listbox.locator('[role="option"]').nth(1);

        await secondOption.click();
        await expect(secondOption).toHaveAttribute('aria-selected', 'true');
        await expect(secondOption).toHaveAttribute('tabindex', '0');
      });

      test('clicking option toggles selection (multi-select)', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const firstOption = getAvailableOptions(listbox).first();

        // First click - select
        await firstOption.click();
        await expect(firstOption).toHaveAttribute('aria-selected', 'true');

        // Second click - deselect
        await firstOption.click();
        await expect(firstOption).toHaveAttribute('aria-selected', 'false');
      });

      test('clicking disabled option does nothing', async ({ page }) => {
        const listbox = getListboxByIndex(page, 1);
        const disabledOption = listbox.locator('[role="option"][aria-disabled="true"]').first();
        const selectedCountBefore = await getSelectedOptions(listbox).count();

        await disabledOption.click({ force: true });

        const selectedCountAfter = await getSelectedOptions(listbox).count();
        expect(selectedCountAfter).toBe(selectedCountBefore);
      });
    });

    // =========================================================================
    // Medium Priority: Accessibility
    // =========================================================================
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        const results = await new AxeBuilder({ page }).include('[role="listbox"]').analyze();
        expect(results.violations).toEqual([]);
      });
    });
  });
}

// =============================================================================
// Cross-framework Consistency Tests
// =============================================================================
test.describe('Listbox - Cross-framework Consistency', () => {
  test('all frameworks have listbox elements', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/listbox/${framework}/demo/`);
      await page.waitForLoadState('networkidle');

      const listboxes = page.locator('[role="listbox"]');
      const count = await listboxes.count();
      expect(count).toBe(3); // single-select, multi-select, horizontal
    }
  });

  test('all frameworks have consistent ARIA structure', async ({ page }) => {
    const ariaStructures: Record<
      string,
      {
        hasAriaLabelledby: boolean;
        ariaMultiselectable: string | null;
        ariaOrientation: string | null;
        optionCount: number;
      }[]
    > = {};

    for (const framework of frameworks) {
      await page.goto(`patterns/listbox/${framework}/demo/`);
      await page.waitForLoadState('networkidle');

      ariaStructures[framework] = await page.evaluate(() => {
        const listboxes = document.querySelectorAll('[role="listbox"]');
        return Array.from(listboxes).map((listbox) => ({
          hasAriaLabelledby: listbox.hasAttribute('aria-labelledby'),
          ariaMultiselectable: listbox.getAttribute('aria-multiselectable'),
          ariaOrientation: listbox.getAttribute('aria-orientation'),
          optionCount: listbox.querySelectorAll('[role="option"]').length,
        }));
      });
    }

    // All frameworks should have the same structure
    const reactStructure = ariaStructures['react'];
    for (const framework of frameworks) {
      expect(ariaStructures[framework].length).toBe(reactStructure.length);
      for (let i = 0; i < reactStructure.length; i++) {
        expect(ariaStructures[framework][i].hasAriaLabelledby).toBe(
          reactStructure[i].hasAriaLabelledby
        );
        expect(ariaStructures[framework][i].ariaMultiselectable).toBe(
          reactStructure[i].ariaMultiselectable
        );
        expect(ariaStructures[framework][i].ariaOrientation).toBe(
          reactStructure[i].ariaOrientation
        );
        expect(ariaStructures[framework][i].optionCount).toBe(reactStructure[i].optionCount);
      }
    }
  });

  test('all frameworks select correctly on click', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/listbox/${framework}/demo/`);
      await page.waitForLoadState('networkidle');

      // Test single-select listbox
      const singleSelectListbox = page.locator('[role="listbox"]').first();
      const secondOption = singleSelectListbox.locator('[role="option"]').nth(1);

      await secondOption.click();
      await expect(secondOption).toHaveAttribute('aria-selected', 'true');
    }
  });

  test('all frameworks handle keyboard navigation consistently', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/listbox/${framework}/demo/`);
      await page.waitForLoadState('networkidle');

      const listbox = page.locator('[role="listbox"]').first();
      const options = listbox.locator('[role="option"]:not([aria-disabled="true"])');
      const firstOption = options.first();
      const secondOption = options.nth(1);

      await firstOption.focus();
      await expect(firstOption).toBeFocused();
      await firstOption.press('ArrowDown');

      // Second option should now be focused and selected
      await expect(secondOption).toHaveAttribute('tabindex', '0');
      await expect(secondOption).toHaveAttribute('aria-selected', 'true');
    }
  });
});

Testing Tools

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

Resources