This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
buildCommands, filterCommands, groupCommands,
|
||||
type CommandContext, type CommandItem,
|
||||
} from '../../lib/command-palette';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
ctx: CommandContext;
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onClose, ctx }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const openerRef = useRef<Element | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
|
||||
const allCommands = useMemo(() => buildCommands(ctx), [ctx]);
|
||||
const results = useMemo(() => filterCommands(allCommands, query), [allCommands, query]);
|
||||
const groups = useMemo(() => groupCommands(results), [results]);
|
||||
|
||||
useEffect(() => {
|
||||
const dlg = dialogRef.current;
|
||||
if (!dlg) return;
|
||||
if (open && !dlg.open) {
|
||||
openerRef.current = document.activeElement;
|
||||
setQuery('');
|
||||
setHighlight(0);
|
||||
dlg.showModal();
|
||||
inputRef.current?.focus();
|
||||
} else if (!open && dlg.open) {
|
||||
dlg.close();
|
||||
const o = openerRef.current;
|
||||
if (o instanceof HTMLElement && o.isConnected) o.focus();
|
||||
else document.body.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dlg = dialogRef.current;
|
||||
if (!dlg) return;
|
||||
const onCancel = (e: Event) => { e.preventDefault(); onClose(); };
|
||||
const onClick = (e: MouseEvent) => { if (e.target === dlg) onClose(); };
|
||||
dlg.addEventListener('cancel', onCancel);
|
||||
dlg.addEventListener('click', onClick);
|
||||
return () => { dlg.removeEventListener('cancel', onCancel); dlg.removeEventListener('click', onClick); };
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => { setHighlight(0); }, [query]);
|
||||
|
||||
const flat = results;
|
||||
const highlightedId = flat[highlight]?.id;
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight((h) => Math.min(h + 1, flat.length - 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)); }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const item = flat[highlight];
|
||||
if (item) { item.run(); onClose(); }
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedId) return;
|
||||
document.getElementById(`cmdk-opt-${highlightedId}`)?.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlightedId]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="コマンドパレット"
|
||||
className="m-0 mt-[12vh] mx-auto w-[min(560px,92vw)] rounded-xl border border-hairline bg-surface text-ink shadow-2xl p-0 backdrop:bg-black/40"
|
||||
>
|
||||
<div className="p-2 border-b border-hairline">
|
||||
<input
|
||||
ref={inputRef}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="cmdk-listbox"
|
||||
aria-label="コマンド・タスクを検索"
|
||||
aria-activedescendant={highlightedId ? `cmdk-opt-${highlightedId}` : undefined}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="コマンド・タスクを検索…"
|
||||
className="w-full h-9 px-2 bg-transparent outline-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div id="cmdk-listbox" role="listbox" className="max-h-[52vh] overflow-y-auto p-1">
|
||||
{flat.length === 0 && <div role="status" className="px-3 py-6 text-center text-sm text-muted">該当なし</div>}
|
||||
{groups.map((g) => (
|
||||
<div key={g.group} role="group" aria-label={g.label}>
|
||||
<div className="section-label px-2 pt-2 pb-1">{g.label}</div>
|
||||
{g.items.map((item: CommandItem) => {
|
||||
const active = item.id === highlightedId;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
id={`cmdk-opt-${item.id}`}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseMove={() => setHighlight(flat.indexOf(item))}
|
||||
onClick={() => { item.run(); onClose(); }}
|
||||
className={`flex items-center justify-between gap-2 px-2.5 h-9 rounded-md cursor-pointer text-sm ${
|
||||
active ? 'bg-surface-2 text-ink' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.hint && <span className="text-2xs text-muted flex-shrink-0">{item.hint}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user