- 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)
299 lines
9.6 KiB
TypeScript
299 lines
9.6 KiB
TypeScript
"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;
|
||
}
|
||
|
||
// 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。
|
||
// WAI-ARIA combobox/listbox:input role=combobox(aria-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 trap:Tab/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>
|
||
);
|
||
}
|