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 CaseNative HTMLCustom Implementation
Simple dropdown selection<select> RecommendedNot needed
Basic autocomplete suggestions<datalist> RecommendedNot needed
JavaScript disabled supportWorks nativelyRequires fallback
Custom option rendering (icons, descriptions)Not supportedFull control
Custom filtering logicBasic prefix matchingCustom algorithms
Consistent cross-browser stylingLimited (especially datalist)Full control
Keyboard navigation customizationBrowser defaults onlyCustomizable
Disabled options<select> onlyFully 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 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

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
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
  • 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

Event Behavior
Navigation via arrow keys DOM focus remains on input; aria-activedescendant references the visually focused option
Popup closes or filter results are empty aria-activedescendant is cleared
Disabled option encountered Disabled options are skipped during navigation

References

Source Code

Combobox.tsx
import { cn } from '@/lib/utils';
import type { HTMLAttributes, KeyboardEvent, ReactElement } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';

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

export interface ComboboxProps extends Omit<
  HTMLAttributes<HTMLDivElement>,
  'onChange' | 'onSelect'
> {
  /** List of options */
  options: ComboboxOption[];
  /** Selected option ID (controlled) */
  selectedOptionId?: string;
  /** Default selected option ID */
  defaultSelectedOptionId?: string;
  /** Input value (controlled) */
  inputValue?: string;
  /** Default input value */
  defaultInputValue?: string;
  /** Label text */
  label: string;
  /** Placeholder */
  placeholder?: string;
  /** Disabled state */
  disabled?: boolean;
  /** Autocomplete type */
  autocomplete?: 'none' | 'list' | 'both';
  /** Message shown when no results found */
  noResultsMessage?: string;

  /** Selection callback */
  onSelect?: (option: ComboboxOption) => void;

  /** Input change callback */
  onInputChange?: (value: string) => void;

  /** Popup open/close callback */
  onOpenChange?: (isOpen: boolean) => void;
}

