APG Patterns
日本語 GitHub
日本語 GitHub

Combobox

An editable combobox with list autocomplete. Users can type to filter options or select from a popup listbox using keyboard or mouse.

🤖 AI Implementation Guide

Demo

Native HTML

Consider Native HTML First

Before using a custom combobox, consider native HTML alternatives. They provide built-in semantics, work without JavaScript, and have native browser support.

<!-- For simple dropdown selection -->
<label for="fruit">Choose a fruit</label>
<select id="fruit">
  <option value="apple">Apple</option>
  <option value="banana">Banana</option>
</select>

<!-- For basic autocomplete -->
<label for="browser">Choose your browser</label>
<input list="browsers" id="browser" name="browser">
<datalist id="browsers">
  <option value="Chrome">
  <option value="Firefox">
  <option value="Safari">
</datalist>

Use a custom combobox only when you need: custom styling, complex filtering logic, rich option rendering, or behaviors not supported by native elements.

Use Case Native HTML Custom Implementation
Simple dropdown selection <select> Recommended Not needed
Basic autocomplete suggestions <datalist> Recommended Not needed
JavaScript disabled support Works natively Requires fallback
Custom option rendering (icons, descriptions) Not supported Full control
Custom filtering logic Basic prefix matching Custom algorithms
Consistent cross-browser styling Limited (especially datalist) Full control
Keyboard navigation customization Browser defaults only Customizable
Disabled options <select> only Fully supported

The native <select> element provides excellent accessibility, form submission support, and works without JavaScript. The <datalist> element provides basic autocomplete functionality, but its appearance varies significantly across browsers and lacks support for disabled options or custom rendering.

Accessibility Concerns with <datalist>

The <datalist> element has several known accessibility issues:

  • Text zoom not supported: The font size of datalist options does not scale when users zoom the page, creating issues for users who rely on text magnification.
  • Limited CSS styling: Options cannot be styled for high-contrast mode, preventing accommodation of users with visual impairments.
  • Screen reader compatibility: Some screen reader and browser combinations (e.g., NVDA with Firefox) do not announce the contents of the autosuggest popup.

Source: MDN Web Docs - <datalist>: Accessibility

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
combobox Input (<input>) The text input element that users type into
listbox Popup (<ul>) The popup containing selectable options
option Each item (<li>) An individual selectable option

WAI-ARIA combobox role (opens in new tab)

WAI-ARIA Properties (Input)

Attribute Values Required Description
role="combobox" - Yes Identifies the input as a combobox
aria-controls ID reference Yes References the listbox popup (even when closed)
aria-expanded true | false Yes Indicates whether the popup is open
aria-autocomplete list | none | both Yes Describes the autocomplete behavior
aria-activedescendant ID reference | empty Yes References the currently focused option in the popup
aria-labelledby ID reference Yes* References the label element

WAI-ARIA Properties (Listbox & Options)

Attribute Target Values Required Description
aria-labelledby listbox ID reference Yes References the label element
aria-selected option true | false Yes Indicates the currently focused option
aria-disabled option true No Indicates the option is disabled

Keyboard Support

Input (Popup Closed)

Key Action
Down Arrow Open popup and focus first option
Up Arrow Open popup and focus last option
Alt + Down Arrow Open popup without changing focus position
Type characters Filter options and open popup

Input (Popup Open)

Key Action
Down Arrow Move focus to next enabled option (no wrap)
Up Arrow Move focus to previous enabled option (no wrap)
Home Move focus to first enabled option
End Move focus to last enabled option
Enter Select focused option and close popup
Escape Close popup and restore previous input value
Alt + Up Arrow Select focused option and close popup
Tab Close popup and move to next focusable element

Focus Management

This component uses aria-activedescendant for virtual focus management:

  • DOM focus remains on the input at all times
  • aria-activedescendant references the visually focused option
  • Arrow keys update aria-activedescendant without moving DOM focus
  • Disabled options are skipped during navigation
  • aria-activedescendant is cleared when the popup closes or filter results are empty

Autocomplete Modes

Mode Behavior
list Options are filtered based on input value (default)
none All options shown regardless of input value
both Options filtered and first match auto-completed in input

Hidden State

