APG Patterns
日本語
日本語

Combobox

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

Demo

Open demo only →

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

RoleTarget ElementDescription
comboboxInput (<input>)The text input element that users type into
listboxPopup (<ul>)The popup containing selectable options
optionEach item (<li>)An individual selectable option

WAI-ARIA Properties

role="combobox"

Identifies the input as a combobox

Values
-
Required
Yes

aria-controls

References the listbox popup (even when closed)

Values
ID reference
Required
Yes

aria-expanded

Indicates whether the popup is open

Values
true | false
Required
Yes

aria-autocomplete

Describes the autocomplete behavior

Values
list | none | both
Required
Yes

aria-activedescendant

References the currently focused option in the popup

Values
ID reference | empty
Required
Yes

aria-labelledby

References the label element

Values
ID reference
Required
Yes*

aria-selected

Indicates the currently focused option

Values
true | false
Required
Yes

aria-disabled

Indicates the option is disabled

Values
true
Required
No

Keyboard Support

KeyAction
Down ArrowOpen popup and focus first option
Up ArrowOpen popup and focus last option
Alt + Down ArrowOpen popup without changing focus position
Type charactersFilter options and open popup
Down ArrowMove focus to next enabled option (no wrap)
Up ArrowMove focus to previous enabled option (no wrap)
HomeMove focus to first enabled option
EndMove focus to last enabled option
EnterSelect focused option and close popup
EscapeClose popup and restore previous input value
Alt + Up ArrowSelect focused option and close popup
TabClose popup and move to next focusable element
  • Listbox always in DOM: Keep listbox in DOM with hidden attribute when closed (for aria-controls reference)
  • IME Handling: Track composition state to prevent filtering during IME input
  • Click Outside: Use event listener to close popup on outside clicks
  • Value Restoration: Store pre-edit value to restore on Escape

Focus Management

EventBehavior
Navigation via arrow keysDOM focus remains on input; aria-activedescendant references the visually focused option
Popup closes or filter results are emptyaria-activedescendant is cleared
Disabled option encounteredDisabled options are skipped during navigation

References

Source Code

Combobox.astro
---
/**
 * APG Combobox Pattern - Astro Implementation
 *
 * An editable combobox with list autocomplete.
 * Uses Web Components for enhanced control and proper focus management.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
 */

import { cn } from '@/lib/utils';

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

export interface Props {
  /** Array of options */
  options: ComboboxOption[];
  /** Label text */
  label: string;
  /** Placeholder text */
  placeholder?: string;
  /** Default input value */
  defaultInputValue?: string;
  /** Default selected option ID */
  defaultSelectedOptionId?: string;
  /** Autocomplete type */
  autocomplete?: 'none' | 'list' | 'both';
  /** Disabled state */
  disabled?: boolean;
  /** Message shown when no results found */
  noResultsMessage?: string;
  /** Additional CSS class */
  class?: string;
}

const {
  options = [],
  label,
  placeholder = '',
  defaultInputValue = '',
  defaultSelectedOptionId,
  autocomplete = 'list',
  disabled = false,
  noResultsMessage = 'No results found',
  class: className = '',
} = Astro.props;

// Generate unique ID for this instance
const instanceId = `combobox-${Math.random().toString(36).slice(2, 11)}`;
const inputId = `${instanceId}-input`;
const labelId = `${instanceId}-label`;
const listboxId = `${instanceId}-listbox`;

// Calculate initial input value
const initialInputValue = defaultSelectedOptionId
  ? (options.find((o) => o.id === defaultSelectedOptionId)?.label ?? defaultInputValue)
  : defaultInputValue;
---

<apg-combobox
  data-autocomplete={autocomplete}
  data-default-input-value={initialInputValue}
  data-default-selected-id={defaultSelectedOptionId || ''}
