From 118a6049267e42040e371a6a3100323a0b60bebb Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Sun, 30 Aug 2026 00:41:09 +0800 Subject: [PATCH 1/2] fix(search): preserve Chinese IME composition (CTX-0046) --- src/nav.test.ts | 32 ++++++++++++++++++++++++++++++++ src/nav.ts | 8 ++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/nav.test.ts diff --git a/src/nav.test.ts b/src/nav.test.ts new file mode 100644 index 00000000..72f1575d --- /dev/null +++ b/src/nav.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test'; +import { isSearchCompositionEvent } from './nav'; + +const keyboardEvent = ( + key: string, + options: { isComposing?: boolean; keyCode?: number } = {}, +): KeyboardEvent => + ({ + key, + isComposing: options.isComposing ?? false, + keyCode: options.keyCode ?? 0, + }) as KeyboardEvent; + +describe('search keyboard routing', () => { + test.each(['ArrowDown', 'ArrowUp', 'Enter', 'Escape'])( + 'leaves %s to an active IME composition', + (key) => { + expect(isSearchCompositionEvent(keyboardEvent(key, { isComposing: true }))).toBe(true); + }, + ); + + test('recognizes the legacy IME keyCode 229 fallback', () => { + expect(isSearchCompositionEvent(keyboardEvent('Enter', { keyCode: 229 }))).toBe(true); + }); + + test.each(['ArrowDown', 'ArrowUp', 'Enter', 'Escape'])( + 'keeps normal %s search behavior after composition', + (key) => { + expect(isSearchCompositionEvent(keyboardEvent(key))).toBe(false); + }, + ); +}); diff --git a/src/nav.ts b/src/nav.ts index 5ad5f2c2..aea64415 100644 --- a/src/nav.ts +++ b/src/nav.ts @@ -50,7 +50,15 @@ const setSearchSelected = (i: number): void => { searchParent?.markDirty(); }; +export function isSearchCompositionEvent( + e: Pick, +): boolean { + // Some IME paths report the legacy process-key value without isComposing. + return e.isComposing || e.keyCode === 229; +} + const onSearchKey = (e: KeyboardEvent): void => { + if (isSearchCompositionEvent(e)) return; if (e.key === 'Escape') { closeSearch(); return; From c79d52e5509a98a43a61115a858c9e9803f678ec Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Sun, 30 Aug 2026 04:35:13 +0800 Subject: [PATCH 2/2] test(search): cover production IME routing (CTX-0046) --- src/nav.test.ts | 123 +++++++++++++++++++++++++++++++++++++++--------- src/nav.ts | 41 ++++++++++++---- 2 files changed, 131 insertions(+), 33 deletions(-) diff --git a/src/nav.test.ts b/src/nav.test.ts index 72f1575d..2d69b0d4 100644 --- a/src/nav.test.ts +++ b/src/nav.test.ts @@ -1,32 +1,109 @@ import { describe, expect, test } from 'bun:test'; -import { isSearchCompositionEvent } from './nav'; +import { routeSearchKey } from './nav'; -const keyboardEvent = ( - key: string, - options: { isComposing?: boolean; keyCode?: number } = {}, -): KeyboardEvent => - ({ - key, - isComposing: options.isComposing ?? false, - keyCode: options.keyCode ?? 0, - }) as KeyboardEvent; +const SEARCH_KEYS = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'] as const; -describe('search keyboard routing', () => { - test.each(['ArrowDown', 'ArrowUp', 'Enter', 'Escape'])( - 'leaves %s to an active IME composition', - (key) => { - expect(isSearchCompositionEvent(keyboardEvent(key, { isComposing: true }))).toBe(true); +function runSearchKey( + key: string, + eventOptions: { isComposing?: boolean; keyCode?: number } = {}, + stateOptions: { rowCount?: number; selectedIndex?: number } = {}, +) { + const calls = { + close: 0, + select: [] as number[], + activate: [] as number[], + preventDefault: 0, + }; + routeSearchKey( + { + key, + isComposing: eventOptions.isComposing ?? false, + keyCode: eventOptions.keyCode ?? 0, + preventDefault: () => { + calls.preventDefault += 1; + }, + }, + { + modalOpen: true, + rowCount: stateOptions.rowCount ?? 3, + selectedIndex: stateOptions.selectedIndex ?? 1, + close: () => { + calls.close += 1; + }, + select: (index) => calls.select.push(index), + activate: (index) => calls.activate.push(index), }, ); + return calls; +} + +describe('search keyboard routing', () => { + for (const [signal, options] of [ + ['isComposing', { isComposing: true }], + ['keyCode 229', { keyCode: 229 }], + ] as const) { + test.each(SEARCH_KEYS)(`leaves %s to IME when ${signal} is active`, (key) => { + expect(runSearchKey(key, options)).toEqual({ + close: 0, + select: [], + activate: [], + preventDefault: 0, + }); + }); + } - test('recognizes the legacy IME keyCode 229 fallback', () => { - expect(isSearchCompositionEvent(keyboardEvent('Enter', { keyCode: 229 }))).toBe(true); + test('keeps normal Escape close behavior after composition', () => { + expect(runSearchKey('Escape')).toEqual({ + close: 1, + select: [], + activate: [], + preventDefault: 0, + }); }); - test.each(['ArrowDown', 'ArrowUp', 'Enter', 'Escape'])( - 'keeps normal %s search behavior after composition', - (key) => { - expect(isSearchCompositionEvent(keyboardEvent(key))).toBe(false); - }, - ); + test('keeps normal ArrowDown result selection after composition', () => { + expect(runSearchKey('ArrowDown')).toEqual({ + close: 0, + select: [2], + activate: [], + preventDefault: 1, + }); + }); + + test('keeps normal ArrowUp result selection after composition', () => { + expect(runSearchKey('ArrowUp')).toEqual({ + close: 0, + select: [0], + activate: [], + preventDefault: 1, + }); + }); + + test('keeps normal Enter result activation after composition', () => { + expect(runSearchKey('Enter')).toEqual({ + close: 0, + select: [], + activate: [1], + preventDefault: 1, + }); + }); + + test('keeps navigation clamped to the available result range', () => { + expect(runSearchKey('ArrowDown', {}, { selectedIndex: 2 }).select).toEqual([2]); + expect(runSearchKey('ArrowUp', {}, { selectedIndex: 0 }).select).toEqual([0]); + }); + + test('does not consume navigation or Enter when there are no results', () => { + expect(runSearchKey('ArrowDown', {}, { rowCount: 0 }).preventDefault).toBe(0); + expect(runSearchKey('Enter', {}, { rowCount: 0 }).preventDefault).toBe(0); + }); + + test('does not consume Enter before a result is selected', () => { + expect(runSearchKey('Enter', {}, { selectedIndex: -1 })).toEqual({ + close: 0, + select: [], + activate: [], + preventDefault: 0, + }); + }); }); diff --git a/src/nav.ts b/src/nav.ts index aea64415..385d9848 100644 --- a/src/nav.ts +++ b/src/nav.ts @@ -50,30 +50,51 @@ const setSearchSelected = (i: number): void => { searchParent?.markDirty(); }; -export function isSearchCompositionEvent( - e: Pick, -): boolean { +function isSearchCompositionEvent(e: Pick): boolean { // Some IME paths report the legacy process-key value without isComposing. return e.isComposing || e.keyCode === 229; } -const onSearchKey = (e: KeyboardEvent): void => { +interface SearchKeyState { + modalOpen: boolean; + rowCount: number; + selectedIndex: number; + close: () => void; + select: (index: number) => void; + activate: (index: number) => void; +} + +export function routeSearchKey( + e: Pick, + state: SearchKeyState, +): void { if (isSearchCompositionEvent(e)) return; if (e.key === 'Escape') { - closeSearch(); + state.close(); return; } - if (!searchModal || searchNavRows.length === 0) return; + if (!state.modalOpen || state.rowCount === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); - setSearchSelected(Math.min(searchSelected + 1, searchNavRows.length - 1)); + state.select(Math.min(state.selectedIndex + 1, state.rowCount - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); - setSearchSelected(Math.max(searchSelected - 1, 0)); - } else if (e.key === 'Enter' && searchSelected >= 0) { + state.select(Math.max(state.selectedIndex - 1, 0)); + } else if (e.key === 'Enter' && state.selectedIndex >= 0) { e.preventDefault(); - searchNavRows[searchSelected].emit('click'); + state.activate(state.selectedIndex); } +} + +const onSearchKey = (e: KeyboardEvent): void => { + routeSearchKey(e, { + modalOpen: searchModal !== null, + rowCount: searchNavRows.length, + selectedIndex: searchSelected, + close: closeSearch, + select: setSearchSelected, + activate: (index) => searchNavRows[index].emit('click'), + }); }; const onSearchBackdrop = (e: PointerEvent): void => {