diff --git a/src/nav.test.ts b/src/nav.test.ts new file mode 100644 index 0000000..2d69b0d --- /dev/null +++ b/src/nav.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'bun:test'; +import { routeSearchKey } from './nav'; + +const SEARCH_KEYS = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'] as const; + +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('keeps normal Escape close behavior after composition', () => { + expect(runSearchKey('Escape')).toEqual({ + close: 1, + select: [], + activate: [], + preventDefault: 0, + }); + }); + + 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 5ad5f2c..385d984 100644 --- a/src/nav.ts +++ b/src/nav.ts @@ -50,22 +50,51 @@ const setSearchSelected = (i: number): void => { searchParent?.markDirty(); }; -const onSearchKey = (e: KeyboardEvent): void => { +function isSearchCompositionEvent(e: Pick): boolean { + // Some IME paths report the legacy process-key value without isComposing. + return e.isComposing || e.keyCode === 229; +} + +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 => {