>
  <div class={cn('apg-combobox', className)}>
    <label id={labelId} for={inputId} class="apg-combobox-label">
      {label}
    </label>
    <div class="apg-combobox-input-wrapper">
      <input
        id={inputId}
        type="text"
        role="combobox"
        class="apg-combobox-input"
        aria-autocomplete={autocomplete}
        aria-expanded="false"
        aria-controls={listboxId}
        aria-labelledby={labelId}
        value={initialInputValue}
        placeholder={placeholder}
        disabled={disabled}
        data-combobox-input
      />
      <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"></path>
        </svg>
      </span>
    </div>
    <ul
      id={listboxId}
      role="listbox"
      aria-labelledby={labelId}
      class="apg-combobox-listbox"
      hidden
      data-combobox-listbox
    >
      <li class="apg-combobox-no-results" role="status" hidden data-no-results>
        {noResultsMessage}
      </li>
      {
        options.map((option) => (
          <li
            id={`${instanceId}-option-${option.id}`}
            role="option"
            class="apg-combobox-option"
            aria-selected="false"
            aria-disabled={option.disabled || undefined}
            data-option-id={option.id}
            data-option-label={option.label}
            data-selected={option.id === defaultSelectedOptionId || undefined}
          >
            <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>
        ))
      }
    </ul>
  </div>
</apg-combobox>

