diff --git a/src/__tests__/SearchBar.test.tsx b/src/__tests__/SearchBar.test.tsx index e605302..5e2d686 100644 --- a/src/__tests__/SearchBar.test.tsx +++ b/src/__tests__/SearchBar.test.tsx @@ -1,5 +1,6 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { renderToStaticMarkup } from 'react-dom/server'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { SearchBar } from '../components/SearchBar'; describe('SearchBar', () => { @@ -34,3 +35,64 @@ describe('SearchBar', () => { expect(html).not.toContain('Clear search'); }); }); + +describe('SearchBar debouncing', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('calls onSearch once with the final query after typing stops', () => { + vi.useFakeTimers(); + const onSearch = vi.fn(); + render(); + const input = screen.getByRole('textbox'); + onSearch.mockClear(); // ignore the initial empty-query call on mount + + for (const value of ['f', 'fl', 'flo', 'floo', 'flood']) { + fireEvent.change(input, { target: { value } }); + act(() => { + vi.advanceTimersByTime(100); + }); + } + expect(onSearch).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(250); + }); + expect(onSearch).toHaveBeenCalledTimes(1); + expect(onSearch).toHaveBeenCalledWith('flood'); + }); + + it('respects a custom debounceMs', () => { + vi.useFakeTimers(); + const onSearch = vi.fn(); + render(); + onSearch.mockClear(); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'crop' } }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(onSearch).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(200); + }); + expect(onSearch).toHaveBeenCalledWith('crop'); + }); + + it('clears results immediately when the clear button is pressed', () => { + vi.useFakeTimers(); + const onSearch = vi.fn(); + render(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'rain' } }); + act(() => { + vi.advanceTimersByTime(250); + }); + onSearch.mockClear(); + + fireEvent.click(screen.getByLabelText('Clear search')); + expect(onSearch).toHaveBeenCalledWith(''); + expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe(''); + }); +}); diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 750c9c2..a40ce4c 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -7,15 +7,18 @@ interface SearchBarProps { onSearch: (query: string) => void; placeholder?: string; className?: string; + /** Delay before `onSearch` fires after the user stops typing. */ + debounceMs?: number; } export function SearchBar({ onSearch, placeholder = 'Search…', className, + debounceMs = 250, }: SearchBarProps) { const [query, setQuery] = useState(''); - const debouncedQuery = useDebounce(query, 250); + const debouncedQuery = useDebounce(query, debounceMs); useEffect(() => { onSearch(debouncedQuery); @@ -35,7 +38,11 @@ export function SearchBar({ /> {query && (