Skip to content
Closed
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
83 changes: 83 additions & 0 deletions src/features/dashboard/pages/SearchPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
38 changes: 37 additions & 1 deletion src/features/dashboard/pages/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -45,6 +54,10 @@ export function SearchPage({
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -255,6 +271,26 @@ export function SearchPage({
</div>
</div>

{/* Character counter — announced to screen readers near the limit */}
{showSearchCounter && (
<p
id="search-input-counter"
aria-live="polite"
className={`text-[13px] -mt-6 mb-8 text-right transition-colors ${
isAtSearchLimit
? darkTheme
? 'text-[#f59e0b]'
: 'text-[#d97706]'
: darkTheme
? 'text-[#b8a898]/80'
: 'text-[#6b5d4d]/80'
}`}
>
{isAtSearchLimit ? 'Character limit reached. ' : ''}
{queryLength}/{MAX_SEARCH_LENGTH}
</p>
)}

{/* Search Results */}
{searchResults.length > 0 && (
<div className="mb-12">
Expand Down