export function Combobox({
  options,
  selectedOptionId: controlledSelectedId,
  defaultSelectedOptionId,
  inputValue: controlledInputValue,
  defaultInputValue = '',
  label,
  placeholder,
  disabled = false,
  autocomplete = 'list',
  noResultsMessage = 'No results found',
  onSelect,
  onInputChange,
  onOpenChange,
  className = '',
  ...restProps
}: ComboboxProps): ReactElement {
  const instanceId = useId();
  const inputId = `${instanceId}-input`;
  const labelId = `${instanceId}-label`;
  const listboxId = `${instanceId}-listbox`;

  // Internal state
  const [isOpen, setIsOpen] = useState(false);
  const [internalInputValue, setInternalInputValue] = useState(() => {
    if (!defaultSelectedOptionId) {
      return defaultInputValue;
    }

    const option = options.find(({ id }) => id === defaultSelectedOptionId);

    if (option === undefined) {
      return defaultInputValue;
    }

    return option.label;
  });
  const [internalSelectedId, setInternalSelectedId] = useState<string | undefined>(
    defaultSelectedOptionId
  );
  const [activeIndex, setActiveIndex] = useState(-1);
  const [isSearching, setIsSearching] = useState(false);

  // Track value before opening for Escape restoration
  const valueBeforeOpen = useRef<string>('');
  const isComposing = useRef(false);

  const containerRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  // Determine controlled vs uncontrolled
  const inputValue = controlledInputValue ?? internalInputValue;
  const selectedId = controlledSelectedId ?? internalSelectedId;

  // Get selected option's label
  const selectedLabel = useMemo(() => {
    if (!selectedId) {
      return '';
    }
    const option = options.find(({ id }) => id === selectedId);
    return option?.label ?? '';
  }, [options, selectedId]);

  // Filter options based on input value and search mode
  const filteredOptions = useMemo(() => {
    // Don't filter if autocomplete is none
    if (autocomplete === 'none') {
      return options;
    }

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

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

    const lowerInputValue = inputValue.toLowerCase();

    return options.filter(({ label }) => label.toLowerCase().includes(lowerInputValue));
  }, [options, inputValue, autocomplete, isSearching, selectedLabel]);

  // Get enabled options from filtered list
  const enabledOptions = useMemo(
    () => filteredOptions.filter(({ disabled }) => !disabled),
    [filteredOptions]
  );

  // Generate option IDs
  const getOptionId = useCallback(
    (optionId: string) => `${instanceId}-option-${optionId}`,
    [instanceId]
  );

  // Get active descendant ID
  const activeDescendantId = useMemo(() => {
    if (activeIndex < 0 || activeIndex >= filteredOptions.length) {
      return undefined;
    }

    const option = filteredOptions[activeIndex];

    if (option === undefined) {
      return undefined;
    }

    return getOptionId(option.id);
  }, [activeIndex, filteredOptions, getOptionId]);

  // Update input value
  const updateInputValue = useCallback(
    (value: string) => {
      if (controlledInputValue === undefined) {
        setInternalInputValue(value);
      }
      onInputChange?.(value);
    },
    [controlledInputValue, onInputChange]
  );

  // Open popup
  const openPopup = useCallback(
    (focusPosition?: 'first' | 'last') => {
      if (isOpen) {
        return;
      }

      valueBeforeOpen.current = inputValue;
      setIsOpen(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);

      setActiveIndex(targetIndex);
    },
    [isOpen, inputValue, enabledOptions, filteredOptions, onOpenChange]
  );

  // Close popup
  const closePopup = useCallback(
    (restore = false) => {
      setIsOpen(false);
      setActiveIndex(-1);
      setIsSearching(false);
      onOpenChange?.(false);

      if (restore) {
        updateInputValue(valueBeforeOpen.current);
      }
    },
    [onOpenChange, updateInputValue]
  );

  // Select option
  const selectOption = useCallback(
    ({ id, label, disabled }: ComboboxOption) => {
      if (disabled) {
        return;
      }

      if (controlledSelectedId === undefined) {
        setInternalSelectedId(id);
      }

      setIsSearching(false);
      updateInputValue(label);
      onSelect?.({ id, label, disabled });
      closePopup();
    },
    [controlledSelectedId, updateInputValue, onSelect, closePopup]
  );

  // Find next/previous enabled option index
  const findEnabledIndex = useCallback(
    (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);
    },
    [enabledOptions, filteredOptions]
  );

  // Handle input keydown
  const handleInputKeyDown = useCallback(
    (event: KeyboardEvent<HTMLInputElement>) => {
      if (isComposing.current) {
        return;
      }

      const { key, altKey } = event;

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

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

            valueBeforeOpen.current = inputValue;
            setIsOpen(true);
            onOpenChange?.(true);
            return;
          }

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

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

          if (nextIndex >= 0) {
            setActiveIndex(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) {
            setActiveIndex(prevIndex);
          }
          break;
        }
        case 'Home': {
          if (!isOpen) {
            return;
          }

          event.preventDefault();

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

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

          event.preventDefault();

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

          if (lastIndex >= 0) {
            setActiveIndex(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;
        }
      }
    },
    [
      isOpen,
      inputValue,
      activeIndex,
      filteredOptions,
      openPopup,
      closePopup,
      selectOption,
      findEnabledIndex,
      onOpenChange,
    ]
  );

  // Handle input change
  const handleInputChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const { value } = event.target;
      setIsSearching(true);
      updateInputValue(value);

      if (!isOpen && !isComposing.current) {
        valueBeforeOpen.current = inputValue;
        setIsOpen(true);
        onOpenChange?.(true);
      }

      setActiveIndex(-1);
    },
    [isOpen, inputValue, updateInputValue, onOpenChange]
  );

  // Handle option click
  const handleOptionClick = useCallback(
    (option: ComboboxOption) => {
      if (option.disabled) {
        return;
      }

      selectOption(option);
    },
    [selectOption]
  );

  // Handle option hover
  const handleOptionHover = useCallback(
    ({ id }: ComboboxOption) => {
      const index = filteredOptions.findIndex((option) => option.id === id);

      if (index < 0) {
        return;
      }

      setActiveIndex(index);
    },
    [filteredOptions]
  );

  // Handle IME composition
  const handleCompositionStart = useCallback(() => {
    isComposing.current = true;
  }, []);

  const handleCompositionEnd = useCallback(() => {
    isComposing.current = false;
  }, []);

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

    openPopup();
  }, [isOpen, disabled, openPopup]);

  // Click outside to close
  useEffect(() => {
    if (!isOpen) {
      return;
    }

    const handleClickOutside = (event: MouseEvent) => {
      const { current: container } = containerRef;

      if (container === null) {
        return;
      }

      if (event.target instanceof Node && !container.contains(event.target)) {
        closePopup();
      }
    };

    document.addEventListener('mousedown', handleClickOutside);

    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [isOpen, closePopup]);

  // Clear active index when filtered options change and no match exists
  useEffect(() => {
    if (activeIndex >= 0 && activeIndex >= filteredOptions.length) {
      setActiveIndex(-1);
    }
  }, [activeIndex, filteredOptions.length]);

  // Reset search mode when input value matches selected label or becomes empty
  useEffect(() => {
    if (inputValue === '' || inputValue === selectedLabel) {
      setIsSearching(false);
    }
  }, [inputValue, selectedLabel]);

  return (
    <div ref={containerRef} className={cn('apg-combobox', className)} {...restProps}>
      <label id={labelId} htmlFor={inputId} className="apg-combobox-label">
        {label}
      </label>
      <div className="apg-combobox-input-wrapper">
        <input
          ref={inputRef}
          id={inputId}
          type="text"
          role="combobox"
          className="apg-combobox-input"
          aria-autocomplete={autocomplete}
          aria-expanded={isOpen}
          aria-controls={listboxId}
          aria-labelledby={labelId}
          aria-activedescendant={activeDescendantId || undefined}
          value={inputValue}
          placeholder={placeholder}
          disabled={disabled}
          onChange={handleInputChange}
          onKeyDown={handleInputKeyDown}
          onFocus={handleFocus}
          onCompositionStart={handleCompositionStart}
          onCompositionEnd={handleCompositionEnd}
        />
        <span className="apg-combobox-caret" aria-hidden="true">
          <svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
            <path
              fillRule="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"
              clipRule="evenodd"
            />
          </svg>
        </span>
      </div>
      <ul
        id={listboxId}
        role="listbox"
        aria-labelledby={labelId}
        className="apg-combobox-listbox"
        hidden={!isOpen || undefined}
      >
        {filteredOptions.length === 0 && (
          <li className="apg-combobox-no-results" role="status">
            {noResultsMessage}
          </li>
        )}
        {filteredOptions.map(({ id, label: optionLabel, disabled: optionDisabled }, index) => {
          const isActive = index === activeIndex;
          const isSelected = id === selectedId;

          return (
            // eslint-disable-next-line jsx-a11y/click-events-have-key-events -- managed on option aria-activedescendant
            <li
              key={id}
              id={getOptionId(id)}
              role="option"
              className="apg-combobox-option"
              aria-selected={isActive}
              aria-disabled={optionDisabled || undefined}
              onClick={() =>
                handleOptionClick({ id, label: optionLabel, disabled: optionDisabled })
              }
              onMouseEnter={() =>
                handleOptionHover({ id, label: optionLabel, disabled: optionDisabled })
              }
              data-selected={isSelected || undefined}
            >
              <span className="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>
              {optionLabel}
            </li>
          );
        })}
      </ul>
    </div>
  );
}

