From 54eb1f113493be9f7d4d2b9f2d6c73cd14559c2b Mon Sep 17 00:00:00 2001 From: juan Date: Sat, 27 Jun 2026 18:11:13 -0500 Subject: [PATCH] feat(forms): bound SearchPage query length with accessible feedback (#271) The dashboard search input had no length bound or feedback, so very long pasted queries were silently debounced and sent to search. - Add maxLength={100} to the search input. - Show an aria-live character counter as the query approaches the limit, with a "Character limit reached" message at the cap. - Trim leading/trailing whitespace before searching (debounce unchanged). - Extend SearchPage tests: maxLength attribute, counter near/at the limit, trimmed query matching, and whitespace-only handling. Closes #271 --- .../dashboard/pages/SearchPage.test.tsx | 83 +++++++++++++++++++ src/features/dashboard/pages/SearchPage.tsx | 38 ++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/features/dashboard/pages/SearchPage.test.tsx b/src/features/dashboard/pages/SearchPage.test.tsx index 125d83e..c31b25e 100644 --- a/src/features/dashboard/pages/SearchPage.test.tsx +++ b/src/features/dashboard/pages/SearchPage.test.tsx @@ -245,4 +245,87 @@ describe('SearchPage Accessibility and Functionality', () => { fireEvent.click(backBtn) expect(mockOnBack).toHaveBeenCalledTimes(1) }) + + it('should enforce a maxLength on the search input', () => { + renderSearchPage() + + const searchInput = screen.getByRole('textbox', { + name: 'Search issues, projects, and contributors', + }) + expect(searchInput).toHaveAttribute('maxlength', '100') + expect(searchInput).toHaveAttribute('aria-describedby', 'search-input-counter') + }) + + it('should not show the character counter for short queries', () => { + const { container } = renderSearchPage() + + const searchInput = screen.getByRole('textbox', { + name: 'Search issues, projects, and contributors', + }) + fireEvent.change(searchInput, { target: { value: 'React' } }) + + expect(container.querySelector('#search-input-counter')).not.toBeInTheDocument() + }) + + it('should show an accessible counter when approaching the limit', () => { + const { container } = renderSearchPage() + + const searchInput = screen.getByRole('textbox', { + name: 'Search issues, projects, and contributors', + }) + fireEvent.change(searchInput, { target: { value: 'a'.repeat(85) } }) + + const counter = container.querySelector('#search-input-counter') + expect(counter).toBeInTheDocument() + expect(counter).toHaveAttribute('aria-live', 'polite') + expect(counter).toHaveTextContent('85/100') + expect(counter).not.toHaveTextContent('Character limit reached') + }) + + it('should announce when the character limit is reached', () => { + const { container } = renderSearchPage() + + const searchInput = screen.getByRole('textbox', { + name: 'Search issues, projects, and contributors', + }) + fireEvent.change(searchInput, { target: { value: 'a'.repeat(100) } }) + + const counter = container.querySelector('#search-input-counter') + expect(counter).toBeInTheDocument() + expect(counter).toHaveTextContent('Character limit reached') + expect(counter).toHaveTextContent('100/100') + }) + + it('should trim leading/trailing whitespace before searching', () => { + renderSearchPage() + + const searchInput = screen.getByRole('textbox', { + name: 'Search issues, projects, and contributors', + }) + // Padded query should still match the same results as the trimmed term. + fireEvent.change(searchInput, { target: { value: ' React ' } }) + + act(() => { + vi.advanceTimersByTime(300) + }) + + const heading = screen.getByRole('heading', { name: /Search Results/i }) + expect(heading).toHaveTextContent('Search Results (3)') + expect(screen.getByText('Add dark mode support')).toBeInTheDocument() + }) + + it('should treat a whitespace-only query as empty (no results list)', () => { + renderSearchPage() + + const searchInput = screen.getByRole('textbox', { + name: 'Search issues, projects, and contributors', + }) + fireEvent.change(searchInput, { target: { value: ' ' } }) + + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(screen.queryByRole('heading', { name: /Search Results/i })).not.toBeInTheDocument() + }) }) diff --git a/src/features/dashboard/pages/SearchPage.tsx b/src/features/dashboard/pages/SearchPage.tsx index 146548d..02fc563 100644 --- a/src/features/dashboard/pages/SearchPage.tsx +++ b/src/features/dashboard/pages/SearchPage.tsx @@ -3,6 +3,15 @@ import { Search, ArrowRight, X, FileText, FolderGit2, User, ChevronLeft } from ' import { useTheme } from '../../../shared/contexts/ThemeContext' import { useDebouncedValue } from '../../../shared/hooks/useDebouncedValue' +/** Maximum number of characters allowed in the search query. */ +const MAX_SEARCH_LENGTH = 100 + +/** + * Show the character counter once the query length is within this many + * characters of {@link MAX_SEARCH_LENGTH}, so users get advance notice. + */ +const SEARCH_COUNTER_THRESHOLD = MAX_SEARCH_LENGTH - 20 + /** * Props for the {@link SearchPage} component. */ @@ -45,6 +54,10 @@ export function SearchPage({ const [searchResults, setSearchResults] = useState([]) const darkTheme = theme === 'dark' + const queryLength = searchQuery.length + const isAtSearchLimit = queryLength >= MAX_SEARCH_LENGTH + const showSearchCounter = queryLength >= SEARCH_COUNTER_THRESHOLD + // Debounce the query so filtering only runs once the user pauses typing. const debouncedQuery = useDebouncedValue(searchQuery, 300) @@ -88,7 +101,8 @@ export function SearchPage({ return } - const query = debouncedQuery.toLowerCase() + // Trim leading/trailing whitespace so padded queries match cleanly. + const query = debouncedQuery.trim().toLowerCase() const results: SearchResult[] = [] // Search issues @@ -220,6 +234,8 @@ export function SearchPage({ value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search issues, projects, contributors..." + maxLength={MAX_SEARCH_LENGTH} + aria-describedby="search-input-counter" autoFocus className={`flex-1 bg-transparent outline-none text-[16px] transition-colors ${ darkTheme @@ -255,6 +271,26 @@ export function SearchPage({ + {/* Character counter — announced to screen readers near the limit */} + {showSearchCounter && ( +

+ {isAtSearchLimit ? 'Character limit reached. ' : ''} + {queryLength}/{MAX_SEARCH_LENGTH} +

+ )} + {/* Search Results */} {searchResults.length > 0 && (