Files
platform/src/apps/officer-web/Screens/Dashboard/Layout/Spotlight/Spotlight.tsx
T
pastilhasandClaude Opus 5 7671368faf alt-space opens spotlight from a text field too
The focus guard was still there. I moved the trigger to alt-space, wrote that the guard was no longer
load-bearing, and then kept it anyway "as belt-and-braces" — which left the exact bug the move was meant to
fix. Focus is usually in the chat composer, so the launcher did not open from the place it is most often
wanted. Reported one commit later, by the owner, from a text field.

The guard existed because shift-space types a space and eating that keystroke was unacceptable. Alt-space
types nothing, so there is nothing to protect. preventDefault on the handler covers the residual case a
layout might deliver, which is what the guard was really insuring against.

isTyping is deleted rather than left unused: a dead helper carrying a long comment about a decision that has
been reversed is worse than no helper, because the comment still reads as current.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:37:20 +00:00

254 lines
10 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { Search } from 'lucide-react';
import { rank } from './match';
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
// Spotlight: type a few letters, go somewhere.
//
// The dock is full and cannot grow, which is what this is for — once navigation lives here, the dock is
// free to be a short list of what you actually use rather than a complete one.
//
// ── Keyboard first, and the trigger ──
//
// `alt-space`, and ONLY that. It went ctrl-space → shift-space → alt-space, and each move was the owner
// telling us what the chord actually collides with on the machine he uses:
//
// ctrl-space focuses the address bar in Brave
// shift-space types a space — see below, this was the real problem
// alt-space taken by the window menu, and the owner rebound it rather than give up the chord
//
// **Shift-space was never a good choice**, and that is why it did not last a day. It is a real key
// combination inside a text field: it types a space, and people hold shift through one more often than
// they think while typing capitals. Guarding it meant IGNORING the trigger whenever focus was in an input
// — which is where focus usually is, the chat composer — so opening Spotlight meant clicking out first.
// A launcher you have to prepare for is not a launcher.
//
// Alt-space has no meaning inside a text field, so there is NO focus check at all. The guard shift-space
// needed was kept for one commit as "belt-and-braces" and was simply the old bug wearing a new hat: focus
// is usually in the chat composer, so a launcher that stands down there is a launcher that does not open
// when you want it. `preventDefault` on the handler covers the only real risk the guard addressed.
//
// Nothing else opens it. There is deliberately no second chord — two triggers for one thing means two
// things to collide with, and this one moved twice already.
//
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
/*
No cap, deliberately, and it used to be 8.
Eight was fine on the machine it was written on, which has few plugins. On a populated server the dock
carries fourteen plugin tiles and the core items alone fill all eight slots — so with an empty query the
list stopped at Browser and every plugin was unreachable unless you already knew its name well enough to
type it. A launcher you can only use for things you can already name is not doing the job.
The container has scrolled since it was written (`max-h-[52vh] overflow-y-auto`), so there was never a
layout reason for the limit, and the truncation was silent — nothing said "and 20 more", which is how a
cap becomes indistinguishable from a missing feature.
*/
/** Label with the matched characters picked out, so it is obvious WHY a result is here. */
const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => {
if (!hits.length) return <>{label}</>;
const set = new Set(hits);
return (
<>
{label.split('').map((char, i) =>
set.has(i) ? (
<span key={i} className="text-duck-teal">
{char}
</span>
) : (
<span key={i}>{char}</span>
),
)}
</>
);
};
const Row = ({
item,
hits,
active,
onPick,
}: {
item: SpotlightItem;
hits: number[];
active: boolean;
onPick: () => void;
}) => {
const Icon = item.icon;
// Keyboard navigation has to survive a list taller than the box. `block: 'nearest'` scrolls only when the
// row is actually out of sight, so arrowing through the visible middle of the list does not jump it.
const ref = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (active) ref.current?.scrollIntoView({ block: 'nearest' });
}, [active]);
return (
<button
ref={ref}
type="button"
// `onMouseDown`, not `onClick`: the input holds focus and a click would blur it first, closing the
// overlay on the blur handler before the navigation ever ran.
onMouseDown={(ev) => {
ev.preventDefault();
onPick();
}}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors ${
active ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
{/*
The dock's tile, at row size. Same two cases and the same rule for choosing between them, because
these are the SAME things and a second visual language for them is what made this list read as
somebody else's menu:
badge a filled square of the item's colour with a white glyph — a white glyph on nothing is
invisible, which is why the fill is not decoration
bare no background, artwork fills the box — a logo framed in an arbitrary swatch reads as a
mistake, and every plugin shipping its own art is this case
The tinted `${color}22` square this used for everything was a third style belonging to neither, and
it put a coloured pane behind Calendar, Contacts and every plugin logo.
*/}
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
style={{
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
color: item.color ? '#fff' : undefined,
}}
>
{item.image ? (
<img
src={item.image}
alt=""
className={item.tile === 'bare' ? 'h-full w-full object-contain' : 'h-5 w-5 object-contain'}
/>
) : Icon ? (
<Icon className={`h-4 w-4 ${item.color ? '' : 'text-muted-foreground'}`} />
) : (
<Search className="h-4 w-4 text-muted-foreground" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">
<Highlighted label={item.label} hits={hits} />
</span>
{item.hint ? <span className="block truncate text-xs text-muted-foreground">{item.hint}</span> : null}
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/60">{item.to}</span>
</button>
);
};
export const Spotlight = () => {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const items = useSpotlightItems();
const results = useMemo(() => rank(items, query), [items, query]);
const close = useCallback(() => {
setOpen(false);
setQuery('');
setActive(0);
}, []);
const pick = useCallback(
(item: SpotlightItem | undefined) => {
if (!item) return;
close();
navigate(item.to);
},
[close, navigate],
);
// The trigger. Bound to the window rather than to a container, because it has to work while focus is
// anywhere — including inside a panel that has its own key handling.
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
// `altKey` and `code`, not `key`: alt-space produces a different `key` on several layouts (and
// nothing at all on some), while the physical key is `Space` everywhere.
//
// No focus check. There was one, inherited from shift-space where it was mandatory, and keeping it
// "as belt-and-braces" was wrong in the plainest way: focus is usually IN the chat composer, so the
// guard meant the launcher did not open from the place it is most often wanted. `preventDefault`
// below covers the only thing the guard was protecting against — a layout that would otherwise type
// a character.
if (ev.altKey && ev.code === 'Space') {
ev.preventDefault();
setOpen((wasOpen) => !wasOpen);
return;
}
if (ev.key === 'Escape' && open) close();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, close]);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
// Reset the cursor whenever the result set changes, so it never points past the end.
useEffect(() => setActive(0), [query]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[12vh] backdrop-blur-sm"
onMouseDown={close}
>
<div
className="w-[min(92vw,560px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
// The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one.
onMouseDown={(ev) => ev.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
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-3.5 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-[52vh] overflow-y-auto p-2">
{results.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
) : (
results.map((result, i) => (
<Row
key={result.item.id}
item={result.item}
hits={result.hits}
active={i === active}
onPick={() => pick(result.item)}
/>
))
)}
</div>
</div>
</div>
);
};