export default Combobox;

Usage

Example
import { Combobox } from './Combobox';

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

function App() {
  return (
    <div>
      {/* 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"
      />

      {/* With callbacks */}
      <Combobox
        options={options}
        label="Fruit"
        onSelect={(option) => console.log('Selected:', option)}
        onInputChange={(value) => console.log('Input:', value)}
        onOpenChange={(isOpen) => console.log('Open:', isOpen)}
      />
    </div>
  );
}

API

PropTypeDefaultDescription
optionsComboboxOption[]RequiredArray of options with id, label, and optional disabled
labelstringRequiredVisible label text
placeholderstring-Placeholder text for input
defaultInputValuestring""Default input value
defaultSelectedOptionIdstring-ID of initially selected option
inputValuestring-Controlled input value
selectedOptionIdstring-Controlled selected option ID
autocomplete"none" | "list" | "both""list"Autocomplete behavior
disabledbooleanfalseWhether 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. 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)

TestDescription
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-controlsInput references the listbox ID (always present)
aria-expandedReflects popup open/closed state
aria-autocompleteSet to "list", "none", or "both"
aria-activedescendantReferences currently focused option
aria-selectedIndicates the currently highlighted option
aria-disabledIndicates disabled options

High Priority: Keyboard - Popup Closed (Unit + E2E)

TestDescription
Down ArrowOpens popup and focuses first option
Up ArrowOpens popup and focuses last option
Alt + Down ArrowOpens popup without changing focus
TypingOpens popup and filters options

High Priority: Keyboard - Popup Open (Unit + E2E)

