diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx index 16a3c3bf..db417c74 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx @@ -168,6 +168,19 @@ export const Spotlight = () => { const results = useMemo(() => rank(items, query), [items, query]); + /* + The window handler reads these through refs. + + It has to: the listener is registered once per open, and closing over `results` and `active` would pin + it to their values at registration β€” so arrowing down would read a stale list and Enter would open + whatever was first when the panel appeared. Adding them to the effect's deps instead would tear down and + re-register the listener on every keystroke, which is the same bug wearing a costume. + */ + const resultsRef = useRef(results); + resultsRef.current = results; + const activeRef = useRef(active); + activeRef.current = active; + const close = useCallback(() => { setOpen(false); setQuery(''); @@ -197,14 +210,38 @@ export const Spotlight = () => { // a character. if (ev.altKey && ev.code === 'Space') { ev.preventDefault(); + // Take focus off whatever had it, explicitly, before the overlay mounts. The effect below focuses + // this panel's own input, and on paper that is enough β€” but "on paper" is what produced arrow keys + // that sometimes did nothing: focus stayed in the composer, so ArrowDown went to the textarea and + // the launcher never saw it. Blurring the outgoing element is one line and removes the race rather + // than narrowing it. + (document.activeElement as HTMLElement | null)?.blur?.(); setOpen((wasOpen) => !wasOpen); return; } - if (ev.key === 'Escape' && open) close(); + if (!open) return; + if (ev.key === 'Escape') { + close(); + return; + } + // Navigation is handled HERE rather than on the input, so it works no matter what has focus β€” a + // panel that grabbed it back, a blur that lost the race, an element with its own key handling. The + // input's own onKeyDown used to own this, which made every keystroke conditional on focus having + // landed where we expected. + if (ev.key === 'ArrowDown') { + ev.preventDefault(); + setActive((i) => Math.min(i + 1, resultsRef.current.length - 1)); + } else if (ev.key === 'ArrowUp') { + ev.preventDefault(); + setActive((i) => Math.max(i - 1, 0)); + } else if (ev.key === 'Enter') { + ev.preventDefault(); + pick(resultsRef.current[activeRef.current]?.item); + } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [open, close]); + }, [open, close, pick]); useEffect(() => { if (open) inputRef.current?.focus(); @@ -231,18 +268,6 @@ export const Spotlight = () => { ref={inputRef} value={query} onChange={(ev) => setQuery(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'ArrowDown') { - ev.preventDefault(); - setActive((i) => Math.min(i + 1, results.length - 1)); - } else if (ev.key === 'ArrowUp') { - ev.preventDefault(); - setActive((i) => Math.max(i - 1, 0)); - } else if (ev.key === 'Enter') { - ev.preventDefault(); - pick(results[active]?.item); - } - }} placeholder="Go to…" className="w-full bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground" />