From 7e85efc43f7ccd2e050540ba8a16433ab3b718b6 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 9 Dec 2025 15:22:44 +0100 Subject: [PATCH 1/4] Implement roving tab index for search experience --- .../SearchOrAskAi/Search/Search.tsx | 17 +- .../Search/SearchResults/SearchFilters.tsx | 191 +++++++++--------- .../Search/SearchResults/SearchResults.tsx | 17 +- .../SearchResults/SearchResultsList.tsx | 66 ++---- .../SearchResults/SearchResultsListItem.tsx | 37 +++- .../SearchOrAskAi/Search/TellMeMoreButton.tsx | 9 +- .../Search/useSearchKeyboardNavigation.ts | 98 ++++----- 7 files changed, 202 insertions(+), 233 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx index 022b631fe..a9fdecd5d 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx @@ -46,13 +46,8 @@ export const Search = () => { closeModal() } - const { - inputRef, - buttonRef, - itemRefs, - handleInputKeyDown, - focusLastAvailable, - } = useSearchKeyboardNavigation(resultsCount) + const { inputRef, buttonRef, itemRefs, filterRefs, handleInputKeyDown } = + useSearchKeyboardNavigation(resultsCount) // Listen for Cmd+K to focus input useEffect(() => { @@ -109,14 +104,11 @@ export const Search = () => { iconType="cross" color="text" onClick={handleCloseModal} + tabIndex={-1} /> - + {!showLoadingSpinner && } {searchTerm && (
{ ref={buttonRef} term={searchTerm} onAsk={askAi} - onArrowUp={focusLastAvailable} />
)} 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 7880550cf..8032a7716 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,20 +1,28 @@ -import { useTypeFilter, useSearchActions } from '../search.store' +import { TypeFilter, useTypeFilter, useSearchActions } from '../search.store' import { useEuiTheme, EuiButton, EuiSpacer } from '@elastic/eui' import { css } from '@emotion/react' -import { useRef, useCallback, MutableRefObject } from 'react' +import { useCallback, useState, MutableRefObject } from 'react' + +const FILTERS: TypeFilter[] = ['all', 'doc', 'api'] +const FILTER_LABELS: Record = { + all: 'All', + doc: 'Docs', + api: 'API', +} +const FILTER_ICONS: Record = { + all: 'globe', + doc: 'documentation', + api: 'code', +} interface SearchFiltersProps { isLoading: boolean - inputRef?: React.RefObject - itemRefs?: MutableRefObject<(HTMLAnchorElement | null)[]> - resultsCount?: number + filterRefs?: MutableRefObject<(HTMLButtonElement | null)[]> } export const SearchFilters = ({ isLoading, - inputRef, - itemRefs, - resultsCount = 0, + filterRefs, }: SearchFiltersProps) => { if (isLoading) { return null @@ -24,51 +32,65 @@ export const SearchFilters = ({ const selectedFilter = useTypeFilter() const { setTypeFilter } = useSearchActions() - const filterRefs = useRef<(HTMLButtonElement | null)[]>([]) + // Track which filter is focused for roving tabindex within the toolbar + const [focusedIndex, setFocusedIndex] = useState(() => + FILTERS.indexOf(selectedFilter) + ) - const handleFilterKeyDown = useCallback( - (e: React.KeyboardEvent, filterIndex: number) => { - const filterCount = 3 // ALL, DOCS, API + // Only the focused filter is tabbable (roving tabindex within toolbar) + const getTabIndex = (index: number): 0 | -1 => { + return index === focusedIndex ? 0 : -1 + } - 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') { + // Arrow keys navigate within the toolbar + const handleFilterKeyDown = useCallback( + (e: React.KeyboardEvent, index: number) => { + if (e.key === 'ArrowLeft' && index > 0) { e.preventDefault() - if (filterIndex > 0) { - filterRefs.current[filterIndex - 1]?.focus() - } - } else if (e.key === 'ArrowRight') { + const newIndex = index - 1 + setFocusedIndex(newIndex) + filterRefs?.current[newIndex]?.focus() + } else if (e.key === 'ArrowRight' && index < FILTERS.length - 1) { e.preventDefault() - if (filterIndex < filterCount - 1) { - filterRefs.current[filterIndex + 1]?.focus() - } + const newIndex = index + 1 + setFocusedIndex(newIndex) + filterRefs?.current[newIndex]?.focus() } + // Tab naturally exits the toolbar }, - [inputRef, itemRefs, resultsCount] + [filterRefs] ) - const buttonStyle = css` + const handleFilterClick = useCallback( + (filter: TypeFilter, index: number) => { + setTypeFilter(filter) + setFocusedIndex(index) + }, + [setTypeFilter] + ) + + const handleFilterFocus = useCallback((index: number) => { + setFocusedIndex(index) + }, []) + + const getButtonStyle = (isSelected: boolean) => css` border-radius: 99999px; padding-inline: ${euiTheme.size.s}; min-inline-size: auto; - &[aria-pressed='true'] { + ${isSelected && + ` background-color: ${euiTheme.colors.backgroundBaseHighlighted}; border-color: ${euiTheme.colors.borderStrongPrimary}; color: ${euiTheme.colors.textPrimary}; border-width: 1px; border-style: solid; + `} + ${isSelected && + ` span svg { fill: ${euiTheme.colors.textPrimary}; } - } + `} &:hover, &:hover:not(:disabled)::before { background-color: ${euiTheme.colors.backgroundBaseHighlighted}; @@ -76,19 +98,24 @@ export const SearchFilters = ({ &:focus-visible { background-color: ${euiTheme.colors.backgroundBasePlain}; } - &[aria-pressed='true']:hover, - &[aria-pressed='true']:focus-visible { + ${isSelected && + ` + &:hover, + &:focus-visible { background-color: ${euiTheme.colors.backgroundBaseHighlighted}; border-color: ${euiTheme.colors.borderStrongPrimary}; color: ${euiTheme.colors.textPrimary}; } + `} span { gap: 4px; &.eui-textTruncate { padding-inline: 4px; } svg { - fill: ${euiTheme.colors.borderBaseProminent}; + fill: ${isSelected + ? euiTheme.colors.textPrimary + : euiTheme.colors.borderBaseProminent}; } } ` @@ -101,63 +128,43 @@ export const SearchFilters = ({ gap: ${euiTheme.size.s}; padding-inline: ${euiTheme.size.base}; `} - role="group" + role="toolbar" aria-label="Search filters" > - setTypeFilter('all')} - onKeyDown={(e: React.KeyboardEvent) => - handleFilterKeyDown(e, 0) - } - buttonRef={(el: HTMLButtonElement | null) => { - filterRefs.current[0] = el - }} - css={buttonStyle} - aria-label={`Show all results`} - aria-pressed={selectedFilter === 'all'} - > - {`All`} - - 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`} - aria-pressed={selectedFilter === 'doc'} - > - {`Docs`} - - 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`} - aria-pressed={selectedFilter === 'api'} - > - {`API`} - + {FILTERS.map((filter, index) => { + const isSelected = selectedFilter === filter + return ( + handleFilterClick(filter, index)} + onFocus={() => handleFilterFocus(index)} + onKeyDown={( + e: React.KeyboardEvent + ) => handleFilterKeyDown(e, index)} + buttonRef={(el: HTMLButtonElement | null) => { + if (filterRefs) { + filterRefs.current[index] = el + } + }} + tabIndex={getTabIndex(index)} + css={getButtonStyle(isSelected)} + aria-label={ + filter === 'all' + ? 'Show all results' + : filter === 'doc' + ? 'Filter to documentation results' + : 'Filter to API results' + } + aria-pressed={isSelected} + > + {FILTER_LABELS[filter]} + + ) + })} 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 ea9e9ac01..332f8e772 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 @@ -14,16 +14,11 @@ import { useDebounce } from '@uidotdev/usehooks' import { useEffect, MutableRefObject } from 'react' interface SearchResultsProps { - inputRef?: React.RefObject - buttonRef?: React.RefObject itemRefs?: MutableRefObject<(HTMLAnchorElement | null)[]> + filterRefs?: MutableRefObject<(HTMLButtonElement | null)[]> } -export const SearchResults = ({ - inputRef, - buttonRef, - itemRefs, -}: SearchResultsProps) => { +export const SearchResults = ({ itemRefs, filterRefs }: SearchResultsProps) => { const { euiTheme } = useEuiTheme() const searchTerm = useSearchTerm() const { setPageNumber } = useSearchActions() @@ -57,9 +52,7 @@ export const SearchResults = ({ <> @@ -83,8 +76,6 @@ export const SearchResults = ({ pageSize={data.pageSize} isLoading={isInitialLoading} searchTerm={debouncedSearchTerm} - inputRef={inputRef} - buttonRef={buttonRef} itemRefs={itemRefs} /> )} @@ -96,8 +87,6 @@ export const SearchResults = ({ pageSize={10} isLoading={true} searchTerm={debouncedSearchTerm} - inputRef={inputRef} - buttonRef={buttonRef} itemRefs={itemRefs} /> )} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsList.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsList.tsx index f9ab585cd..43799b18d 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsList.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsList.tsx @@ -11,8 +11,6 @@ interface SearchResultsListProps { pageSize: number isLoading: boolean searchTerm: string - inputRef?: React.RefObject - buttonRef?: React.RefObject itemRefs?: MutableRefObject<(HTMLAnchorElement | null)[]> } @@ -22,8 +20,6 @@ export const SearchResultsList = ({ pageSize, isLoading, searchTerm, - inputRef, - buttonRef, itemRefs, }: SearchResultsListProps) => { if (isLoading) { @@ -31,7 +27,7 @@ export const SearchResultsList = ({ } const { euiTheme } = useEuiTheme() const selectedIndex = useSelectedIndex() - const { setSelectedIndex, clearSelection } = useSearchActions() + const { setSelectedIndex } = useSearchActions() const scrollContainerRef = useRef(null) const scrollbarStyle = css` @@ -52,67 +48,40 @@ export const SearchResultsList = ({ resetScrollToTop() }, [searchTerm, resetScrollToTop]) - // Scroll selected item into view when selection changes - useEffect(() => { - const selectedElement = itemRefs?.current[selectedIndex] - // scrollIntoView may not exist in test environments (JSDOM) - if ( - selectedElement && - typeof selectedElement.scrollIntoView === 'function' - ) { - selectedElement.scrollIntoView({ block: 'nearest' }) - } - }, [selectedIndex, itemRefs]) - - // Sync selectedIndex when an item receives focus (e.g., via click or tab) - const handleItemFocus = useCallback( - (index: number) => { - setSelectedIndex(index) + // Roving tabindex: only one item is tabbable + const getTabIndex = useCallback( + (index: number): 0 | -1 => { + const effectiveIndex = selectedIndex >= 0 ? selectedIndex : 0 + return index === effectiveIndex ? 0 : -1 }, - [setSelectedIndex] + [selectedIndex] ) - // Handle keyboard navigation when an item is focused + // Handle arrow keys when focus is on a result item const handleItemKeyDown = useCallback( (e: React.KeyboardEvent, currentIndex: number) => { if (e.key === 'ArrowDown') { e.preventDefault() if (currentIndex < results.length - 1) { - // Move to next item - itemRefs?.current[currentIndex + 1]?.focus() - } else { - // At last item, go to button - buttonRef?.current?.focus() + const nextIndex = currentIndex + 1 + setSelectedIndex(nextIndex) + itemRefs?.current[nextIndex]?.focus() } } else if (e.key === 'ArrowUp') { e.preventDefault() if (currentIndex > 0) { - // Move to previous item - itemRefs?.current[currentIndex - 1]?.focus() - } else { - // At first item, go back to input - inputRef?.current?.focus() + const prevIndex = currentIndex - 1 + setSelectedIndex(prevIndex) + itemRefs?.current[prevIndex]?.focus() } } }, - [results.length, inputRef, buttonRef, itemRefs] - ) - - // Clear selection when focus leaves the results list - const handleListBlur = useCallback( - (e: React.FocusEvent) => { - const newFocusTarget = e.relatedTarget as HTMLElement | null - const focusLeftList = !e.currentTarget.contains(newFocusTarget) - if (focusLeftList) { - clearSelection() - } - }, - [clearSelection] + [results.length, setSelectedIndex, itemRefs] ) return (
-