Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion src/__tests__/SearchBar.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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(<SearchBar onSearch={onSearch} />);
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(<SearchBar onSearch={onSearch} debounceMs={500} />);
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(<SearchBar onSearch={onSearch} />);
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('');
});
});
11 changes: 9 additions & 2 deletions src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -35,7 +38,11 @@ export function SearchBar({
/>
{query && (
<button
onClick={() => setQuery('')}
onClick={() => {
// Clearing is a deliberate action, so reset the results immediately.
setQuery('');
onSearch('');
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400 hover:text-white transition-colors"
aria-label="Clear search"
>
Expand Down