feat(web): add SearchInput header component (debounced URL sync)
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.
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
|
let value = $state(page.url.searchParams.get('q') ?? '');
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Re-sync local value when the URL changes from outside (back/forward,
|
||||||
|
// result-link clicks). Read `value` non-reactively to avoid a feedback
|
||||||
|
// loop where the user's typing would race against the URL re-read.
|
||||||
|
$effect(() => {
|
||||||
|
const fromUrl = page.url.searchParams.get('q') ?? '';
|
||||||
|
if (fromUrl !== untrack(() => value)) value = fromUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigate(next: string) {
|
||||||
|
const onSearchPage = page.url.pathname.startsWith('/search');
|
||||||
|
const trimmed = next.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
if (onSearchPage) goto('/search', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goto(`/search?q=${encodeURIComponent(trimmed)}`, { replaceState: onSearchPage });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(e: Event) {
|
||||||
|
value = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timeout = null;
|
||||||
|
navigate(value);
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
value = '';
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = null;
|
||||||
|
}
|
||||||
|
navigate('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search artists, albums, tracks…"
|
||||||
|
aria-label="Search"
|
||||||
|
{value}
|
||||||
|
oninput={onInput}
|
||||||
|
onkeydown={onKey}
|
||||||
|
class="w-full max-w-md rounded border border-border bg-surface px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
|
/>
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user