fix(web): SearchInput passes keepFocus to goto() to prevent blur on debounce

The actual cause of the search-field focus loss: SvelteKit's `goto()`
defaults to resetting focus on navigation, so each debounced URL update
blurred the input mid-typing. Pass `keepFocus: true` to both `goto()`
calls in `navigate()`.

The earlier `bind:value` change in 6931358 didn't address this — it was
solving a hypothetical DOM-write-side cursor-jump issue rather than the
real focus-reset behavior. Leaving `bind:value` in place since it's
still cleaner Svelte 5.

Tests updated to match the new goto args.
This commit is contained in:
2026-04-27 23:37:21 -04:00
parent 693135896e
commit b7a59a9473
2 changed files with 13 additions and 7 deletions
+8 -2
View File
@@ -17,11 +17,17 @@
function navigate(next: string) {
const onSearchPage = page.url.pathname.startsWith('/search');
const trimmed = next.trim();
// keepFocus: SvelteKit blurs the active element on navigation by default.
// For a typeahead-style URL update we want the user to keep typing
// without re-clicking the input every 250 ms.
if (trimmed === '') {
if (onSearchPage) goto('/search', { replaceState: true });
if (onSearchPage) goto('/search', { replaceState: true, keepFocus: true });
return;
}
goto(`/search?q=${encodeURIComponent(trimmed)}`, { replaceState: onSearchPage });
goto(`/search?q=${encodeURIComponent(trimmed)}`, {
replaceState: onSearchPage,
keepFocus: true,
});
}
function onInput() {
+5 -5
View File
@@ -41,7 +41,7 @@ describe('SearchInput', () => {
expect(goto).not.toHaveBeenCalled();
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false });
expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false, keepFocus: true });
});
test('rapid keystrokes debounce to a single goto', async () => {
@@ -54,7 +54,7 @@ describe('SearchInput', () => {
await fireEvent.input(input, { target: { value: 'hel' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false });
expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false, keepFocus: true });
});
test('typing while on /search uses replaceState=true', async () => {
@@ -63,7 +63,7 @@ describe('SearchInput', () => {
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'he' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true });
expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true, keepFocus: true });
});
test('clearing input on /search calls goto("/search") (drops ?q=)', async () => {
@@ -72,7 +72,7 @@ describe('SearchInput', () => {
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: '' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search', { replaceState: true });
expect(goto).toHaveBeenCalledWith('/search', { replaceState: true, keepFocus: true });
});
test('Escape clears the input value', async () => {
@@ -89,6 +89,6 @@ describe('SearchInput', () => {
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'a b&c' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false });
expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false, keepFocus: true });
});
});