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:
69
frontend/src/lib/hooks/__tests__/useTypeahead.svelte.test.ts
Normal file
69
frontend/src/lib/hooks/__tests__/useTypeahead.svelte.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
64
frontend/src/lib/hooks/useTypeahead.svelte.ts
Normal file
64
frontend/src/lib/hooks/useTypeahead.svelte.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user