Follow-up from PR #155 (ARIA correctness).
SearchBox and TagPicker now both implement the ARIA APG combobox-with-listbox pattern by hand: open / activeIndex state, clamped activeIdx, useId()-derived listbox/option ids, aria-activedescendant, wrapping ArrowUp/ArrowDown, Enter/Escape, guarded onMouseMove highlight, and close-on-focus-out. The two copies have already drifted in small ways (SearchBox swallows mousedown on the popup and closes on input blur; TagPicker uses a container onBlur + relatedTarget check with tabIndex={-1} options).
Extract a useCombobox hook (or adopt shadcn's Command) so the keyboard/ARIA contract lives in one place and the two widgets only differ in how they render options and what activation does. Keep the existing SearchBox.test.tsx / TagPicker.test.tsx as the behavioural contract.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr
Follow-up from PR #155 (ARIA correctness).
SearchBoxandTagPickernow both implement the ARIA APG combobox-with-listbox pattern by hand:open/activeIndexstate, clampedactiveIdx,useId()-derived listbox/option ids,aria-activedescendant, wrapping ArrowUp/ArrowDown, Enter/Escape, guardedonMouseMovehighlight, and close-on-focus-out. The two copies have already drifted in small ways (SearchBox swallows mousedown on the popup and closes on input blur; TagPicker uses a containeronBlur+relatedTargetcheck withtabIndex={-1}options).Extract a
useComboboxhook (or adopt shadcn'sCommand) so the keyboard/ARIA contract lives in one place and the two widgets only differ in how they render options and what activation does. Keep the existingSearchBox.test.tsx/TagPicker.test.tsxas the behavioural contract.🤖 Generated with Claude Code
https://claude.ai/code/session_01RdRwHvDupRLV8GuJpYKzEr