From a5f387929db085ad5e3ecc8fe5b4ea3363179776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 22 Aug 2026 04:00:45 +0000 Subject: [PATCH] spotlight takes focus, and its arrow keys no longer depend on getting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arrow keys sometimes did nothing when the launcher was opened from a text field, and the two halves of that are one bug: navigation lived on the panel input's onKeyDown, so every keystroke was conditional on focus having actually landed there. When it had not — the composer kept it — ArrowDown went to the textarea and the launcher never saw the key. Two fixes, because either alone leaves the other half. Opening now blurs whatever had focus, explicitly, before the overlay mounts. The effect that focuses the panel's own input was already there and is enough on paper; "on paper" is what produced an intermittent bug, so the outgoing element is blurred rather than assumed away. And navigation moved to the window listener that already handled Escape, so it works regardless of what has focus — a panel that grabbed it back, a blur that lost a race, an element with its own key handling. The input's copy is deleted; leaving both would move the cursor twice per press whenever focus did land. The handler reads `results` and `active` through refs, deliberately. The listener registers once per open, so closing over them would pin it to their values at registration — arrowing down would read a stale list and Enter would open whatever was first when the panel appeared. Putting them in the effect's deps instead re-registers the listener on every keystroke, which is the same bug in a costume. Co-Authored-By: Claude Opus 5 --- .../Dashboard/Layout/Spotlight/Spotlight.tsx | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) 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" />