From 251d165e0ba1289294f856de2a861e6eba629e17 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 16:01:25 -0400 Subject: [PATCH] feat(web): add SearchInput header component (debounced URL sync) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fills from page.url.searchParams.get('q'); typing debounces 250ms then goto('/search?q=…'); first nav from elsewhere pushes, subsequent typing on /search replaces. Empty input on /search drops the param. Escape clears the input. URL → value re-sync via \$effect uses untrack() on the value comparison so it doesn't loop with typing. --- web/src/lib/components/SearchInput.svelte | 57 +++++++++++++ web/src/lib/components/SearchInput.test.ts | 94 ++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 web/src/lib/components/SearchInput.svelte create mode 100644 web/src/lib/components/SearchInput.test.ts diff --git a/web/src/lib/components/SearchInput.svelte b/web/src/lib/components/SearchInput.svelte new file mode 100644 index 00000000..f1755709 --- /dev/null +++ b/web/src/lib/components/SearchInput.svelte @@ -0,0 +1,57 @@ + + + diff --git a/web/src/lib/components/SearchInput.test.ts b/web/src/lib/components/SearchInput.test.ts new file mode 100644 index 00000000..84517669 --- /dev/null +++ b/web/src/lib/components/SearchInput.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; + +const state = vi.hoisted(() => ({ + pageUrl: new URL('http://localhost/') +})); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +import SearchInput from './SearchInput.svelte'; +import { goto } from '$app/navigation'; + +beforeEach(() => { + vi.useFakeTimers(); + state.pageUrl = new URL('http://localhost/'); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe('SearchInput', () => { + test('mount pre-fills value from page.url.searchParams', () => { + state.pageUrl = new URL('http://localhost/search?q=hello'); + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + expect(input.value).toBe('hello'); + }); + + test('typing fires goto once after 250ms debounce', async () => { + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'h' } }); + expect(goto).not.toHaveBeenCalled(); + vi.advanceTimersByTime(250); + expect(goto).toHaveBeenCalledTimes(1); + expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false }); + }); + + test('rapid keystrokes debounce to a single goto', async () => { + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'h' } }); + vi.advanceTimersByTime(100); + await fireEvent.input(input, { target: { value: 'he' } }); + vi.advanceTimersByTime(100); + await fireEvent.input(input, { target: { value: 'hel' } }); + vi.advanceTimersByTime(250); + expect(goto).toHaveBeenCalledTimes(1); + expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false }); + }); + + test('typing while on /search uses replaceState=true', async () => { + state.pageUrl = new URL('http://localhost/search?q=h'); + render(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 }); + }); + + test('clearing input on /search calls goto("/search") (drops ?q=)', async () => { + state.pageUrl = new URL('http://localhost/search?q=h'); + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: '' } }); + vi.advanceTimersByTime(250); + expect(goto).toHaveBeenCalledWith('/search', { replaceState: true }); + }); + + test('Escape clears the input value', async () => { + render(SearchInput); + const input = screen.getByRole('searchbox') as HTMLInputElement; + await fireEvent.input(input, { target: { value: 'hello' } }); + expect(input.value).toBe('hello'); + await fireEvent.keyDown(input, { key: 'Escape' }); + expect(input.value).toBe(''); + }); + + test('encodeURIComponent applied to special chars', async () => { + render(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 }); + }); +});