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 && (