<script>
  class ApgCombobox extends HTMLElement {
    private container: HTMLDivElement | null = null;
    private input: HTMLInputElement | null = null;
    private listbox: HTMLUListElement | null = null;
    private rafId: number | null = null;

    private isOpen = false;
    private activeIndex = -1;
    private isComposing = false;
    private valueBeforeOpen = '';
    private autocomplete: 'none' | 'list' | 'both' = 'list';
    private allOptions: HTMLLIElement[] = [];
    private noResultsElement: HTMLLIElement | null = null;
    private isSearching = false;
    private selectedId: string | null = null;

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

    private initialize() {
      this.rafId = null;
      this.container = this.querySelector('.apg-combobox');
      this.input = this.querySelector('[data-combobox-input]');
      this.listbox = this.querySelector('[data-combobox-listbox]');

      if (!this.input || !this.listbox) {
        console.warn('apg-combobox: required elements not found');
        return;
      }

      // Initialize state from data attributes
      this.autocomplete = (this.dataset.autocomplete as 'none' | 'list' | 'both') || 'list';
      this.allOptions = Array.from(this.listbox.querySelectorAll<HTMLLIElement>('[role="option"]'));
      this.noResultsElement = this.listbox.querySelector<HTMLLIElement>('[data-no-results]');
      this.selectedId = this.dataset.defaultSelectedId || null;

      // Attach event listeners
      this.input.addEventListener('input', this.handleInput);
      this.input.addEventListener('keydown', this.handleKeyDown);
      this.input.addEventListener('focus', this.handleFocus);
      this.input.addEventListener('compositionstart', this.handleCompositionStart);
      this.input.addEventListener('compositionend', this.handleCompositionEnd);
      this.listbox.addEventListener('click', this.handleListboxClick);
      this.listbox.addEventListener('mouseenter', this.handleListboxMouseEnter, true);
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      document.removeEventListener('pointerdown', this.handleClickOutside);
      this.input?.removeEventListener('input', this.handleInput);
      this.input?.removeEventListener('keydown', this.handleKeyDown);
      this.input?.removeEventListener('focus', this.handleFocus);
      this.input?.removeEventListener('compositionstart', this.handleCompositionStart);
      this.input?.removeEventListener('compositionend', this.handleCompositionEnd);
      this.listbox?.removeEventListener('click', this.handleListboxClick);
      this.listbox?.removeEventListener('mouseenter', this.handleListboxMouseEnter, true);
    }

    private getSelectedLabel(): string {
      if (!this.selectedId) {
        return '';
      }
      const selectedOption = this.allOptions.find(
        (option) => option.dataset.optionId === this.selectedId
      );
      return selectedOption?.dataset.optionLabel ?? '';
    }

    private getFilteredOptions(): HTMLLIElement[] {
      if (!this.input) {
        return [];
      }

      const inputValue = this.input.value;
      const selectedLabel = this.getSelectedLabel();

      // Don't filter if autocomplete is none
      if (this.autocomplete === 'none') {
        return this.allOptions;
      }

      // Don't filter if input is empty
      if (!inputValue) {
        return this.allOptions;
      }

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

      const lowerInputValue = inputValue.toLowerCase();

      return this.allOptions.filter((option) => {
        const { optionLabel } = option.dataset;
        const label = optionLabel?.toLowerCase() ?? '';
        return label.includes(lowerInputValue);
      });
    }

    private getEnabledOptions(): HTMLLIElement[] {
      return this.getFilteredOptions().filter(
        (option) => option.getAttribute('aria-disabled') !== 'true'
      );
    }

    private updateListboxVisibility() {
      if (!this.listbox) return;

      const filteredOptions = this.getFilteredOptions();

      // Hide all options first
      this.allOptions.forEach((option) => {
        option.hidden = true;
      });

      // Show filtered options
      filteredOptions.forEach((option) => {
        option.hidden = false;
      });

      // Show/hide no results message
      if (this.noResultsElement) {
        this.noResultsElement.hidden = filteredOptions.length > 0;
      }
    }

    private openPopup(focusPosition?: 'first' | 'last') {
      if (!this.input || !this.listbox || this.isOpen) {
        return;
      }

      this.valueBeforeOpen = this.input.value;
      this.isOpen = true;
      this.input.setAttribute('aria-expanded', 'true');
      this.listbox.removeAttribute('hidden');

      this.updateListboxVisibility();
      document.addEventListener('pointerdown', this.handleClickOutside);

      if (!focusPosition) {
        return;
      }

      const enabledOptions = this.getEnabledOptions();

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

      const targetOption =
        focusPosition === 'first' ? enabledOptions[0] : enabledOptions[enabledOptions.length - 1];
      const filteredOptions = this.getFilteredOptions();
      this.activeIndex = filteredOptions.indexOf(targetOption);
      this.updateActiveDescendant();
    }

    private closePopup(restore = false) {
      if (!this.input || !this.listbox) {
        return;
      }

      this.isOpen = false;
      this.activeIndex = -1;
      this.isSearching = false;
      this.input.setAttribute('aria-expanded', 'false');
      this.input.removeAttribute('aria-activedescendant');
      this.listbox.setAttribute('hidden', '');

      // Reset aria-selected
      this.allOptions.forEach((option) => {
        option.setAttribute('aria-selected', 'false');
      });

      document.removeEventListener('pointerdown', this.handleClickOutside);

      if (restore && this.input) {
        this.input.value = this.valueBeforeOpen;
      }
    }

    private updateSelectedState() {
      this.allOptions.forEach((option) => {
        const { optionId } = option.dataset;
        if (optionId === this.selectedId) {
          option.dataset.selected = 'true';
        } else {
          delete option.dataset.selected;
        }
      });
    }

    private selectOption(option: HTMLLIElement) {
      if (!this.input || option.getAttribute('aria-disabled') === 'true') {
        return;
      }

      const { optionLabel, optionId } = option.dataset;
      const label = optionLabel ?? option.textContent?.trim() ?? '';
      const id = optionId ?? '';

      this.selectedId = id;
      this.isSearching = false;
      this.input.value = label;
      this.updateSelectedState();

      this.dispatchEvent(
        new CustomEvent('select', {
          detail: { id, label },
          bubbles: true,
        })
      );

      this.closePopup();
    }

    private updateActiveDescendant() {
      if (!this.input) {
        return;
      }

      const filteredOptions = this.getFilteredOptions();

      // Reset all aria-selected
      this.allOptions.forEach((option) => {
        option.setAttribute('aria-selected', 'false');
      });

      if (this.activeIndex < 0 || this.activeIndex >= filteredOptions.length) {
        this.input.removeAttribute('aria-activedescendant');
        return;
      }

      const activeOption = filteredOptions[this.activeIndex];
      this.input.setAttribute('aria-activedescendant', activeOption.id);
      activeOption.setAttribute('aria-selected', 'true');
    }

    private findEnabledIndex(
      startIndex: number,
      direction: 'next' | 'prev' | 'first' | 'last'
    ): number {
      const enabledOptions = this.getEnabledOptions();
      const filteredOptions = this.getFilteredOptions();

      if (enabledOptions.length === 0) {
        return -1;
      }

      if (direction === 'first') {
        return filteredOptions.indexOf(enabledOptions[0]);
      }

      if (direction === 'last') {
        return filteredOptions.indexOf(enabledOptions[enabledOptions.length - 1]);
      }

      const currentOption = filteredOptions[startIndex];
      const currentEnabledIndex = currentOption ? enabledOptions.indexOf(currentOption) : -1;

      if (direction === 'next') {
        if (currentEnabledIndex < 0) {
          return filteredOptions.indexOf(enabledOptions[0]);
        }

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

        return filteredOptions.indexOf(enabledOptions[currentEnabledIndex + 1]);
      }

      // direction === 'prev'
      if (currentEnabledIndex < 0) {
        return filteredOptions.indexOf(enabledOptions[enabledOptions.length - 1]);
      }

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

      return filteredOptions.indexOf(enabledOptions[currentEnabledIndex - 1]);
    }

    private handleInput = () => {
      if (!this.input) {
        return;
      }

      this.isSearching = true;

      if (!this.isOpen && !this.isComposing) {
        this.valueBeforeOpen = this.input.value;
        this.openPopup();
      }

      this.updateListboxVisibility();
      this.activeIndex = -1;
      this.updateActiveDescendant();

      // Reset search mode if input matches selected label or is empty
      const selectedLabel = this.getSelectedLabel();
      if (this.input.value === '' || this.input.value === selectedLabel) {
        this.isSearching = false;
      }

      this.dispatchEvent(
        new CustomEvent('inputchange', {
          detail: { value: this.input.value },
          bubbles: true,
        })
      );
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      if (this.isComposing) {
        return;
      }

      const { key, altKey } = event;

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

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

            this.openPopup();
            return;
          }

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

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

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

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

            const filteredOptions = this.getFilteredOptions();
            const option = filteredOptions[this.activeIndex];

            if (!option || option.getAttribute('aria-disabled') === 'true') {
              return;
            }

            this.selectOption(option);
            return;
          }

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

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

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

          event.preventDefault();

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

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

          event.preventDefault();

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

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

          event.preventDefault();

          const filteredOptions = this.getFilteredOptions();
          const option = filteredOptions[this.activeIndex];

          if (!option || option.getAttribute('aria-disabled') === 'true') {
            return;
          }

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

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

    private handleListboxClick = (event: MouseEvent) => {
      const target = event.target as HTMLElement;
      const option = target.closest('[role="option"]') as HTMLLIElement | null;

      if (!option || option.getAttribute('aria-disabled') === 'true') {
        return;
      }

      this.selectOption(option);
    };

    private handleListboxMouseEnter = (event: MouseEvent) => {
      const target = event.target as HTMLElement;
      const option = target.closest('[role="option"]') as HTMLLIElement | null;

      if (!option) {
        return;
      }

      const filteredOptions = this.getFilteredOptions();
      const index = filteredOptions.indexOf(option);

      if (index < 0) {
        return;
      }

      this.activeIndex = index;
      this.updateActiveDescendant();
    };

    private handleCompositionStart = () => {
      this.isComposing = true;
    };

    private handleCompositionEnd = () => {
      this.isComposing = false;
    };

    // Handle focus - open popup when input receives focus
    private handleFocus = () => {
      if (this.isOpen || !this.input || this.input.disabled) {
        return;
      }

      this.openPopup();
    };

    private handleClickOutside = (event: PointerEvent) => {
      if (!this.container) {
        return;
      }

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

  if (!customElements.get('apg-combobox')) {
    customElements.define('apg-combobox', ApgCombobox);
  }
</script>

Usage

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

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

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

<!-- With default value -->
<Combobox
  options={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={options}
  label="Select"
  autocomplete="none"
/>

<!-- Listen to selection events (Web Component event) -->
<Combobox id="my-combobox" options={options} label="Fruit" />

<script>
  const combobox = document.querySelector('#my-combobox');
  combobox?.addEventListener('select', (e) => {
    console.log('Selected:', e.detail);
  });
  combobox?.addEventListener('inputchange', (e) => {
    console.log('Input:', e.detail.value);
  });
</script>

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
This component uses Web Components for client-side interactivity without requiring hydration.

Custom Events

Event Detail Description
select {id: string, label: string} Dispatched when an option is selected
inputchange {value: string} Dispatched when input value changes

Testing

Tests verify APG compliance for ARIA attributes, keyboard interactions, filtering behavior, and accessibility requirements. The Combobox 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-controls, aria-expanded, etc.)
  • Keyboard interaction (Arrow keys, Enter, Escape, etc.)
  • Filtering behavior and option rendering
  • 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 and selection
  • Mouse interactions (click, hover)
  • ARIA structure in live browser
  • Focus management with aria-activedescendant
  • axe-core accessibility scanning
  • Cross-framework consistency checks

Test Categories

High Priority: ARIA Attributes (Unit + E2E)

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: Keyboard - Popup Closed (Unit + E2E)

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 - Popup Open (Unit + E2E)

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 (Unit + E2E)

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 (Unit)

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 (E2E)

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

Testing Tools

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

Resources