feat(web): 命令面板 combobox+可见入口 + sticky 导航壳 + 焦点环统一 + 抽屉锁滚动/关闭按钮 + 主题首帧修复 + 设置徽标/datalist

This commit is contained in:
Yaojia Wang
2026-06-30 08:56:23 +02:00
parent 164e98887e
commit 6854dac98f
11 changed files with 251 additions and 77 deletions

View File

@@ -1,6 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useRouter } from "next/navigation";
import {
@@ -13,6 +19,52 @@ import {
} 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}`;
// 从 pathname 抽取当前 projectId/projects/<id>/...)。
function projectIdFromPath(pathname: string): string | null {
@@ -25,14 +77,17 @@ interface CommandPaletteProps {
}
// 命令面板⌘KUX §7键盘触发的快速导航/动作。
// 焦点管理打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11yrole=dialog/listbox
// WAI-ARIA combobox/listboxinput role=comboboxaria-expanded/controls/activedescendant
// 列表 role=listbox每项稳定 id + role=option。焦点管理打开聚焦输入框、关闭归还触发元素。
export function CommandPalette({ pathname }: CommandPaletteProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
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);
@@ -51,7 +106,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
);
const close = useCallback((): void => {
setOpen(false);
setCommandPaletteOpen(false);
setQuery("");
setHighlight(0);
}, []);
@@ -65,21 +120,29 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
[close, router],
);
useBodyScrollLock(open);
// ⌘K / Ctrl+K 全局开关。
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((prev) => !prev);
toggleCommandPalette();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// 打开时聚焦输入框;切查询重置高亮
// 打开时记住先前焦点并聚焦输入框;关闭后把焦点归还触发元素
useEffect(() => {
if (open) inputRef.current?.focus();
if (open) {
restoreFocusRef.current = document.activeElement as HTMLElement | null;
inputRef.current?.focus();
return;
}
restoreFocusRef.current?.focus();
restoreFocusRef.current = null;
}, [open]);
// 首次打开(项目内)惰性拉工具箱生成器列表,供生成 action-gen-<key> 命令。
@@ -110,6 +173,8 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
if (!open) return null;
const activeCmd = results[highlight];
const onInputKey = (e: React.KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
@@ -128,7 +193,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/30 pt-[15vh] motion-safe:transition-opacity"
className={`${overlayScrim} flex items-start justify-center pt-[15vh] motion-safe:transition-opacity`}
onClick={close}
>
<div
@@ -136,7 +201,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
role="dialog"
aria-modal="true"
aria-label="命令面板"
className="w-full max-w-lg overflow-hidden rounded-lg border border-line bg-panel shadow-paper"
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)。
@@ -149,15 +214,19 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onInputKey}
placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)"
role="combobox"
aria-label="命令搜索"
aria-controls="command-list"
className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none"
aria-expanded={true}
aria-controls={LIST_ID}
aria-activedescendant={activeCmd ? optionId(activeCmd) : undefined}
aria-autocomplete="list"
className={`w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink ${focusRing}`}
/>
<ul
id="command-list"
id={LIST_ID}
role="listbox"
aria-label="命令列表"
className="max-h-80 overflow-auto py-1"
className="max-h-80 overflow-auto py-1 overscroll-contain"
>
{results.length === 0 ? (
<li className="px-4 py-3 text-sm text-ink-soft"></li>
@@ -165,6 +234,7 @@ export function CommandPalette({ pathname }: CommandPaletteProps) {
results.map((cmd, i) => (
<li
key={cmd.id}
id={optionId(cmd)}
role="option"
aria-selected={i === highlight}
onMouseEnter={() => setHighlight(i)}

View File

@@ -1,8 +1,10 @@
"use client";
import { Search } from "lucide-react";
import { usePathname } from "next/navigation";
import { CommandPalette } from "./CommandPalette";
import { buttonClass } from "@/lib/ui/variants";
import { CommandPalette, openCommandPalette } from "./CommandPalette";
// 全局挂载命令面板⌘K读当前 pathname 注入项目上下文。
// 放在 RootLayout 内,对所有页面生效。
@@ -10,3 +12,27 @@ export function CommandPaletteMount() {
const pathname = usePathname();
return <CommandPalette pathname={pathname ?? "/"} />;
}
// 命令面板的可见入口(顶栏常驻):让不知道 ⌘K 的用户也能发现搜索。
// 与 ⌘K 触发同一外部 store移动端折叠为图标。
export function CommandSearchButton() {
return (
<button
type="button"
onClick={() => openCommandPalette()}
aria-label="搜索命令或页面"
title="搜索命令或页面⌘K"
className={buttonClass({
variant: "ghost",
size: "sm",
className: "border-transparent",
})}
>
<Search className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline"></span>
<kbd className="hidden rounded border border-line bg-bg px-1 font-mono text-2xs text-ink-soft sm:inline">
K
</kbd>
</button>
);
}