When closed, the listbox uses the hidden attribute to:

  • Hide the popup from visual display
  • Remove the popup from the accessibility tree
  • The listbox element remains in the DOM so aria-controls reference is valid

Source Code

Combobox.svelte
<script lang="ts">
  import { cn } from '@/lib/utils';
  import { onDestroy, tick } from 'svelte';

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

  interface ComboboxProps {
    options: ComboboxOption[];
    selectedOptionId?: string;
    defaultSelectedOptionId?: string;
    inputValue?: string;
    defaultInputValue?: string;
    label: string;
    placeholder?: string;
    disabled?: boolean;
    autocomplete?: 'none' | 'list' | 'both';
    noResultsMessage?: string;
    onSelect?: (option: ComboboxOption) => void;
    onInputChange?: (value: string) => void;
    onOpenChange?: (isOpen: boolean) => void;
    class?: string;
  }

  let {
    options = [],
    selectedOptionId = undefined,
    defaultSelectedOptionId = undefined,
    inputValue = undefined,
    defaultInputValue = '',
    label,
    placeholder = undefined,
    disabled = false,
    autocomplete = 'list',
    noResultsMessage = 'No results found',
    onSelect = () => {},
    onInputChange = () => {},
    onOpenChange = () => {},
    class: className = '',
    ...restProps
  }: ComboboxProps = $props();

  // Generate ID for SSR-safe aria-controls/aria-labelledby
  const instanceId = `combobox-${Math.random().toString(36).slice(2, 11)}`;

  // Compute initial input value
  const getInitialInputValue = () => {
    if (defaultSelectedOptionId) {
      const option = options.find((o) => o.id === defaultSelectedOptionId);
      return option?.label ?? defaultInputValue;
    }
    return defaultInputValue;
  };

  // State
  let isOpen = $state(false);
  let activeIndex = $state(-1);
  let isComposing = $state(false);
  let valueBeforeOpen = '';
  let internalInputValue = $state(getInitialInputValue());
  let internalSelectedId = $state<string | undefined>(defaultSelectedOptionId);
  let isSearching = $state(false);

  // Refs
  let containerElement: HTMLDivElement;
  let inputElement: HTMLInputElement;

  // Derived values
  let inputId = $derived(`${instanceId}-input`);
  let labelId = $derived(`${instanceId}-label`);
  let listboxId = $derived(`${instanceId}-listbox`);

  let currentInputValue = $derived(inputValue !== undefined ? inputValue : internalInputValue);
  let currentSelectedId = $derived(selectedOptionId ?? internalSelectedId);

  // Get selected option's label
  let selectedLabel = $derived.by(() => {
    if (!currentSelectedId) {
      return '';
    }
    const option = options.find(({ id }) => id === currentSelectedId);
    return option?.label ?? '';
  });

  let filteredOptions = $derived.by(() => {
    // Don't filter if autocomplete is none
    if (autocomplete === 'none') {
      return options;
    }

    // Don't filter if input is empty
    if (!currentInputValue) {
      return options;
    }

    // Don't filter if not in search mode AND input matches selected label
    if (!isSearching && currentInputValue === selectedLabel) {
      return options;
    }

    const lowerInputValue = currentInputValue.toLowerCase();

    return options.filter(({ label }) => label.toLowerCase().includes(lowerInputValue));
  });

  let enabledOptions = $derived(filteredOptions.filter(({ disabled }) => !disabled));

  let activeDescendantId = $derived.by(() => {
    if (activeIndex < 0 || activeIndex >= filteredOptions.length) {
      return undefined;
    }
    const option = filteredOptions[activeIndex];
    return option ? getOptionId(option.id) : undefined;
  });

  onDestroy(() => {
    if (typeof document !== 'undefined') {
      document.removeEventListener('mousedown', handleClickOutside);
    }
  });

  // Click outside effect
  $effect(() => {
    if (typeof document === 'undefined') return;

    if (isOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    } else {
      document.removeEventListener('mousedown', handleClickOutside);
    }
  });

  // Clear active index when filtered options change
  $effect(() => {
    if (activeIndex >= 0 && activeIndex >= filteredOptions.length) {
      activeIndex = -1;
    }
  });

  // Reset search mode when input value matches selected label or becomes empty
  $effect(() => {
    if (currentInputValue === '' || currentInputValue === selectedLabel) {
      isSearching = false;
    }
  });

  function getOptionId(optionId: string): string {
    return `${instanceId}-option-${optionId}`;
  }

  function updateInputValue(value: string) {
    if (inputValue === undefined) {
      internalInputValue = value;
    }
    onInputChange(value);
  }

  function openPopup(focusPosition?: 'first' | 'last') {
    if (isOpen) {
      return;
    }

    valueBeforeOpen = currentInputValue;
    isOpen = true;
    onOpenChange(true);

    if (!focusPosition || enabledOptions.length === 0) {
      return;
    }

    const targetOption =
      focusPosition === 'first' ? enabledOptions[0] : enabledOptions[enabledOptions.length - 1];
    const { id: targetId } = targetOption;
    const targetIndex = filteredOptions.findIndex(({ id }) => id === targetId);
    activeIndex = targetIndex;
  }

  function closePopup(restore = false) {
    isOpen = false;
    activeIndex = -1;
    isSearching = false;
    onOpenChange(false);

    if (restore) {
      updateInputValue(valueBeforeOpen);
    }
  }

  function selectOption({ id, label, disabled }: ComboboxOption) {
    if (disabled) {
      return;
    }

    if (selectedOptionId === undefined) {
      internalSelectedId = id;
    }

    isSearching = false;
    updateInputValue(label);
    onSelect({ id, label, disabled });
    closePopup();
  }

  function findEnabledIndex(
    startIndex: number,
    direction: 'next' | 'prev' | 'first' | 'last'
  ): number {
    if (enabledOptions.length === 0) {
      return -1;
    }

    if (direction === 'first') {
      const { id: firstId } = enabledOptions[0];
      return filteredOptions.findIndex(({ id }) => id === firstId);
    }

    if (direction === 'last') {
      const { id: lastId } = enabledOptions[enabledOptions.length - 1];
      return filteredOptions.findIndex(({ id }) => id === lastId);
    }

    const currentOption = filteredOptions[startIndex];
    const currentEnabledIndex = currentOption
      ? enabledOptions.findIndex(({ id }) => id === currentOption.id)
      : -1;

    if (direction === 'next') {
      if (currentEnabledIndex < 0) {
        const { id: firstId } = enabledOptions[0];
        return filteredOptions.findIndex(({ id }) => id === firstId);
      }

      if (currentEnabledIndex >= enabledOptions.length - 1) {
        return startIndex;
      }

      const { id: nextId } = enabledOptions[currentEnabledIndex + 1];
      return filteredOptions.findIndex(({ id }) => id === nextId);
    }

    // direction === 'prev'
    if (currentEnabledIndex < 0) {
      const { id: lastId } = enabledOptions[enabledOptions.length - 1];
      return filteredOptions.findIndex(({ id }) => id === lastId);
    }

    if (currentEnabledIndex <= 0) {
      return startIndex;
    }

    const { id: prevId } = enabledOptions[currentEnabledIndex - 1];
    return filteredOptions.findIndex(({ id }) => id === prevId);
  }

  function handleClickOutside(event: MouseEvent) {
    if (!containerElement) {
      return;
    }

    if (!containerElement.contains(event.target as Node)) {
      closePopup();
    }
  }

  function handleInput(event: Event) {
    const target = event.target as HTMLInputElement;
    const value = target.value;
    isSearching = true;
    updateInputValue(value);

    if (!isOpen && !isComposing) {
      valueBeforeOpen = currentInputValue;
      isOpen = true;
      onOpenChange(true);
    }

    activeIndex = -1;
  }

  function handleKeyDown(event: KeyboardEvent) {
    if (isComposing) {
      return;
    }

    const { key, altKey } = event;

    switch (key) {
      case 'ArrowDown': {
        event.preventDefault();

        if (altKey) {
          if (isOpen) {
            return;
          }

          valueBeforeOpen = currentInputValue;
          isOpen = true;
          onOpenChange(true);
          return;
        }

        if (!isOpen) {
          openPopup('first');
          return;
        }

        const nextIndex = findEnabledIndex(activeIndex, 'next');

        if (nextIndex >= 0) {
          activeIndex = nextIndex;
        }
        break;
      }
      case 'ArrowUp': {
        event.preventDefault();

        if (altKey) {
          if (!isOpen || activeIndex < 0) {
            return;
          }

          const option = filteredOptions[activeIndex];

          if (option === undefined || option.disabled) {
            return;
          }

          selectOption(option);
          return;
        }

        if (!isOpen) {
          openPopup('last');
          return;
        }

        const prevIndex = findEnabledIndex(activeIndex, 'prev');

        if (prevIndex >= 0) {
          activeIndex = prevIndex;
        }
        break;
      }
      case 'Home': {
        if (!isOpen) {
          return;
        }

        event.preventDefault();

        const firstIndex = findEnabledIndex(0, 'first');

        if (firstIndex >= 0) {
          activeIndex = firstIndex;
        }
        break;
      }
      case 'End': {
        if (!isOpen) {
          return;
        }

        event.preventDefault();

        const lastIndex = findEnabledIndex(0, 'last');

        if (lastIndex >= 0) {
          activeIndex = lastIndex;
        }
        break;
      }
      case 'Enter': {
        if (!isOpen || activeIndex < 0) {
          return;
        }

        event.preventDefault();

        const option = filteredOptions[activeIndex];

        if (option === undefined || option.disabled) {
          return;
        }

        selectOption(option);
        break;
      }
      case 'Escape': {
        if (!isOpen) {
          return;
        }

        event.preventDefault();
        closePopup(true);
        break;
      }
      case 'Tab': {
        if (isOpen) {
          closePopup();
        }
        break;
      }
    }
  }

  function handleOptionClick(option: ComboboxOption) {
    if (option.disabled) {
      return;
    }

    selectOption(option);
  }

  function handleOptionHover({ id }: ComboboxOption) {
    const index = filteredOptions.findIndex((option) => option.id === id);

    if (index < 0) {
      return;
    }

    activeIndex = index;
  }

  function handleCompositionStart() {
    isComposing = true;
  }

  function handleCompositionEnd() {
    isComposing = false;
  }

  // Handle focus - open popup when input receives focus
  function handleFocus() {
    if (isOpen || disabled) {
      return;
    }

    openPopup();
  }
