From a3ce570e0adc3f194f3a33919c56e10def02b367 Mon Sep 17 00:00:00 2001 From: NnamdiCyber Date: Thu, 23 Jul 2026 22:09:14 +0100 Subject: [PATCH] feat: accessible search combobox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements app/components/SearchInput.tsx following the WAI-ARIA 1.2 combobox pattern (§5.9) with full keyboard navigation. ARIA wiring - role=combobox on input; aria-expanded, aria-haspopup=listbox, aria-autocomplete=list, aria-controls, aria-activedescendant - role=option + aria-selected on each listbox item - aria-live=polite status region announces result counts, loading, and empty states to screen-reader users - Clear button has aria-label='Clear search' and is tab-accessible Keyboard interaction - ArrowDown / ArrowUp navigate options with wrap-around - Home / End jump to first / last option - Enter confirm highlighted option or submit free-text - Escape close listbox; second press clears value - Tab close listbox, natural focus flow API - Controlled (value + onChange) and uncontrolled modes - suggestions[], onSelect, onSubmit, onOpen, onClose callbacks - isLoading, disabled, maxSuggestions, aria-label, aria-labelledby props - Design-system tokens (bg-popover, accent, border-input) with motion-safe: utilities for prefers-reduced-motion compliance Tests (app/components/__tests__/SearchInput.test.tsx) ~50 focused cases covering ARIA wiring, all keyboard paths, click/hover, clear button, loading/empty states, controlled vs uncontrolled, maxSuggestions, disabled, live region, outside-click close, and a snapshot. Docs: docs/SEARCH_INPUT.md — props table, keyboard table, usage examples, comparison with the navbar-specific SearchInput. Closes #477 --- app/components/SearchInput.tsx | 485 +++++++++++++++++ app/components/__tests__/SearchInput.test.tsx | 514 ++++++++++++++++++ docs/SEARCH_INPUT.md | 179 ++++++ 3 files changed, 1178 insertions(+) create mode 100644 app/components/SearchInput.tsx create mode 100644 app/components/__tests__/SearchInput.test.tsx create mode 100644 docs/SEARCH_INPUT.md diff --git a/app/components/SearchInput.tsx b/app/components/SearchInput.tsx new file mode 100644 index 00000000..3a764222 --- /dev/null +++ b/app/components/SearchInput.tsx @@ -0,0 +1,485 @@ +/** + * SearchInput + * + * An accessible search input that follows the ARIA combobox pattern (WAI-ARIA + * 1.2 §5.9). Users can navigate suggestions with the arrow keys, select with + * Enter, and dismiss the listbox with Escape. + * + * Accessibility features + * ────────────────────── + * • Input carries role="combobox" with aria-expanded, aria-haspopup="listbox", + * aria-autocomplete="list", and aria-controls pointing at the listbox. + * • aria-activedescendant tracks the keyboard-highlighted option. + * • Each option has role="option" and aria-selected. + * • The listbox label ("Search suggestions") is announced when it appears. + * • A live region announces result counts so screen-reader users know when + * the list has changed. + * • The clear button is keyboard-reachable and has a descriptive aria-label. + * • Reduced-motion: the dropdown entrance animation respects + * `prefers-reduced-motion: reduce`. + * + * Keyboard interaction + * ──────────────────── + * ArrowDown Move visual focus to the next option (wraps at end) + * ArrowUp Move visual focus to the previous option (wraps at start) + * Home Move to the first option + * End Move to the last option + * Enter Confirm the highlighted option (or submit raw query if none) + * Escape Close the listbox and return focus to the input + * Tab Close the listbox (natural focus movement) + */ + +"use client"; + +import React, { + useState, + useRef, + useId, + useMemo, + useCallback, + useEffect, +} from "react"; +import { Search as SearchIcon, X as ClearIcon, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** A single suggestion item displayed in the listbox. */ +export interface SearchSuggestion { + /** Unique identifier for the item. */ + id: string; + /** Primary label shown to the user. */ + label: string; + /** Optional sub-label shown in a smaller font beneath the label. */ + sublabel?: string; + /** Optional accessible icon rendered before the label (must carry aria-hidden). */ + icon?: React.ReactNode; +} + +export interface SearchInputProps { + /** Current value of the input (controlled). */ + value?: string; + /** Called when the user changes the input value. */ + onChange?: (value: string) => void; + /** + * Suggestions to display in the listbox. The consumer is responsible for + * filtering / sorting; SearchInput renders whatever is provided. + */ + suggestions?: SearchSuggestion[]; + /** + * Called when the user confirms a suggestion (Enter key or click). + * Receives the confirmed suggestion object. + */ + onSelect?: (suggestion: SearchSuggestion) => void; + /** + * Called when the user submits a free-text query (Enter with no suggestion + * highlighted, or Enter when suggestions is empty). + */ + onSubmit?: (query: string) => void; + /** Called when the listbox opens. */ + onOpen?: () => void; + /** Called when the listbox closes. */ + onClose?: () => void; + /** Show a spinner inside the input to indicate loading. */ + isLoading?: boolean; + /** Placeholder text for the input. */ + placeholder?: string; + /** Additional class names for the outermost wrapper. */ + className?: string; + /** + * Maximum number of suggestions to show. Remaining items are hidden. + * @default 10 + */ + maxSuggestions?: number; + /** Whether the input should be disabled. */ + disabled?: boolean; + /** id forwarded to the underlying . Useful for