Files
writer-work-flow/apps/web/components/command/CommandPalette.tsx
Yaojia Wang 45025c36bf feat(ui): P4 无障碍与打磨收尾
- P4-1 对比度:muted-soft 提到 WCAG AA 小字(≥4.5:1)于两主题各面
- P4-3 ConflictCard 原文/建议加 Minus/Plus 图标 + sr-only 标签(不靠颜色)
- P4-4 动效:3 处 chevron transition-transform 加 motion-safe 门控
- P4-5 命令面板 ⌘K 键帽提示 + 键盘操作读数 + 空态引导 + mono 快捷键
- P2-8 SegmentedControl 升级 radiogroup(role=radio/aria-checked + 方向键 roving)
2026-07-11 08:12:36 +02:00

299 lines
9.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useRouter } from "next/navigation";
import {
filterCommands,
globalCommands,
moveHighlight,
projectCommands,
type Command,
type ToolCommandInfo,
} from "@/lib/command/palette";
import { handleTabTrap } from "@/lib/a11y/focusTrap";
import { api } from "@/lib/api/client";
import { useBodyScrollLock } from "@/lib/ui/useBodyScrollLock";
import { focusRing, overlayScrim } from "@/lib/ui/variants";
// 命令面板开关的极简外部 store⌘K 与可见入口(顶栏按钮 / 移动抽屉)共享同一开态。
// 用 useSyncExternalStore 订阅,避免把 open 提升到 RootLayout context 造成全树重渲染。
let paletteOpen = false;
const paletteListeners = new Set<() => void>();
function emitPalette(): void {
for (const listener of paletteListeners) listener();
}
export function setCommandPaletteOpen(next: boolean): void {
if (paletteOpen === next) return;
paletteOpen = next;
emitPalette();
}
export function openCommandPalette(): void {
setCommandPaletteOpen(true);
}
export function toggleCommandPalette(): void {
setCommandPaletteOpen(!paletteOpen);
}
function subscribePalette(callback: () => void): () => void {
paletteListeners.add(callback);
return () => paletteListeners.delete(callback);
}
function getPaletteSnapshot(): boolean {
return paletteOpen;
}
// SSR 快照恒为 false命令面板永不在服务端渲染开态避免水合不一致。
export function useCommandPaletteOpen(): boolean {
return useSyncExternalStore(
subscribePalette,
getPaletteSnapshot,
() => false,
);
}
const LIST_ID = "command-list";
const optionId = (cmd: Command): string => `command-option-${cmd.id}`;
// 统一的键帽kbd样式暖纸感描边 + 等宽小字,与 NavDrawer 的 ⌘K 提示一致。
function KeyCap({ children }: { children: React.ReactNode }) {
return (
<kbd className="rounded border border-line bg-panel px-1 font-mono text-2xs text-ink-soft">
{children}
</kbd>
);
}
// 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null {
const m = pathname.match(/^\/projects\/([^/]+)/);
return m?.[1] ?? null;
}
interface CommandPaletteProps {
pathname: string;
}
// 命令面板⌘KUX §7键盘触发的快速导航/动作。
// WAI-ARIA combobox/listboxinput role=comboboxaria-expanded/controls/activedescendant
// 列表 role=listbox每项稳定 id + role=option。焦点管理打开聚焦输入框、关闭归还触发元素。
export function CommandPalette({ pathname }: CommandPaletteProps) {
const router = useRouter();
const open = useCommandPaletteOpen();
const [query, setQuery] = useState("");
const [highlight, setHighlight] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// 打开前的焦点元素关闭时归还焦点WCAG 2.4.3)。
const restoreFocusRef = useRef<HTMLElement | null>(null);
// 工具箱生成器(命令面板发现性):首次打开惰性拉取一次,失败静默降级为空。
const [tools, setTools] = useState<ToolCommandInfo[]>([]);
const toolsLoadedRef = useRef(false);
const projectId = projectIdFromPath(pathname);
const commands = useMemo<Command[]>(
() =>
projectId
? [...projectCommands(projectId, tools), ...globalCommands()]
: globalCommands(),
[projectId, tools],
);
const results = useMemo(
() => filterCommands(commands, query),
[commands, query],
);
const close = useCallback((): void => {
setCommandPaletteOpen(false);
setQuery("");
setHighlight(0);
}, []);
const run = useCallback(
(cmd: Command | undefined): void => {
if (!cmd) return;
close();
if (cmd.href) router.push(cmd.href);
},
[close, router],
);
useBodyScrollLock(open);
// ⌘K / Ctrl+K 全局开关。
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
toggleCommandPalette();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// 打开时记住先前焦点并聚焦输入框;关闭后把焦点归还触发元素。
useEffect(() => {
if (open) {
restoreFocusRef.current = document.activeElement as HTMLElement | null;
inputRef.current?.focus();
return;
}
restoreFocusRef.current?.focus();
restoreFocusRef.current = null;
}, [open]);
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
useEffect(() => {
if (!open || !projectId || toolsLoadedRef.current) return;
toolsLoadedRef.current = true;
let cancelled = false;
void (async () => {
try {
const { data } = await api.GET("/skills/toolbox");
if (cancelled || !data?.tools) return;
setTools(
data.tools
.filter((t) => !t.is_legacy)
.map((t) => ({ key: t.key, title: t.title, genre: t.genre })),
);
} catch {
// 静默降级:命令面板照常工作,仅缺生成器动作。
}
})();
return () => {
cancelled = true;
};
}, [open, projectId]);
useEffect(() => {
setHighlight(0);
}, [query]);
if (!open) return null;
const activeCmd = results[highlight];
const onInputKey = (e: React.KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => moveHighlight(h, 1, results.length));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => moveHighlight(h, -1, results.length));
} else if (e.key === "Enter") {
e.preventDefault();
run(results[highlight]);
}
};
return (
<div
className={`${overlayScrim} flex items-start justify-center pt-[15vh] motion-safe:transition-opacity`}
onClick={close}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="命令面板"
className="w-full max-w-lg overflow-hidden rounded border border-line bg-panel shadow-paper"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
// focus trapTab/Shift+Tab 在对话框内循环不逃逸到背景WCAG 2.1.2)。
if (dialogRef.current) handleTabTrap(dialogRef.current, e);
}}
>
<div className="relative border-b border-line">
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKey}
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
role="combobox"
aria-label="命令搜索"
aria-expanded={true}
aria-controls={LIST_ID}
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
aria-autocomplete="list"
className={`w-full bg-bg py-3 pl-4 pr-12 text-sm text-ink ${focusRing}`}
/>
{/* 触发快捷键读数:随面板常驻,强化 ⌘K 的可发现性(纯装饰,不入 Tab 序)。 */}
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<KeyCap>K</KeyCap>
</span>
</div>
<ul
id={LIST_ID}
role="listbox"
aria-label="命令列表"
className="max-h-80 overflow-auto py-1 overscroll-contain"
>
{results.length === 0 ? (
<li className="px-4 py-6 text-center">
<p className="text-sm text-ink-soft">
{query.trim()}
</p>
<p className="mt-1 text-caption text-muted-soft">
稿
</p>
</li>
) : (
results.map((cmd, i) => (
<li
key={cmd.id}
id={optionId(cmd)}
role="option"
aria-selected={i === highlight}
onMouseEnter={() => setHighlight(i)}
onClick={() => run(cmd)}
className={`flex cursor-pointer items-center justify-between px-4 py-2 text-sm ${
i === highlight
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink"
}`}
>
<span>{cmd.title}</span>
<span className="text-caption text-ink-soft">{cmd.group}</span>
</li>
))
)}
</ul>
{/* 键盘操作读数:↑↓ 选择 · ↵ 打开 · esc 关闭(常驻引导,纯装饰)。 */}
<div
aria-hidden="true"
className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-line px-4 py-2 text-caption text-muted-soft"
>
<span className="flex items-center gap-1">
<KeyCap></KeyCap>
<KeyCap></KeyCap>
</span>
<span className="flex items-center gap-1">
<KeyCap></KeyCap>
</span>
<span className="flex items-center gap-1">
<KeyCap>esc</KeyCap>
</span>
</div>
</div>
</div>
);
}