Skip to content

Commit bae6abe

Browse files
committed
fix(web): restore search enter behavior
1 parent 9d7d815 commit bae6abe

8 files changed

Lines changed: 457 additions & 9 deletions

File tree

‎apps/web/app/components/CommandPalette.tsx‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { useEffect, useRef, useState, type RefObject } from "react";
1+
import {
2+
useEffect,
3+
useRef,
4+
useState,
5+
type FormEvent,
6+
type KeyboardEvent as ReactKeyboardEvent,
7+
type RefObject,
8+
} from "react";
29
import { useNavigate, useRouteLoaderData } from "react-router";
310
import { FileText, Info, LayoutDashboard, MessageSquare, Pencil, X } from "lucide-react";
411
import { Button } from "./ui/button";
@@ -36,6 +43,7 @@ export function CommandPalette({
3643
finalFocus?: RefObject<HTMLElement | null>;
3744
}) {
3845
const [query, setQuery] = useState("");
46+
const hasExplicitCommandSelectionRef = useRef(false);
3947
const fallbackFinalFocusRef = useRef<HTMLElement | null>(null);
4048
const finalFocusRef = finalFocus ?? fallbackFinalFocusRef;
4149
const navigate = useNavigate();
@@ -64,17 +72,48 @@ export function CommandPalette({
6472
}, [finalFocusRef, onOpenChange, open]);
6573

6674
useEffect(() => {
67-
if (!open) setQuery("");
75+
if (!open) {
76+
setQuery("");
77+
hasExplicitCommandSelectionRef.current = false;
78+
}
6879
}, [open]);
6980

70-
function submit(event: React.FormEvent) {
71-
event.preventDefault();
81+
function search() {
7282
const value = query.trim();
7383
if (!value) return;
7484
onOpenChange(false);
7585
void navigate(`/search?q=${encodeURIComponent(value)}`);
7686
}
7787

88+
function submit(event: FormEvent) {
89+
event.preventDefault();
90+
search();
91+
}
92+
93+
function updateQuery(value: string) {
94+
hasExplicitCommandSelectionRef.current = false;
95+
setQuery(value);
96+
}
97+
98+
function handleCommandKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
99+
if (!(event.target instanceof HTMLInputElement)) return;
100+
101+
const isComposing = event.nativeEvent.isComposing || event.keyCode === 229;
102+
if (isComposing) return;
103+
104+
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
105+
hasExplicitCommandSelectionRef.current = Boolean(
106+
event.currentTarget.querySelector('[cmdk-item]:not([aria-disabled="true"])'),
107+
);
108+
return;
109+
}
110+
111+
if (event.key !== "Enter" || hasExplicitCommandSelectionRef.current) return;
112+
113+
event.preventDefault();
114+
search();
115+
}
116+
78117
function go(to: string) {
79118
onOpenChange(false);
80119
void navigate(to);
@@ -91,12 +130,12 @@ export function CommandPalette({
91130
<DialogTitle>搜索和快速操作</DialogTitle>
92131
<DialogDescription>搜索话题、用户,或跳转到常用页面。</DialogDescription>
93132
</DialogHeader>
94-
<Command label="搜索命令">
133+
<Command label="搜索命令" onKeyDown={handleCommandKeyDown}>
95134
<div data-slot="command-search-row" className="flex min-w-0 items-center gap-1 p-1 pb-0">
96135
<form onSubmit={submit} className="min-w-0 flex-1">
97136
<CommandInput
98137
value={query}
99-
onValueChange={setQuery}
138+
onValueChange={updateQuery}
100139
placeholder="搜索话题、用户,或输入关键词..."
101140
aria-label="搜索命令"
102141
autoFocus

‎apps/web/tests/AsyncFeedback.test.tsx‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ afterEach(() => {
1818
});
1919

2020
describe("异步反馈", () => {
21+
function renderSearch(initialEntry = "/search") {
22+
const router = createMemoryRouter([{ path: "*", element: <Search /> }], {
23+
initialEntries: [initialEntry],
24+
});
25+
return { ...render(<RouterProvider router={router} />), router };
26+
}
27+
2128
it("announces Turnstile readiness and verification", async () => {
2229
let options: Record<string, unknown> = {};
2330
window.turnstile = {
@@ -57,4 +64,46 @@ describe("异步反馈", () => {
5764
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
5865
expect(await screen.findByText('未找到与 "node" 相关的内容')).toBeInTheDocument();
5966
});
67+
68+
it.each(["keyboard", "button"])(
69+
"normalizes and submits a search with the %s path",
70+
async (submission) => {
71+
const user = userEvent.setup();
72+
apiFetch.mockResolvedValue({ success: true, data: [] });
73+
const { router } = renderSearch();
74+
const input = screen.getByRole("textbox", { name: "搜索话题" });
75+
76+
await user.type(input, " node js ");
77+
if (submission === "keyboard") {
78+
await user.keyboard("{Enter}");
79+
} else {
80+
await user.click(screen.getByRole("button", { name: "搜索" }));
81+
}
82+
83+
await waitFor(() =>
84+
expect(new URLSearchParams(router.state.location.search).get("q")).toBe("node js"),
85+
);
86+
await waitFor(() =>
87+
expect(apiFetch).toHaveBeenCalledWith(
88+
"/api/v1/search?q=node%20js&engine=local",
89+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
90+
),
91+
);
92+
},
93+
);
94+
95+
it.each(["", " "])("ignores an empty search query %p", async (query) => {
96+
const user = userEvent.setup();
97+
const { router } = renderSearch();
98+
const input = screen.getByRole("textbox", { name: "搜索话题" });
99+
100+
if (query) await user.type(input, query);
101+
await user.keyboard("{Enter}");
102+
103+
expect(router.state.location.pathname).toBe("/search");
104+
expect(router.state.location.search).toBe("");
105+
expect(apiFetch).not.toHaveBeenCalled();
106+
expect(input).toHaveValue(query);
107+
expect(input).toBeEnabled();
108+
});
60109
});

‎apps/web/tests/LayoutNavigation.test.tsx‎

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { render, screen, waitFor, within } from "@testing-library/react";
1+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import { createMemoryRouter, MemoryRouter, RouterProvider } from "react-router";
44
import { renderToString } from "react-dom/server";
5-
import { describe, expect, it } from "vite-plus/test";
5+
import { describe, expect, it, vi } from "vite-plus/test";
66
import { Footer, Header, Layout } from "~/components/Layout";
77
import { CommandPalette } from "~/components/CommandPalette";
88
import { AdminLayout } from "~/components/AdminLayout";
@@ -109,7 +109,87 @@ describe("公开导航收束", () => {
109109
expect(await screen.findByRole("status")).toHaveTextContent("没有匹配的快捷命令");
110110
});
111111

112-
it.each([375, 1280])(
112+
it.each([
113+
["绝对不存在的命令", "绝对不存在的命令"],
114+
["node", "node"],
115+
[" node js ", "node js"],
116+
])(
117+
"searches for %p with Enter instead of activating an implicit command",
118+
async (query, expected) => {
119+
const user = userEvent.setup();
120+
const onOpenChange = vi.fn();
121+
const { router } = renderWithRoot(<CommandPalette open onOpenChange={onOpenChange} />);
122+
const input = screen.getByRole("combobox", { name: "搜索命令" });
123+
124+
await user.type(input, query);
125+
await user.keyboard("{Enter}");
126+
127+
await waitFor(() => expect(router.state.location.pathname).toBe("/search"));
128+
expect(new URLSearchParams(router.state.location.search).get("q")).toBe(expected);
129+
expect(onOpenChange).toHaveBeenCalledWith(false);
130+
},
131+
);
132+
133+
it("resets explicit command selection when the query changes", async () => {
134+
const user = userEvent.setup();
135+
const { router } = renderWithRoot(<CommandPalette open onOpenChange={() => {}} />);
136+
const input = screen.getByRole("combobox", { name: "搜索命令" });
137+
138+
await user.keyboard("{ArrowDown}");
139+
await user.type(input, "node");
140+
await user.keyboard("{Enter}");
141+
142+
await waitFor(() => expect(router.state.location.pathname).toBe("/search"));
143+
expect(new URLSearchParams(router.state.location.search).get("q")).toBe("node");
144+
});
145+
146+
it("keeps Enter available for search when arrow navigation has no matching command", async () => {
147+
const user = userEvent.setup();
148+
const { router } = renderWithRoot(<CommandPalette open onOpenChange={() => {}} />);
149+
const input = screen.getByRole("combobox", { name: "搜索命令" });
150+
151+
await user.type(input, "绝对不存在的命令");
152+
await user.keyboard("{ArrowDown}{Enter}");
153+
154+
await waitFor(() => expect(router.state.location.pathname).toBe("/search"));
155+
expect(new URLSearchParams(router.state.location.search).get("q")).toBe("绝对不存在的命令");
156+
});
157+
158+
it.each(["", " "])("ignores an empty command search %p", async (query) => {
159+
const user = userEvent.setup();
160+
const onOpenChange = vi.fn();
161+
const { router } = renderWithRoot(<CommandPalette open onOpenChange={onOpenChange} />);
162+
const input = screen.getByRole("combobox", { name: "搜索命令" });
163+
164+
if (query) await user.type(input, query);
165+
await user.keyboard("{Enter}");
166+
167+
expect(router.state.location.pathname).toBe("/");
168+
expect(router.state.location.search).toBe("");
169+
expect(onOpenChange).not.toHaveBeenCalledWith(false);
170+
expect(input).toHaveValue(query);
171+
expect(input).toBeEnabled();
172+
});
173+
174+
it("does not submit or activate a command while an IME composition is active", async () => {
175+
const user = userEvent.setup();
176+
const onOpenChange = vi.fn();
177+
const { router } = renderWithRoot(<CommandPalette open onOpenChange={onOpenChange} />);
178+
const input = screen.getByRole("combobox", { name: "搜索命令" });
179+
await user.type(input, "节点");
180+
181+
fireEvent.keyDown(input, { key: "Enter", code: "Enter", isComposing: true });
182+
183+
expect(router.state.location.pathname).toBe("/");
184+
expect(onOpenChange).not.toHaveBeenCalledWith(false);
185+
expect(input).toHaveValue("节点");
186+
187+
await user.keyboard("{Enter}");
188+
await waitFor(() => expect(router.state.location.pathname).toBe("/search"));
189+
expect(new URLSearchParams(router.state.location.search).get("q")).toBe("节点");
190+
});
191+
192+
it.each([375, 768, 1280, 1440])(
113193
"allocates a non-overlapping search row and touch-safe close control at %ipx",
114194
(width) => {
115195
window.innerWidth = width;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-11-09
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Design
2+
3+
## Context
4+
5+
参见 `proposal.md` 的问题说明。页头 `CommandPalette` 同时承载两种操作:提交自由文本搜索,以及从 `cmdk` 列表执行快捷操作。当前输入框位于原生 `<form>` 内,但 `cmdk` 根组件在冒泡阶段对 Enter 调用 `preventDefault()`,导致表单 `onSubmit` 不运行;若筛选后存在隐式选中项,Enter 还会执行该项。
6+
7+
`/search` 是现有 feed archetype 路由,查询词由 URL `q` 参数驱动,随后调用既有 `/api/v1/search?q=...&engine=local`。本次设计保持该 URL 和请求边界不变。UI 继续复用 `Layout`、`FeedPage`、Field、Input、Button、Dialog 和 Command 组合,不修改 design token、响应式结构或 primitive API。
8+
9+
## Goals / Non-Goals
10+
11+
**Goals:**
12+
13+
- 在 `CommandPalette` consumer 层区分“直接提交搜索”和“明确选择快捷操作”。
14+
- 让 Enter 和按钮提交共用同一套 trim、空值判断与 URL 构造规则。
15+
- 在事件处理顺序中正确保护输入法组合文本。
16+
- 用行为测试覆盖 `cmdk` 拦截 Enter 的真实集成路径,而不是只测试提交函数。
17+
18+
**Non-Goals:**
19+
20+
- 不改变搜索接口、结果数据、搜索范围、排序或错误反馈。
21+
- 不把站内搜索语义写入通用 Command/Input primitive。
22+
- 不升级、替换或 fork `cmdk`,也不新增搜索抽象层。
23+
24+
## Decisions
25+
26+
### 1. 在 CommandPalette consumer 层抢先处理搜索 Enter
27+
28+
在 `CommandPalette` 的 Command 事件边界观察键盘操作。当事件来自搜索输入、不是输入法组合状态、查询词非空,且用户没有通过方向键明确移动快捷项选择时,处理器将阻止 `cmdk` 的默认 Enter 行为并调用统一搜索提交函数。`cmdk` 会先调用 consumer 提供的键盘处理器,再检查 `defaultPrevented`,因此该边界可以避免隐式快捷项执行。
29+
30+
```mermaid
31+
flowchart TD
32+
A[搜索输入收到 Enter] --> B{正在输入法组合?}
33+
B -- 是 --> C[保留组合确认行为]
34+
B -- 否 --> D{关键词 trim 后非空?}
35+
D -- 否 --> E[不发起搜索]
36+
D -- 是 --> F{用户明确移动过快捷项选择?}
37+
F -- 是 --> G[交给 cmdk 执行当前快捷项]
38+
F -- 否 --> H[阻止 cmdk 默认行为]
39+
H --> I[关闭面板并导航到 /search?q=...]
40+
```
41+
42+
选择意图只由当前查询周期内的方向键导航建立;搜索词变化后重置。鼠标或触控直接激活 `CommandItem` 继续走现有 `onSelect`。
43+
44+
**拒绝的替代方案:**
45+
46+
- 只依赖 `<form onSubmit>`:`cmdk` 已经取消 Enter 默认行为,无法修复根因。
47+
- 在输入框每次 Enter 都搜索:会破坏现有方向键选择快捷操作的键盘能力。
48+
- 修改共享 `components/ui/command.tsx`:搜索优先是 CNode CommandPalette 的领域行为,写入 primitive 会影响其他 Command 使用者并违反 primitive ownership。
49+
- 将“搜索关键词”渲染为动态 CommandItem:可以利用 `cmdk` 选择模型,但会改变列表结构、筛选排序和辅助技术播报,修复范围更大。
50+
51+
### 2. 搜索提交使用单一函数,表单作为可访问回退边界
52+
53+
CommandPalette 内部用一个提交函数完成 trim、空值判断、关闭面板和 React Router 导航;表单 `onSubmit` 与键盘拦截路径调用相同逻辑。`/search` 页面继续使用原生表单提交事件,使 Enter 和 Button `type="submit"` 进入同一个 handler。
54+
55+
URL 继续通过路由 API 写入 `q`,并在构造导航 URL 时编码查询值。不引入额外本地搜索状态或请求层改动。
56+
57+
**拒绝的替代方案:**
58+
59+
- 为 Enter 和按钮维护两个处理函数:容易产生 trim、空查询和 URL 编码差异。
60+
- 使用 `window.location` 全页跳转:会丢失 React Router 客户端导航和现有 transition 行为。
61+
62+
### 3. IME 判断先于搜索和快捷操作判断
63+
64+
键盘处理器首先检查原生 composition 状态,并兼容浏览器用于组合输入的 key code。组合中的 Enter 不调用 `preventDefault()`、不关闭 overlay、不导航;组合结束后的下一次 Enter 才按普通规则处理。
65+
66+
**拒绝的替代方案:**
67+
68+
- 仅监听 `compositionend` 后设置延时状态:增加竞态和计时依赖,没有必要。
69+
- 仅检查 React 合成事件字段:底层 `cmdk` 读取原生事件,统一参考原生 composition 状态更接近实际冲突点。
70+
71+
### 4. 测试覆盖事件竞争和两个入口的行为一致性
72+
73+
CommandPalette 测试使用真实 Command、输入框和 memory router:输入无匹配关键词按 Enter、输入可模糊匹配快捷项的关键词按 Enter、方向键明确选项后按 Enter、空查询,以及 composition Enter。搜索页测试验证 Enter 与按钮都更新 `q` 并触发同一查询。
74+
75+
视觉结构没有变化,因此不新增截图基线;实施验收仍在桌面和移动 viewport 检查 overlay、焦点、Escape、关闭控件及结果状态,并检查浏览器 console。
76+
77+
## Risks / Trade-offs
78+
79+
- [明确选择状态没有在查询变化时重置] → 在输入值变化、面板关闭和重新打开时重置,并用“方向键后继续输入再 Enter”的测试覆盖。
80+
- [IME Enter 被误提交] → 在 consumer handler 最前检查 `nativeEvent.isComposing` 和兼容信号,并增加 composition 回归测试。
81+
- [键盘快捷操作失效] → 保留方向键建立明确选择后由 `cmdk` 处理 Enter,并保留现有快捷操作测试。
82+
- [事件处理依赖 `cmdk` 当前调用顺序] → 集成测试直接挂载真实 `cmdk@1.1.1`;不 mock Command primitive。未来升级依赖时同一测试将暴露变化。
83+
- [额外选择意图状态增加复杂度] → 状态只存在于 CommandPalette,不进入全局 store 或通用 primitive。
84+
85+
## Migration Plan
86+
87+
1. 实施 Web consumer 行为和回归测试,不执行数据迁移或接口发布。
88+
2. 运行目标测试、Web test/typecheck/build,并在可行时运行 `pnpm verify`。
89+
3. 按 `cnode-deploy` 流程等待 CI 镜像构建完成、部署现有 Compose 服务,并验证生产健康状态。
90+
4. 在生产环境分别验证无匹配关键词、可模糊匹配关键词、方向键快捷项、Escape/焦点恢复和移动端搜索。
91+
5. 如出现回归,按现有部署流程回滚到前一个不可变镜像;无数据库或 API 回滚步骤。

0 commit comments

Comments
 (0)