Skip to content

Commit b0fd352

Browse files
authored
fix(inputs): ignore IME confirmation Enter in filter editors (#627)
1 parent 7f27830 commit b0fd352

10 files changed

Lines changed: 149 additions & 34 deletions

File tree

‎.changeset/quiet-inputs-compose.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@tailor-platform/app-shell": patch
3+
---
4+
5+
Prevent IME confirmation Enter from applying DataTable filters or closing their value editors mid-input.
6+
Use consistent IME detection, including the keyCode 229 fallback, in filters, cell editors, the command palette, and AI chat.

‎packages/core/src/components/ai-chat/ai-chat.test.tsx‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState } from "react";
22
import { afterEach, describe, expect, it, vi } from "vitest";
3-
import { cleanup, render, screen } from "@testing-library/react";
3+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
44
import userEvent from "@testing-library/user-event";
55

66
import { AIChat } from "./ai-chat";
@@ -253,19 +253,21 @@ describe("AIChat", () => {
253253
expect(onSubmit).not.toHaveBeenCalled();
254254
});
255255

256-
it("does not submit on Enter during IME composition", async () => {
256+
it.each([
257+
{ isComposing: true, keyCode: 13 },
258+
{ isComposing: false, keyCode: 229 },
259+
])("ignores IME Enter (%j) but submits on normal Enter", async (ime) => {
257260
const user = userEvent.setup();
258261
const { onSubmit } = renderComposer();
259262
await user.type(textbox(), "変換");
260-
textbox().dispatchEvent(
261-
new KeyboardEvent("keydown", {
262-
key: "Enter",
263-
bubbles: true,
264-
cancelable: true,
265-
isComposing: true,
266-
}),
267-
);
263+
264+
fireEvent.keyDown(textbox(), { key: "Enter", ...ime });
268265
expect(onSubmit).not.toHaveBeenCalled();
266+
expect(textbox().value).toBe("変換");
267+
268+
await user.keyboard("{Enter}");
269+
expect(onSubmit).toHaveBeenCalledExactlyOnceWith("変換");
270+
expect(textbox().value).toBe("");
269271
});
270272

271273
it("disables submit while the draft is empty or whitespace-only", async () => {

‎packages/core/src/components/ai-chat/composer.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Button } from "@/components/button";
1111
import { Textarea } from "@/components/textarea";
1212
import { useT } from "@/i18n-labels";
1313
import { cn } from "@/lib/utils";
14+
import { isComposing } from "@/lib/is-composing";
1415
import { useAIChatContext } from "./ai-chat-context";
1516

1617
type AIChatComposerProps = {
@@ -121,7 +122,7 @@ function Composer({
121122
submitOnEnter &&
122123
event.key === "Enter" &&
123124
!event.shiftKey &&
124-
!event.nativeEvent.isComposing
125+
!isComposing(event.nativeEvent)
125126
) {
126127
event.preventDefault();
127128
event.currentTarget.form?.requestSubmit();

‎packages/core/src/components/command-palette/command-palette.test.tsx‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -161,23 +161,22 @@ describe("CommandPaletteContent Integration", () => {
161161
});
162162
});
163163

164-
it("ignores Enter during IME composition", async () => {
164+
it.each([
165+
{ isComposing: true, keyCode: 13 },
166+
{ isComposing: false, keyCode: 229 },
167+
])("ignores IME Enter (%j) but navigates on normal Enter", async (ime) => {
165168
renderCommandPaletteContent();
166169

167170
fireEvent.keyDown(document, { key: "k", metaKey: true });
168171
const dialog = await screen.findByRole("dialog");
169172

170-
// Simulate IME composition
171-
const enterEvent = new KeyboardEvent("keydown", {
172-
key: "Enter",
173-
bubbles: true,
174-
isComposing: true,
175-
});
176-
177-
dialog.dispatchEvent(enterEvent);
178-
179-
// Dialog should remain open
173+
fireEvent.keyDown(dialog, { key: "Enter", ...ime });
180174
expect(screen.getByPlaceholderText("Search pages...")).toBeDefined();
175+
176+
fireEvent.keyDown(dialog, { key: "Enter" });
177+
await vi.waitFor(() => {
178+
expect(screen.queryByPlaceholderText("Search pages...")).toBeNull();
179+
});
181180
});
182181
});
183182

‎packages/core/src/components/command-palette/command-palette.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Input } from "@/components/input";
77
import { Spinner } from "@/components/spinner";
88
import { useT } from "@/i18n-labels";
99
import { cn } from "@/lib/utils";
10+
import { isComposing } from "@/lib/is-composing";
1011
import { filterRoutes, NavigatableRoute } from "@/routing/path";
1112
import { useNavItems, NavItem, NavItemResource } from "../../routing/navigation";
1213
import {
@@ -460,8 +461,7 @@ export function useCommandPalette({
460461

461462
const handleKeyDown = useCallback(
462463
(e: React.KeyboardEvent) => {
463-
// Ignore during IME composition
464-
if (e.nativeEvent.isComposing) return;
464+
if (isComposing(e.nativeEvent)) return;
465465

466466
switch (e.key) {
467467
case "ArrowDown":

‎packages/core/src/components/data-table/editable-cell.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Popover } from "@base-ui/react/popover";
1717
import type { CalendarDate } from "@internationalized/date";
1818
import { CalendarDays, ChevronDown } from "lucide-react";
1919
import { cn } from "@/lib/utils";
20+
import { isComposing } from "@/lib/is-composing";
2021
import { useTimeZone } from "@/contexts/appshell-context";
2122
import { BadgeList } from "@/components/badge-list";
2223
import { Button } from "@/components/button";
@@ -399,7 +400,7 @@ function TypingEditCell<TRow extends Record<string, unknown>>({
399400

400401
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
401402
// While an IME is composing, Enter confirms the conversion, not the cell.
402-
if (event.nativeEvent.isComposing || composingRef.current || event.keyCode === 229) return;
403+
if (isComposing(event.nativeEvent, composingRef.current)) return;
403404
const from = { rowKey, colKey };
404405
switch (event.key) {
405406
case "Enter":
@@ -670,7 +671,7 @@ function ChoiceEditCell<TRow extends Record<string, unknown>>({
670671
const handleKeyDown: NonNullable<ComponentProps<typeof ComboboxParts.Input>["onKeyDown"]> = (
671672
event,
672673
) => {
673-
if (event.nativeEvent.isComposing || composingRef.current || event.keyCode === 229) {
674+
if (isComposing(event.nativeEvent, composingRef.current)) {
674675
event.preventBaseUIHandler();
675676
return;
676677
}

‎packages/core/src/components/data-table/toolbar.test.tsx‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,90 @@ const stringInvalidOperatorColumn: Column<TestRow> = {
232232
},
233233
};
234234

235+
describe.each([
236+
{ isComposing: true, keyCode: 13 },
237+
{ isComposing: false, keyCode: 229 },
238+
])("filter IME handling ($isComposing, $keyCode)", (compositionEvent) => {
239+
describe.each(["panel", "chip"] as const)("%s", (surface) => {
240+
it.each([
241+
{ column: stringColumn, initial: "Alice", draft: "株式会社", final: "株式会社東京" },
242+
{ column: uuidColumn, initial: "uuid-1", draft: "uuid-2", final: "uuid-3" },
243+
{ column: numberColumn, initial: 1, draft: "2", final: 3 },
244+
{ column: timeColumn, initial: "09:30", draft: "10:30", final: "11:30" },
245+
])(
246+
"does not commit $column.id until Enter outside composition",
247+
async ({ column, initial, draft, final }) => {
248+
const user = userEvent.setup();
249+
const field = column.id!;
250+
const operator = column === stringColumn ? "contains" : "eq";
251+
const control = makeControl({ filters: [{ field, operator, value: initial }] });
252+
render(<TestFilters control={control} columns={[column]} />, { wrapper });
253+
254+
if (surface === "panel") {
255+
await user.click(screen.getByRole("button", { name: "Add filter" }));
256+
} else {
257+
await openValueEditor(user);
258+
}
259+
260+
const input = await screen.findByDisplayValue(String(initial));
261+
await user.click(input);
262+
fireEvent.change(input, { target: { value: draft } });
263+
fireEvent.keyDown(input, { key: "Enter", ...compositionEvent });
264+
265+
expect(control.addFilter).not.toHaveBeenCalled();
266+
expect(control.removeFilter).not.toHaveBeenCalled();
267+
expect(document.activeElement).toBe(input);
268+
expect(screen.getByDisplayValue(draft)).toBe(input);
269+
270+
fireEvent.change(input, { target: { value: String(final) } });
271+
fireEvent.keyDown(input, { key: "Enter", ...compositionEvent });
272+
expect(control.addFilter).not.toHaveBeenCalled();
273+
expect(control.removeFilter).not.toHaveBeenCalled();
274+
275+
fireEvent.keyDown(input, { key: "Enter", isComposing: false, keyCode: 13 });
276+
expect(control.addFilter).toHaveBeenCalledTimes(1);
277+
expect(vi.mocked(control.addFilter).mock.calls[0].slice(0, 3)).toEqual([
278+
field,
279+
operator,
280+
final,
281+
]);
282+
if (surface === "chip") {
283+
await vi.waitFor(() => expect(screen.queryByDisplayValue(String(final))).toBeNull());
284+
} else {
285+
expect(screen.getByDisplayValue(String(final))).toBe(input);
286+
}
287+
},
288+
);
289+
290+
it.each(["Min", "Max"])("does not commit a range from %s during composition", async (bound) => {
291+
const user = userEvent.setup();
292+
const control = makeControl({
293+
filters: [{ field: "count", operator: "between", value: { min: 1, max: 10 } }],
294+
});
295+
render(<TestFilters control={control} columns={[numberColumn]} />, { wrapper });
296+
if (surface === "panel") {
297+
await user.click(screen.getByRole("button", { name: "Add filter" }));
298+
} else {
299+
await openValueEditor(user);
300+
}
301+
302+
const input = await screen.findByRole("spinbutton", { name: bound });
303+
fireEvent.change(input, { target: { value: bound === "Min" ? "2" : "20" } });
304+
fireEvent.keyDown(input, { key: "Enter", ...compositionEvent });
305+
expect(control.addFilter).not.toHaveBeenCalled();
306+
expect(control.removeFilter).not.toHaveBeenCalled();
307+
expect(screen.getByRole("spinbutton", { name: bound })).toBe(input);
308+
309+
fireEvent.keyDown(input, { key: "Enter", isComposing: false, keyCode: 13 });
310+
expect(control.addFilter).toHaveBeenCalledTimes(1);
311+
expect(control.addFilter).toHaveBeenCalledWith("count", "between", {
312+
min: bound === "Min" ? 2 : 1,
313+
max: bound === "Max" ? 20 : 10,
314+
});
315+
});
316+
});
317+
});
318+
235319
// ---------------------------------------------------------------------------
236320
// DataTable.Filters — rendering
237321
// ---------------------------------------------------------------------------

‎packages/core/src/components/data-table/toolbar.tsx‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
22
import { Popover } from "@base-ui/react/popover";
33
import { ChevronDown, Filter as FilterIcon, X, Check, Search } from "lucide-react";
44
import { cn } from "@/lib/utils";
5+
import { isComposing } from "@/lib/is-composing";
56
import { useCollectionControlOptional } from "@/contexts/collection-control-context";
67
import { Button } from "@/components/button";
78
import { Input } from "@/components/input";
@@ -931,7 +932,7 @@ function PanelValueEditor({
931932
value={text}
932933
onChange={(e) => setText(e.target.value)}
933934
onKeyDown={(e) => {
934-
if (e.key === "Enter") apply();
935+
if (e.key === "Enter" && !isComposing(e.nativeEvent)) apply();
935936
}}
936937
className="astw:h-8 astw:text-sm"
937938
/>
@@ -947,7 +948,7 @@ function PanelValueEditor({
947948
placeholder={t("filterValuePlaceholder", { field: label })}
948949
onChange={(e) => setText(e.target.value)}
949950
onKeyDown={(e) => {
950-
if (e.key === "Enter") apply();
951+
if (e.key === "Enter" && !isComposing(e.nativeEvent)) apply();
951952
}}
952953
className="astw:h-8 astw:text-sm"
953954
/>
@@ -1031,7 +1032,7 @@ function BetweenInputGroup({
10311032
onChange={(e) => onChangeMin(e.target.value)}
10321033
onKeyDown={(e) => {
10331034
e.stopPropagation();
1034-
if (e.key === "Enter") onSubmit();
1035+
if (e.key === "Enter" && !isComposing(e.nativeEvent)) onSubmit();
10351036
}}
10361037
className={inputCell}
10371038
/>
@@ -1046,7 +1047,7 @@ function BetweenInputGroup({
10461047
onChange={(e) => onChangeMax(e.target.value)}
10471048
onKeyDown={(e) => {
10481049
e.stopPropagation();
1049-
if (e.key === "Enter") onSubmit();
1050+
if (e.key === "Enter" && !isComposing(e.nativeEvent)) onSubmit();
10501051
}}
10511052
className={inputCell}
10521053
/>
@@ -1732,7 +1733,7 @@ function StringFilterEditor({
17321733
onChange={(e) => setLocalValue(e.target.value)}
17331734
onKeyDown={(e) => {
17341735
e.stopPropagation();
1735-
if (e.key === "Enter") handleCommit();
1736+
if (e.key === "Enter" && !isComposing(e.nativeEvent)) handleCommit();
17361737
}}
17371738
className="astw:h-8 astw:text-sm"
17381739
/>
@@ -1784,7 +1785,7 @@ function UuidFilterEditor({
17841785
onChange={(e) => setLocalValue(e.target.value)}
17851786
onKeyDown={(e) => {
17861787
e.stopPropagation();
1787-
if (e.key === "Enter") handleCommit();
1788+
if (e.key === "Enter" && !isComposing(e.nativeEvent)) handleCommit();
17881789
}}
17891790
className="astw:h-8 astw:text-sm"
17901791
/>
@@ -1936,7 +1937,7 @@ function NumericFilterEditor({
19361937
onChange={(e) => setLocalValue(e.target.value)}
19371938
onKeyDown={(e) => {
19381939
e.stopPropagation();
1939-
if (e.key === "Enter") handleCommit();
1940+
if (e.key === "Enter" && !isComposing(e.nativeEvent)) handleCommit();
19401941
}}
19411942
className="astw:h-8 astw:text-sm"
19421943
/>
@@ -2120,7 +2121,7 @@ function TemporalFilterEditor({
21202121
}}
21212122
onKeyDown={(e) => {
21222123
e.stopPropagation();
2123-
if (e.key === "Enter") {
2124+
if (e.key === "Enter" && !isComposing(e.nativeEvent)) {
21242125
handleCommit();
21252126
}
21262127
}}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isComposing } from "./is-composing";
3+
4+
describe("isComposing", () => {
5+
it.each([
6+
{ isComposing: false, keyCode: 13, composing: false, expected: false },
7+
{ isComposing: true, keyCode: 13, composing: false, expected: true },
8+
{ isComposing: false, keyCode: 229, composing: false, expected: true },
9+
{ isComposing: false, keyCode: 13, composing: true, expected: true },
10+
])("detects IME events: %o", ({ composing, expected, ...event }) => {
11+
expect(isComposing(new KeyboardEvent("keydown", event), composing)).toBe(expected);
12+
});
13+
14+
it("defaults to the native event when no composition state is supplied", () => {
15+
expect(isComposing(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(false);
16+
});
17+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export function isComposing(event: KeyboardEvent, composing = false): boolean {
2+
// Some browsers end composition before keydown; keyCode 229 still identifies the IME event.
3+
return event.isComposing || composing || event.keyCode === 229;
4+
}

0 commit comments

Comments
 (0)