TestDescription
Down ArrowMoves to next enabled option (no wrap)
Up ArrowMoves to previous enabled option (no wrap)
HomeMoves to first enabled option
EndMoves to last enabled option
EnterSelects focused option and closes popup
EscapeCloses popup and restores previous value
Alt + Up ArrowSelects focused option and closes popup
TabCloses popup and moves to next focusable element

High Priority: Focus Management (Unit + E2E)

TestDescription
DOM focus on inputDOM focus remains on input at all times
Virtual focus via aria-activedescendantVisual focus controlled by aria-activedescendant
Clear on closearia-activedescendant cleared when popup closes
Skip disabled optionsNavigation skips disabled options

Medium Priority: Filtering (Unit)

TestDescription
Filter on typingOptions filtered as user types
Case insensitiveFiltering is case insensitive
No filter (autocomplete="none")All options shown regardless of input
Empty resultsaria-activedescendant cleared when no matches

Medium Priority: Mouse Interaction (E2E)

TestDescription
Click optionSelects option and closes popup
Hover optionUpdates aria-activedescendant on hover
Click disabledDisabled options cannot be selected
Click outsideCloses popup without selection

Testing Tools

See the Testing Strategy guide for details.

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

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

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

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

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

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

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

    it('has accessible name via aria-labelledby', () => {
      render(<Combobox 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 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 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 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 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 options={defaultOptions} label="Fruit" />);
      expect(screen.getByRole('combobox')).toHaveAttribute('aria-autocomplete', 'list');
    });

    it('has aria-autocomplete="none" when autocomplete is none', () => {
      render(<Combobox 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 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 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('clears aria-activedescendant when list is empty after filtering', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

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

      // Type something that matches no options
      await user.clear(input);
      await user.type(input, 'xyz');
      expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
    });

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

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

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

    it('listbox is hidden when closed', () => {
      render(<Combobox options={defaultOptions} label="Fruit" />);
      const input = screen.getByRole('combobox');
      const listboxId = input.getAttribute('aria-controls');
      const listbox = document.getElementById(listboxId!);

      expect(listbox).toHaveAttribute('hidden');
    });

    it('options have role="option"', async () => {
      const user = userEvent.setup();
      render(<Combobox 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 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('non-focused options have aria-selected="false"', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

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

      const secondOption = screen.getByRole('option', { name: 'Banana' });
      const thirdOption = screen.getByRole('option', { name: 'Cherry' });
      expect(secondOption).toHaveAttribute('aria-selected', 'false');
      expect(thirdOption).toHaveAttribute('aria-selected', 'false');
    });

    it('disabled option has aria-disabled="true"', async () => {
      const user = userEvent.setup();
      render(<Combobox 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 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 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 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 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 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('restores input value on Escape', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" defaultInputValue="App" />);

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

      await user.click(input);
      await user.keyboard('{ArrowDown}');
      await user.keyboard('{ArrowDown}');
      // After navigation, input might show preview of Banana
      await user.keyboard('{Escape}');

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

    it('selects option and closes popup on Enter', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(<Combobox options={defaultOptions} label="Fruit" onSelect={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(
        <div>
          <Combobox options={defaultOptions} label="Fruit" />
          <button>Next</button>
        </div>
      );

      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 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 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');
      // aria-activedescendant should not be set
      expect(input.getAttribute('aria-activedescendant')).toBeFalsy();
    });

    it('Alt+ArrowUp commits selection and closes', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(<Combobox options={defaultOptions} label="Fruit" onSelect={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 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 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 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}');
      // Should skip Banana (disabled) and go to Cherry
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Cherry');
    });

    it('skips disabled option on ArrowUp', async () => {
      const user = userEvent.setup();
      render(<Combobox 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}');
      // Should skip Banana (disabled) and go to Apple
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Apple');
    });

    it('moves to first enabled option on Home', async () => {
      const user = userEvent.setup();
      render(<Combobox 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}');
      // Should skip Apple (disabled) and go to Banana
      expect(
        document.getElementById(input.getAttribute('aria-activedescendant')!)
      ).toHaveTextContent('Banana');
    });

    it('moves to last enabled option on End', async () => {
      const user = userEvent.setup();
      render(<Combobox 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}');
      // Should skip Cherry (disabled) and go to Banana
      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 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}');
      // Should stay at Cherry, no wrap
      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 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}');
      // Should stay at Apple, no wrap
      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 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 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('aria-activedescendant references existing element', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

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

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

    it('maintains focus on input after selection', async () => {
      const user = userEvent.setup();
      render(<Combobox 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 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 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 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 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);
    });

    it('case-insensitive filtering', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

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

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

    it('shows no options message when filter results are empty', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

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

      expect(screen.queryAllByRole('option')).toHaveLength(0);
    });
  });

  // 🔴 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 options={optionsWithFirstDisabled} label="Fruit" onSelect={onSelect} />);

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.keyboard('{ArrowDown}');
      // First enabled option is Banana
      await user.keyboard('{ArrowUp}');
      // Try to go to Apple (disabled) - should stay at Banana
      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 options={optionsWithDisabled} label="Fruit" onSelect={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');
    });

    it('shows disabled options in filtered results', async () => {
      const user = userEvent.setup();
      render(<Combobox options={optionsWithDisabled} label="Fruit" />);

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

      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(1);
      expect(options[0]).toHaveTextContent('Banana');
      expect(options[0]).toHaveAttribute('aria-disabled', 'true');
    });
  });

  // 🔴 High Priority: Mouse Interaction
  describe('Mouse Interaction', () => {
    it('selects option on click', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(<Combobox options={defaultOptions} label="Fruit" onSelect={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 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(
        <div>
          <Combobox options={defaultOptions} label="Fruit" />
          <button>Outside</button>
        </div>
      );

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

      await user.click(screen.getByRole('button', { name: 'Outside' }));
      expect(input).toHaveAttribute('aria-expanded', 'false');
    });

    it('does not select on outside click', async () => {
      const user = userEvent.setup();
      const onSelect = vi.fn();
      render(
        <div>
          <Combobox options={defaultOptions} label="Fruit" onSelect={onSelect} />
          <button>Outside</button>
        </div>
      );

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

      await user.click(screen.getByRole('button', { name: 'Outside' }));
      expect(onSelect).not.toHaveBeenCalled();
    });

    it('updates aria-selected on hover', async () => {
      const user = userEvent.setup();
      render(<Combobox 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 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 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 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();
    });

    it('has no axe violations with disabled options', async () => {
      const user = userEvent.setup();
      const { container } = render(<Combobox options={optionsWithDisabled} label="Fruit" />);

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

      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 options={defaultOptions} label="Fruit" onSelect={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 options={defaultOptions} label="Fruit" onInputChange={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 options={defaultOptions} label="Fruit" onOpenChange={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 options={defaultOptions} label="Fruit" className="custom-class" />
      );

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

    it('supports disabled state on combobox', () => {
      render(<Combobox options={defaultOptions} label="Fruit" disabled />);

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

    it('supports placeholder', () => {
      render(<Combobox 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 options={defaultOptions} label="Fruit" defaultInputValue="Ban" />);

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

    it('supports defaultSelectedOptionId', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" defaultSelectedOptionId="banana" />);

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

      // Open popup - should show all options (not filtered) since input matches selected label
      await user.click(input);

      // All options should be visible (defaultOptions has 3 items)
      const options = screen.getAllByRole('option');
      expect(options).toHaveLength(3);

      // Banana should have data-selected (visually selected state)
      const bananaOption = screen.getByRole('option', { name: 'Banana' });
      expect(bananaOption).toHaveAttribute('data-selected', 'true');

      // Navigate with ArrowDown - focuses first option (Apple)
      await user.keyboard('{ArrowDown}');
      const appleOption = screen.getByRole('option', { name: 'Apple' });
      expect(appleOption).toHaveAttribute('aria-selected', 'true');
    });

    it('IDs do not conflict with multiple instances', () => {
      render(
        <>
          <Combobox options={defaultOptions} label="Fruit 1" />
          <Combobox options={defaultOptions} label="Fruit 2" />
        </>
      );

      const inputs = screen.getAllByRole('combobox');
      const listboxId1 = inputs[0].getAttribute('aria-controls');
      const listboxId2 = inputs[1].getAttribute('aria-controls');

      expect(listboxId1).not.toBe(listboxId2);
    });
  });

  // Edge Cases
  describe('Edge Cases', () => {
    it('handles empty options array', () => {
      expect(() => {
        render(<Combobox 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 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();
    });

    it('handles rapid typing without errors', async () => {
      const user = userEvent.setup();
      render(<Combobox options={defaultOptions} label="Fruit" />);

      const input = screen.getByRole('combobox');
      await user.click(input);
      await user.type(input, 'applebananacherry', { delay: 10 });

      // Should not throw and should handle gracefully
      expect(input).toHaveValue('applebananacherry');
    });
  });
});

Resources