diff --git a/web/src/lib/components/SearchInput.svelte b/web/src/lib/components/SearchInput.svelte index 2708d12d..174fd5e2 100644 --- a/web/src/lib/components/SearchInput.svelte +++ b/web/src/lib/components/SearchInput.svelte @@ -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() { diff --git a/web/src/lib/components/SearchInput.test.ts b/web/src/lib/components/SearchInput.test.ts index 84517669..ca1f97bb 100644 --- a/web/src/lib/components/SearchInput.test.ts +++ b/web/src/lib/components/SearchInput.test.ts @@ -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 }); }); });