diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchFilters.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchFilters.tsx index 1819f9027..ce2454966 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchFilters.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchFilters.tsx @@ -1,6 +1,7 @@ import { useTypeFilter, useSearchActions } from '../search.store' import { useEuiTheme, EuiButton, EuiSkeletonRectangle } from '@elastic/eui' import { css } from '@emotion/react' +import { useRef, useCallback, MutableRefObject } from 'react' interface SearchFiltersProps { counts: { @@ -9,14 +10,54 @@ interface SearchFiltersProps { totalCount: number } isLoading: boolean + inputRef?: React.RefObject + itemRefs?: MutableRefObject<(HTMLAnchorElement | null)[]> + resultsCount?: number } -export const SearchFilters = ({ counts, isLoading }: SearchFiltersProps) => { +export const SearchFilters = ({ + counts, + isLoading, + inputRef, + itemRefs, + resultsCount = 0, +}: SearchFiltersProps) => { const { euiTheme } = useEuiTheme() const selectedFilter = useTypeFilter() const { setTypeFilter } = useSearchActions() const { apiResultsCount, docsResultsCount, totalCount } = counts + const filterRefs = useRef<(HTMLButtonElement | null)[]>([]) + + const handleFilterKeyDown = useCallback( + (e: React.KeyboardEvent, filterIndex: number) => { + const filterCount = 3 // ALL, DOCS, API + + if (e.key === 'ArrowUp') { + e.preventDefault() + // Go back to input + inputRef?.current?.focus() + } else if (e.key === 'ArrowDown') { + e.preventDefault() + // Go to first result if available + if (resultsCount > 0) { + itemRefs?.current[0]?.focus() + } + } else if (e.key === 'ArrowLeft') { + e.preventDefault() + if (filterIndex > 0) { + filterRefs.current[filterIndex - 1]?.focus() + } + } else if (e.key === 'ArrowRight') { + e.preventDefault() + if (filterIndex < filterCount - 1) { + filterRefs.current[filterIndex + 1]?.focus() + } + } + }, + [inputRef, itemRefs, resultsCount] + ) + const buttonStyle = css` border-radius: 99999px; padding-inline: ${euiTheme.size.m}; @@ -34,6 +75,8 @@ export const SearchFilters = ({ counts, isLoading }: SearchFiltersProps) => { gap: ${euiTheme.size.s}; padding-inline: ${euiTheme.size.base}; `} + role="group" + aria-label="Search filters" > { fill={selectedFilter === 'all'} isLoading={isLoading} onClick={() => setTypeFilter('all')} + onKeyDown={(e: React.KeyboardEvent) => + handleFilterKeyDown(e, 0) + } + buttonRef={(el: HTMLButtonElement | null) => { + filterRefs.current[0] = el + }} css={buttonStyle} aria-label={`Show all results, ${totalCount} total`} aria-pressed={selectedFilter === 'all'} @@ -66,6 +115,12 @@ export const SearchFilters = ({ counts, isLoading }: SearchFiltersProps) => { fill={selectedFilter === 'doc'} isLoading={isLoading} onClick={() => setTypeFilter('doc')} + onKeyDown={(e: React.KeyboardEvent) => + handleFilterKeyDown(e, 1) + } + buttonRef={(el: HTMLButtonElement | null) => { + filterRefs.current[1] = el + }} css={buttonStyle} aria-label={`Filter to documentation results, ${docsResultsCount} available`} aria-pressed={selectedFilter === 'doc'} @@ -85,6 +140,12 @@ export const SearchFilters = ({ counts, isLoading }: SearchFiltersProps) => { fill={selectedFilter === 'api'} isLoading={isLoading} onClick={() => setTypeFilter('api')} + onKeyDown={(e: React.KeyboardEvent) => + handleFilterKeyDown(e, 2) + } + buttonRef={(el: HTMLButtonElement | null) => { + filterRefs.current[2] = el + }} css={buttonStyle} aria-label={`Filter to API results, ${apiResultsCount} available`} aria-pressed={selectedFilter === 'api'} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx index ee83ee29f..ad657a86d 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx @@ -65,6 +65,9 @@ export const SearchResults = ({ diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchKeyboardNavigation.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchKeyboardNavigation.ts index 69172a849..0b72f1e62 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchKeyboardNavigation.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchKeyboardNavigation.ts @@ -27,6 +27,17 @@ export const useSearchKeyboardNavigation = ( } } + const focusNextItem = () => { + if (resultsCount > 1) { + // First item is already visually selected, so go to second item + const targetIndex = Math.min(selectedIndex + 1, resultsCount - 1) + itemRefs.current[targetIndex]?.focus() + } else { + // Only 1 or 0 results, go to button + buttonRef.current?.focus() + } + } + const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() @@ -36,19 +47,12 @@ export const useSearchKeyboardNavigation = ( } else { askAi() } - } else if (e.key === 'ArrowDown') { + } else if ( + e.key === 'ArrowDown' || + (e.key === 'Tab' && !e.shiftKey && selectedIndex !== NO_SELECTION) + ) { e.preventDefault() - if (resultsCount > 1) { - // First item is already visually selected, so go to second item - const targetIndex = Math.min( - selectedIndex + 1, - resultsCount - 1 - ) - itemRefs.current[targetIndex]?.focus() - } else { - // Only 1 or 0 results, go to button - buttonRef.current?.focus() - } + focusNextItem() } else if ( e.key === 'Tab' && e.shiftKey &&