storebound/apps/desktop/src/ui/Palette.tsx

73 lines
3.7 KiB
TypeScript

// Command palette (Cmd/Ctrl+K): jump to an app, a tab, a screen, or run an action. Arrow keys, Enter, Esc.
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { t } from '../i18n';
import { fuzzy } from '../lib/model';
import { Icon, type IconName } from './icons';
import { AppIcon, Kbd, MOD } from './kit';
export interface Command { id: string; group: string; title: string; hint?: string; icon?: IconName; app?: { name: string; icon?: string | null }; keywords?: string; run: () => void }
export function Palette({ commands, onClose }: { commands: Command[]; onClose: () => void }) {
const [q, setQ] = useState('');
const [sel, setSel] = useState(0);
const input = useRef<HTMLInputElement>(null);
const list = useRef<HTMLDivElement>(null);
const before = useRef<Element | null>(null);
useEffect(() => {
before.current = document.activeElement;
input.current?.focus();
return () => { (before.current as HTMLElement | null)?.focus?.(); };
}, []);
const items = useMemo(() => {
if (!q.trim()) return commands;
return commands.map(c => ({ c, s: Math.max(fuzzy(q, c.title), fuzzy(q, `${c.group} ${c.title} ${c.keywords ?? ''}`) - 1) }))
.filter(x => x.s >= 0).sort((a, b) => b.s - a.s).map(x => x.c);
}, [q, commands]);
useEffect(() => { setSel(0); }, [q]);
useEffect(() => { list.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); }, [sel]);
const run = (c: Command | undefined) => { if (!c) return; onClose(); c.run(); };
const onKey = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(items.length - 1, s + 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); }
else if (e.key === 'Enter') { e.preventDefault(); run(items[sel]); }
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
else if (e.key === 'Tab') e.preventDefault();
};
const rows: ReactNode[] = [];
let group = '';
items.forEach((c, i) => {
if (!q.trim() && c.group !== group) { group = c.group; rows.push(<div key={`g-${group}`} className="palette-group" role="presentation">{group}</div>); }
rows.push(
<div key={c.id} id={`pi-${i}`} role="option" aria-selected={i === sel} className="palette-item" onMouseMove={() => setSel(i)} onClick={() => run(c)}>
{c.app ? <AppIcon name={c.app.name} src={c.app.icon} /> : <Icon name={c.icon ?? 'arrowRight'} />}
<span className="grow">{c.title}</span>
{c.hint && <span className="hint">{c.hint}</span>}
</div>,
);
});
return (
<div className="scrim" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div className="palette" role="dialog" aria-modal="true" aria-label={t('palette.title')} onKeyDown={onKey}>
<div className="palette-in">
<Icon name="search" />
<input ref={input} value={q} onChange={e => setQ(e.target.value)} placeholder={t('palette.placeholder')} role="combobox" aria-expanded="true"
aria-controls="palette-list" aria-activedescendant={items.length ? `pi-${sel}` : undefined} aria-autocomplete="list" spellCheck={false} />
<Kbd keys={['Esc']} />
</div>
<div className="palette-list" id="palette-list" role="listbox" ref={list} aria-label={t('palette.results')}>
{items.length ? rows : <div className="palette-empty">{t('palette.none', { q })}</div>}
</div>
<div className="palette-foot">
<span><Kbd keys={['↑', '↓']} />{t('palette.move')}</span>
<span><Kbd keys={['↵']} />{t('palette.open')}</span>
<span><Kbd keys={[MOD, 'K']} />{t('palette.toggle')}</span>
</div>
</div>
</div>
);
}