Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/nav.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
43 changes: 36 additions & 7 deletions src/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,51 @@ const setSearchSelected = (i: number): void => {
searchParent?.markDirty();
};

const onSearchKey = (e: KeyboardEvent): void => {
function isSearchCompositionEvent(e: Pick<KeyboardEvent, 'isComposing' | 'keyCode'>): 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<KeyboardEvent, 'key' | 'isComposing' | 'keyCode' | 'preventDefault'>,
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 => {
Expand Down