sync: update from private repo (6c4d482)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-08 01:01:47 +00:00
parent caa0d03900
commit 03be80f036
21 changed files with 1140 additions and 15 deletions
@@ -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>
);
}
+13 -2
View File
@@ -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);
+13
View File
@@ -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">