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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { type ThemePref, readStoredTheme, setThemePref } from '../../lib/theme';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { type ThemePref, isThemePref, readStoredTheme, setThemePref, THEME_CHANGE_EVENT } from '../../lib/theme';
|
||||
|
||||
const ICON_PROPS = {
|
||||
width: 14,
|
||||
@@ -56,6 +56,17 @@ const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [
|
||||
export function ThemeToggle() {
|
||||
const [pref, setPref] = useState<ThemePref>(() => readStoredTheme());
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (e: Event) => {
|
||||
// Prefer the pref carried in the event (works even if localStorage write
|
||||
// failed); fall back to stored value for plain Event dispatches.
|
||||
const detail = (e as CustomEvent<unknown>).detail;
|
||||
setPref(isThemePref(detail) ? detail : readStoredTheme());
|
||||
};
|
||||
window.addEventListener(THEME_CHANGE_EVENT, sync);
|
||||
return () => window.removeEventListener(THEME_CHANGE_EVENT, sync);
|
||||
}, []);
|
||||
|
||||
const choose = (value: ThemePref) => {
|
||||
setPref(value);
|
||||
setThemePref(value);
|
||||
|
||||
@@ -14,6 +14,7 @@ interface TopBarProps {
|
||||
onOpenDrawer: () => void;
|
||||
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
|
||||
navDrawerOpen?: boolean;
|
||||
onOpenCommandK?: () => void;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
|
||||
@@ -69,6 +70,7 @@ export function TopBar({
|
||||
onOpenDrawer,
|
||||
hamburgerButtonRef,
|
||||
navDrawerOpen = false,
|
||||
onOpenCommandK,
|
||||
}: TopBarProps) {
|
||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
|
||||
@@ -138,6 +140,17 @@ export function TopBar({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenCommandK && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCommandK}
|
||||
aria-label="コマンドパレットを開く"
|
||||
title="コマンドパレット (⌘K)"
|
||||
className="hidden sm:inline-flex items-center gap-1 px-2 h-7 rounded-md border border-hairline text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
|
||||
>
|
||||
<span aria-hidden>⌘K</span>
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
{user && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user