feat(#248): add createTypeahead composable with debounced fetch and selection state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 23:01:33 +02:00
parent 97fbf1e4ca
commit 83629e0c6e
2 changed files with 133 additions and 0 deletions

View File

@@ -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');
});
});

View File

@@ -0,0 +1,64 @@
type Options<T> = {
fetchUrl: (query: string) => Promise<T[]>;
onSelect?: (item: T) => void;
debounceMs?: number;
};
export function createTypeahead<T>(options: Options<T>) {
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<typeof setTimeout> | 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
};
}