</script>

<div bind:this={containerElement} class={cn('apg-combobox', className)}>
  <label id={labelId} for={inputId} class="apg-combobox-label">
    {label}
  </label>
  <div class="apg-combobox-input-wrapper">
    <input
      bind:this={inputElement}
      id={inputId}
      type="text"
      role="combobox"
      class="apg-combobox-input"
      aria-autocomplete={autocomplete}
      aria-expanded={isOpen}
      aria-controls={listboxId}
      aria-labelledby={labelId}
      aria-activedescendant={activeDescendantId}
      value={currentInputValue}
      {placeholder}
      {disabled}
      oninput={handleInput}
      onkeydown={handleKeyDown}
      onfocus={handleFocus}
      oncompositionstart={handleCompositionStart}
      oncompositionend={handleCompositionEnd}
      {...restProps}
    />
    <span class="apg-combobox-caret" aria-hidden="true">
      <svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
        <path
          fill-rule="evenodd"
          d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
          clip-rule="evenodd"
        />
      </svg>
    </span>
  </div>
  <!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
  <ul
    id={listboxId}
    role="listbox"
    aria-labelledby={labelId}
    class="apg-combobox-listbox"
    hidden={!isOpen ? true : undefined}
  >
    {#if filteredOptions.length === 0}
      <li class="apg-combobox-no-results" role="status">
        {noResultsMessage}
      </li>
    {/if}
    {#each filteredOptions as option, index}
      {@const isActive = index === activeIndex}
      {@const isSelected = option.id === currentSelectedId}
      <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
      <li
        id={getOptionId(option.id)}
        role="option"
        class="apg-combobox-option"
        aria-selected={isActive}
        aria-disabled={option.disabled || undefined}
        data-selected={isSelected || undefined}
        onclick={() => handleOptionClick(option)}
        onmouseenter={() => handleOptionHover(option)}
      >
        <span class="apg-combobox-option-icon" aria-hidden="true">
          <svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
            <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"
            />
          </svg>
        </span>
        {option.label}
      </li>
    {/each}
  </ul>
</div>

Usage

Example
<script>
  import Combobox from './Combobox.svelte';

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

  function handleSelect(option) {
    console.log('Selected:', option);
  }

  function handleInputChange(value) {
    console.log('Input:', value);
  }

  function handleOpenChange(isOpen) {
    console.log('Open:', isOpen);
  }
</script>

<!-- Basic usage -->
<Combobox
  {options}
  label="Favorite Fruit"
  placeholder="Type to search..."
/>

<!-- With default value -->
<Combobox
  {options}
  label="Fruit"
  defaultSelectedOptionId="banana"
/>

<!-- With disabled options -->
<Combobox
  options={[
    { id: 'a', label: 'Option A' },
    { id: 'b', label: 'Option B', disabled: true },
    { id: 'c', label: 'Option C' },
  ]}
  label="Select Option"
/>

<!-- No filtering (autocomplete="none") -->
<Combobox
  {options}
  label="Select"
  autocomplete="none"
/>

<!-- With callbacks -->
<Combobox
  {options}
  label="Fruit"
  onselect={handleSelect}
  oninputchange={handleInputChange}
  onopenchange={handleOpenChange}
/>

API

Prop Type Default Description
options ComboboxOption[] Required Array of options with id, label, and optional disabled
label string Required Visible label text
placeholder string - Placeholder text for input
defaultInputValue string "" Default input value
defaultSelectedOptionId string - ID of initially selected option
autocomplete "none" | "list" | "both" "list" Autocomplete behavior
disabled boolean false Whether the combobox is disabled
onselect (option: ComboboxOption) => void - Callback when an option is selected
oninputchange (value: string) => void - Callback when input value changes
onopenchange (isOpen: boolean) => void - Callback when popup opens/closes

Testing

Tests verify APG compliance for ARIA attributes, keyboard interactions, filtering behavior, and accessibility requirements.

Test Categories

High Priority: ARIA Attributes

Test Description
role="combobox" Input element has the combobox role
role="listbox" Popup element has the listbox role
role="option" Each option has the option role
aria-controls Input references the listbox ID (always present)
aria-expanded Reflects popup open/closed state
aria-autocomplete Set to "list", "none", or "both"
aria-activedescendant References currently focused option
aria-selected Indicates the currently highlighted option
aria-disabled Indicates disabled options

High Priority: Accessible Name

Test Description
aria-labelledby Input references visible label element
aria-labelledby (listbox) Listbox also references the label

High Priority: Keyboard Interaction (Popup Closed)

Test Description
Down Arrow Opens popup and focuses first option
Up Arrow Opens popup and focuses last option
Alt + Down Arrow Opens popup without changing focus
Typing Opens popup and filters options

High Priority: Keyboard Interaction (Popup Open)

Test Description
Down Arrow Moves to next enabled option (no wrap)
Up Arrow Moves to previous enabled option (no wrap)
Home Moves to first enabled option
End Moves to last enabled option
Enter Selects focused option and closes popup
Escape Closes popup and restores previous value
Alt + Up Arrow Selects focused option and closes popup
Tab Closes popup and moves to next focusable element

High Priority: Focus Management

Test Description
DOM focus on input DOM focus remains on input at all times
Virtual focus via aria-activedescendant Visual focus controlled by aria-activedescendant
Clear on close aria-activedescendant cleared when popup closes
Skip disabled options Navigation skips disabled options

Medium Priority: Filtering

Test Description
Filter on typing Options filtered as user types
Case insensitive Filtering is case insensitive
No filter (autocomplete="none") All options shown regardless of input
Empty results aria-activedescendant cleared when no matches

Medium Priority: Mouse Interaction

Test Description
Click option Selects option and closes popup
Hover option Updates aria-activedescendant on hover
Click disabled Disabled options cannot be selected
Click outside Closes popup without selection

Medium Priority: IME Composition

Test Description
During composition Keyboard navigation blocked during IME
On composition end Filtering updates after composition ends

Medium Priority: Callbacks

Test Description
onSelect Called with option data when selected
onInputChange Called with input value on typing
onOpenChange Called when popup opens or closes

Low Priority: HTML Attribute Inheritance

Test Description
className Custom class is applied to container
placeholder Placeholder text is shown in input
disabled state Component is disabled when disabled prop is set

Testing Tools

Combobox.test.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Combobox from './Combobox.svelte';

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

// Options with disabled item
const optionsWithDisabled = [
  { id: 'apple', label: 'Apple' },
  { id: 'banana', label: 'Banana', disabled: true },
  { id: 'cherry', label: 'Cherry' },
];

// Options with first item disabled
const optionsWithFirstDisabled = [
  { id: 'apple', label: 'Apple', disabled: true },
  { id: 'banana', label: 'Banana' },
  { id: 'cherry', label: 'Cherry' },
];

// Options with last item disabled
const optionsWithLastDisabled = [
  { id: 'apple', label: 'Apple' },
  { id: 'banana', label: 'Banana' },
  { id: 'cherry', label: 'Cherry', disabled: true },
];

// All disabled options
const allDisabledOptions = [
  { id: 'apple', label: 'Apple', disabled: true },
  { id: 'banana', label: 'Banana', disabled: true },
  { id: 'cherry', label: 'Cherry', disabled: true },
];

describe('Combobox (Svelte)', () => {
  // 🔴 High Priority: APG ARIA Attributes
  describe('APG: ARIA Attributes', () => {
    it('input has role="combobox"', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      expect(screen.getByRole('combobox')).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Select a fruit' },
      });
      const input = screen.getByRole('combobox');
      expect(input).toHaveAccessibleName('Select a fruit');
    });

    it('has aria-controls pointing to listbox', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      const input = screen.getByRole('combobox');
      const listboxId = input.getAttribute('aria-controls');

      expect(listboxId).toBeTruthy();
      expect(document.getElementById(listboxId!)).toHaveAttribute('role', 'listbox');
    });

    it('aria-controls points to existing listbox even when closed', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      const input = screen.getByRole('combobox');
      const listboxId = input.getAttribute('aria-controls');

      expect(listboxId).toBeTruthy();
      const listbox = document.getElementById(listboxId!);
      expect(listbox).toBeInTheDocument();
      expect(listbox).toHaveAttribute('hidden');
    });

    it('has aria-expanded="false" when closed', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false');
    });

    it('has aria-expanded="true" when opened', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      expect(input).toHaveAttribute('aria-expanded', 'true');
    });

    it('has aria-autocomplete="list"', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'list');
    });

    it('has aria-autocomplete="none" when autocomplete is none', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', autocomplete: 'none' },
      });
      expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'none');
    });

    it('has aria-activedescendant when option focused', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      expect(input).toHaveAttribute('aria-activedescendant');
      const activeId = input.getAttribute('aria-activedescendant');
      expect(activeId).toBeTruthy();
      expect(document.getElementById(activeId!)).toHaveTextContent('Apple');
    });

    it('clears aria-activedescendant when closed', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(input.getAttribute('aria-activedescendant')).toBeTruthy();

      await user.keyboard('{Escape}');
      expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
    });

    it('listbox has role="listbox"', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      expect(screen.getByRole('listbox')).toBeInTheDocument();
    });

    it('options have role="option"', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(3);
    });

    it('focused option has aria-selected="true"', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const firstOption = screen.getByRole('option', { name: 'Apple' });
      expect(firstOption).toHaveAttribute('aria-selected', 'true');
    });

    it('disabled option has aria-disabled="true"', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const disabledOption = screen.getByRole('option', { name: 'Banana' });
      expect(disabledOption).toHaveAttribute('aria-disabled', 'true');
    });
  });

  // 🔴 High Priority: APG Keyboard Interaction (Input)
  describe('APG: Keyboard Interaction (Input)', () => {
    it('opens popup and focuses first enabled option on ArrowDown', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      expect(input).toHaveAttribute('aria-expanded', 'true');
      const activeId = input.getAttribute('aria-activedescendant');
      expect(document.getElementById(activeId!)).toHaveTextContent('Apple');
    });

    it('opens popup and focuses last enabled option on ArrowUp', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowUp}');

      expect(input).toHaveAttribute('aria-expanded', 'true');
      const activeId = input.getAttribute('aria-activedescendant');
      expect(document.getElementById(activeId!)).toHaveTextContent('Cherry');
    });

    it('skips disabled first option on ArrowDown', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithFirstDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const activeId = input.getAttribute('aria-activedescendant');
      expect(document.getElementById(activeId!)).toHaveTextContent('Banana');
    });

    it('skips disabled last option on ArrowUp', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithLastDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowUp}');

      const activeId = input.getAttribute('aria-activedescendant');
      expect(document.getElementById(activeId!)).toHaveTextContent('Banana');
    });

    it('closes popup on Escape', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(input).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{Escape}');
      expect(input).toHaveAttribute('aria-expanded', 'false');
    });

    it('selects option and closes popup on Enter', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Enter}');

      expect(onSelect).toHaveBeenCalledWith(defaultOptions[0]);
      expect(input).toHaveAttribute('aria-expanded', 'false');
      expect(input).toHaveValue('Apple');
    });

    it('closes popup on Tab', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(input).toHaveAttribute('aria-expanded', 'true');

      await user.keyboard('{Tab}');
      expect(input).toHaveAttribute('aria-expanded', 'false');
    });

    it('opens popup on typing', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.type(input, 'a');

      expect(input).toHaveAttribute('aria-expanded', 'true');
    });

    it('Alt+ArrowDown opens without changing focus position', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{Alt>}{ArrowDown}{/Alt}');

      expect(input).toHaveAttribute('aria-expanded', 'true');
      expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
    });

    it('Alt+ArrowUp commits selection and closes', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Alt>}{ArrowUp}{/Alt}');

      expect(onSelect).toHaveBeenCalledWith(defaultOptions[1]);
      expect(input).toHaveAttribute('aria-expanded', 'false');
    });
  });

  // 🔴 High Priority: APG Keyboard Interaction (Listbox Navigation)
  describe('APG: Keyboard Interaction (Listbox)', () => {
    it('moves to next enabled option on ArrowDown', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');

      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');
    });

    it('moves to previous enabled option on ArrowUp', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');

      await user.keyboard('{ArrowUp}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');
    });

    it('skips disabled option on ArrowDown', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');

      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');
    });

    it('skips disabled option on ArrowUp', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowUp}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');

      await user.keyboard('{ArrowUp}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');
    });

    it('moves to first enabled option on Home', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithFirstDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowUp}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');

      await user.keyboard('{Home}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');
    });

    it('moves to last enabled option on End', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: optionsWithLastDisabled, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');

      await user.keyboard('{End}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');
    });

    it('does not wrap on ArrowDown at last option', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');

      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');
    });

    it('does not wrap on ArrowUp at first option', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');

      await user.keyboard('{ArrowUp}');
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('APG: Focus Management', () => {
    it('keeps DOM focus on input when navigating', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');

      expect(input).toHaveFocus();
    });

    it('updates aria-activedescendant on navigation', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const firstActiveId = input.getAttribute('aria-activedescendant');
      expect(firstActiveId).toBeTruthy();

      await user.keyboard('{ArrowDown}');

      const secondActiveId = input.getAttribute('aria-activedescendant');
      expect(secondActiveId).toBeTruthy();
      expect(secondActiveId).not.toBe(firstActiveId);
    });

    it('maintains focus on input after selection', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Enter}');

      expect(input).toHaveFocus();
    });
  });

  // 🔴 High Priority: Autocomplete
  describe('Autocomplete', () => {
    it('filters options based on input', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.type(input, 'app');

      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(1);
      expect(options[0]).toHaveTextContent('Apple');
    });

    it('shows all options when input is empty', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(3);
    });

    it('updates input value on selection', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Enter}');

      expect(input).toHaveValue('Banana');
    });

    it('does not filter when autocomplete="none"', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', autocomplete: 'none' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.type(input, 'xyz');

      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(3);
    });
  });

  // 🔴 High Priority: Disabled Options
  describe('Disabled Options', () => {
    it('does not select disabled option on Enter', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: optionsWithFirstDisabled, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowUp}');
      await user.keyboard('{Enter}');

      expect(onSelect).toHaveBeenCalledWith(optionsWithFirstDisabled[1]);
    });

    it('does not select disabled option on click', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: optionsWithDisabled, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const disabledOption = screen.getByRole('option', { name: 'Banana' });
      await user.click(disabledOption);

      expect(onSelect).not.toHaveBeenCalled();
      expect(input).toHaveAttribute('aria-expanded', 'true');
    });
  });

  // 🔴 High Priority: Mouse Interaction
  describe('Mouse Interaction', () => {
    it('selects option on click', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const option = screen.getByRole('option', { name: 'Banana' });
      await user.click(option);

      expect(onSelect).toHaveBeenCalledWith(defaultOptions[1]);
      expect(input).toHaveValue('Banana');
    });

    it('closes popup on option click', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(input).toHaveAttribute('aria-expanded', 'true');

      const option = screen.getByRole('option', { name: 'Banana' });
      await user.click(option);

      expect(input).toHaveAttribute('aria-expanded', 'false');
    });

    it('closes popup on outside click', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(input).toHaveAttribute('aria-expanded', 'true');

      await user.click(document.body);
      expect(input).toHaveAttribute('aria-expanded', 'false');
    });

    it('updates aria-selected on hover', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const bananaOption = screen.getByRole('option', { name: 'Banana' });
      await user.hover(bananaOption);

      expect(bananaOption).toHaveAttribute('aria-selected', 'true');
      expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute(
        'aria-selected',
        'false'
      );
    });
  });

  // 🟡 Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('has no axe violations when closed', async () => {
      const { container } = render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when open', async () => {
      const user = userEvent.setup();
      const { container } = render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with selection', async () => {
      const user = userEvent.setup();
      const { container } = render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Enter}');

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: Props & Behavior
  describe('Props & Behavior', () => {
    it('calls onSelect when option selected', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onSelect },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{Enter}');

      expect(onSelect).toHaveBeenCalledWith(defaultOptions[0]);
      expect(onSelect).toHaveBeenCalledTimes(1);
    });

    it('calls onInputChange when typing', async () => {
      const user = userEvent.setup();
      const onInputChange = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onInputChange },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.type(input, 'app');

      expect(onInputChange).toHaveBeenCalledWith('a');
      expect(onInputChange).toHaveBeenCalledWith('ap');
      expect(onInputChange).toHaveBeenCalledWith('app');
    });

    it('calls onOpenChange when popup toggles', async () => {
      const user = userEvent.setup();
      const onOpenChange = vi.fn();
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', onOpenChange },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      expect(onOpenChange).toHaveBeenCalledWith(true);

      await user.keyboard('{Escape}');
      expect(onOpenChange).toHaveBeenCalledWith(false);
    });

    it('applies className to container', () => {
      const { container } = render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', class: 'custom-class' },
      });

      expect(container.querySelector('.apg-combobox')).toHaveClass('custom-class');
    });

    it('supports disabled state on combobox', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', disabled: true },
      });

      const input = screen.getByRole('combobox');
      expect(input).toBeDisabled();
    });

    it('supports placeholder', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', placeholder: 'Choose a fruit...' },
      });

      const input = screen.getByRole('combobox');
      expect(input).toHaveAttribute('placeholder', 'Choose a fruit...');
    });

    it('supports defaultInputValue', () => {
      render(Combobox, {
        props: { options: defaultOptions, label: 'Fruit', defaultInputValue: 'Ban' },
      });

      const input = screen.getByRole('combobox');
      expect(input).toHaveValue('Ban');
    });
  });

  // Edge Cases
  describe('Edge Cases', () => {
    it('handles empty options array', () => {
      expect(() => {
        render(Combobox, {
          props: { options: [], label: 'Fruit' },
        });
      }).not.toThrow();

      expect(screen.getByRole('combobox')).toBeInTheDocument();
    });

    it('when all options are disabled, popup opens but no focus set', async () => {
      const user = userEvent.setup();
      render(Combobox, {
        props: { options: allDisabledOptions, label: 'Fruit' },
      });

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');

      expect(input).toHaveAttribute('aria-expanded', 'true');
      expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
    });
  });
});

Resources