From 83629e0c6e49a9b6c7963e02dfc0e1f34312896b Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 23:01:33 +0200 Subject: [PATCH] feat(#248): add createTypeahead composable with debounced fetch and selection state Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/useTypeahead.svelte.test.ts | 69 +++++++++++++++++++ frontend/src/lib/hooks/useTypeahead.svelte.ts | 64 +++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 frontend/src/lib/hooks/__tests__/useTypeahead.svelte.test.ts create mode 100644 frontend/src/lib/hooks/useTypeahead.svelte.ts diff --git a/frontend/src/lib/hooks/__tests__/useTypeahead.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useTypeahead.svelte.test.ts new file mode 100644 index 00000000..bb053c01 --- /dev/null +++ b/frontend/src/lib/hooks/__tests__/useTypeahead.svelte.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { createTypeahead } = await import('../useTypeahead.svelte'); + +describe('createTypeahead', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts with empty query and closed dropdown', () => { + const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) }); + expect(ta.query).toBe(''); + expect(ta.isOpen).toBe(false); + expect(ta.results).toEqual([]); + expect(ta.loading).toBe(false); + }); + + it('setQuery updates query and opens dropdown', async () => { + const fetchUrl = vi.fn().mockResolvedValue([{ id: '1', name: 'Foo' }]); + const ta = createTypeahead({ fetchUrl }); + ta.setQuery('foo'); + expect(ta.query).toBe('foo'); + expect(ta.isOpen).toBe(true); + }); + + it('setQuery triggers debounced fetch and populates results', async () => { + const fetchUrl = vi.fn().mockResolvedValue([{ id: '1', name: 'Foo' }]); + const ta = createTypeahead({ fetchUrl, debounceMs: 300 }); + ta.setQuery('foo'); + expect(fetchUrl).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(300); + expect(fetchUrl).toHaveBeenCalledWith('foo'); + expect(ta.results).toEqual([{ id: '1', name: 'Foo' }]); + }); + + it('close() resets isOpen', () => { + const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) }); + ta.setQuery('foo'); + expect(ta.isOpen).toBe(true); + ta.close(); + expect(ta.isOpen).toBe(false); + }); + + it('select(item) calls onSelect and closes dropdown', () => { + const onSelect = vi.fn(); + const ta = createTypeahead({ + fetchUrl: vi.fn().mockResolvedValue([]), + onSelect + }); + ta.setQuery('foo'); + ta.select({ id: '1', name: 'Foo' }); + expect(onSelect).toHaveBeenCalledWith({ id: '1', name: 'Foo' }); + expect(ta.isOpen).toBe(false); + }); + + it('debounce coalesces rapid setQuery calls', async () => { + const fetchUrl = vi.fn().mockResolvedValue([]); + const ta = createTypeahead({ fetchUrl, debounceMs: 300 }); + ta.setQuery('f'); + ta.setQuery('fo'); + ta.setQuery('foo'); + await vi.advanceTimersByTimeAsync(300); + expect(fetchUrl).toHaveBeenCalledTimes(1); + expect(fetchUrl).toHaveBeenCalledWith('foo'); + }); +}); diff --git a/frontend/src/lib/hooks/useTypeahead.svelte.ts b/frontend/src/lib/hooks/useTypeahead.svelte.ts new file mode 100644 index 00000000..c51da957 --- /dev/null +++ b/frontend/src/lib/hooks/useTypeahead.svelte.ts @@ -0,0 +1,64 @@ +type Options = { + fetchUrl: (query: string) => Promise; + onSelect?: (item: T) => void; + debounceMs?: number; +}; + +export function createTypeahead(options: Options) { + const { fetchUrl, onSelect, debounceMs = 300 } = options; + + let query = $state(''); + let results: T[] = $state([]); + let isOpen = $state(false); + let loading = $state(false); + let activeIndex = $state(-1); + + let debounceTimer: ReturnType | undefined; + + function setQuery(q: string) { + query = q; + isOpen = true; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(async () => { + loading = true; + try { + results = await fetchUrl(q); + } catch { + results = []; + } finally { + loading = false; + } + }, debounceMs); + } + + function close() { + isOpen = false; + activeIndex = -1; + } + + function select(item: T) { + onSelect?.(item); + close(); + } + + return { + get query() { + return query; + }, + get results() { + return results; + }, + get isOpen() { + return isOpen; + }, + get loading() { + return loading; + }, + get activeIndex() { + return activeIndex; + }, + setQuery, + close, + select + }; +}