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