Desktop D2 UI: design system, portfolio, release timeline, catalog parity, listings, health, reviews, TestFlight, checks, command palette, toasts, first-run guide, en + tr with i18n check, 7:1 theme check, dev fixture mode and screenshot harness
This commit is contained in:
parent
95b7cf7ced
commit
2ef0769730
44 changed files with 5475 additions and 742 deletions
73
apps/desktop/src/ui/Palette.tsx
Normal file
73
apps/desktop/src/ui/Palette.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue