Skip to content

Commit f6849cd

Browse files
authored
Optimize keyboard navigation in search (#2314)
* Optimize keyboard navigation in search * Handle tabs better
1 parent 06ef559 commit f6849cd

File tree

3 files changed

+81
-13
lines changed

3 files changed

+81
-13
lines changed

src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchFilters.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useTypeFilter, useSearchActions } from '../search.store'
22
import { useEuiTheme, EuiButton, EuiSkeletonRectangle } from '@elastic/eui'
33
import { css } from '@emotion/react'
4+
import { useRef, useCallback, MutableRefObject } from 'react'
45

56
interface SearchFiltersProps {
67
counts: {
@@ -9,14 +10,54 @@ interface SearchFiltersProps {
910
totalCount: number
1011
}
1112
isLoading: boolean
13+
inputRef?: React.RefObject<HTMLInputElement>
14+
itemRefs?: MutableRefObject<(HTMLAnchorElement | null)[]>
15+
resultsCount?: number
1216
}
1317

14-
export const SearchFilters = ({ counts, isLoading }: SearchFiltersProps) => {
18+
export const SearchFilters = ({
19+
counts,
20+
isLoading,
21+
inputRef,
22+
itemRefs,
23+
resultsCount = 0,
24+
}: SearchFiltersProps) => {
1525
const { euiTheme } = useEuiTheme()
1626
const selectedFilter = useTypeFilter()
1727
const { setTypeFilter } = useSearchActions()
1828
const { apiResultsCount, docsResultsCount, totalCount } = counts
1929

30+
const filterRefs = useRef<(HTMLButtonElement | null)[]>([])
31+
32+
const handleFilterKeyDown = useCallback(
33+
(e: React.KeyboardEvent<HTMLButtonElement>, filterIndex: number) => {
34+
const filterCount = 3 // ALL, DOCS, API
35+
36+
if (e.key === 'ArrowUp') {
37+
e.preventDefault()
38+
// Go back to input
39+
inputRef?.current?.focus()
40+
} else if (e.key === 'ArrowDown') {
41+
e.preventDefault()
42+
// Go to first result if available
43+
if (resultsCount > 0) {
44+
itemRefs?.current[0]?.focus()
45+
}
46+
} else if (e.key === 'ArrowLeft') {
47+
e.preventDefault()
48+
if (filterIndex > 0) {
49+
filterRefs.current[filterIndex - 1]?.focus()
50+
}
51+
} else if (e.key === 'ArrowRight') {
52+
e.preventDefault()
53+
if (filterIndex < filterCount - 1) {
54+
filterRefs.current[filterIndex + 1]?.focus()
55+
}
56+
}
57+
},
58+
[inputRef, itemRefs, resultsCount]
59+
)
60+
2061
const buttonStyle = css`
2162
border-radius: 99999px;
2263
padding-inline: ${euiTheme.size.m};
@@ -34,6 +75,8 @@ export const SearchFilters = ({ counts, isLoading }: SearchFiltersProps) => {
3475
gap: ${euiTheme.size.s};
3576
padding-inline: ${euiTheme.size.base};
3677
`}
78+
role="group"
79+
aria-label="Search filters"
3780
>
3881
<EuiSkeletonRectangle
3982
isLoading={isLoading}
@@ -49,6 +92,12 @@ export const SearchFilters = ({ counts, isLoading }: SearchFiltersProps) => {
4992
fill={selectedFilter === 'all'}
5093
isLoading={isLoading}
5194
onClick={() => setTypeFilter('all')}
95+
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) =>
96+
handleFilterKeyDown(e, 0)
97+
}
98+
buttonRef={(el: HTMLButtonElement | null) => {
99+
filterRefs.current[0] = el
100+
}}
52101
css={buttonStyle}
53102
aria-label={`Show all results, ${totalCount} total`}
54103
aria-pressed={selectedFilter === 'all'}
@@ -70,6 +119,12 @@ export const SearchFilters = ({ counts, isLoading }: SearchFiltersProps) => {
70119
fill={selectedFilter === 'doc'}
71120
isLoading={isLoading}
72121
onClick={() => setTypeFilter('doc')}
122+
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) =>
123+
handleFilterKeyDown(e, 1)
124+
}
125+
buttonRef={(el: HTMLButtonElement | null) => {
126+
filterRefs.current[1] = el
127+
}}
73128
css={buttonStyle}
74129
aria-label={`Filter to documentation results, ${docsResultsCount} available`}
75130
aria-pressed={selectedFilter === 'doc'}
@@ -91,6 +146,12 @@ export const SearchFilters = ({ counts, isLoading }: SearchFiltersProps) => {
91146
fill={selectedFilter === 'api'}
92147
isLoading={isLoading}
93148
onClick={() => setTypeFilter('api')}
149+
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) =>
150+
handleFilterKeyDown(e, 2)
151+
}
152+
buttonRef={(el: HTMLButtonElement | null) => {
153+
filterRefs.current[2] = el
154+
}}
94155
css={buttonStyle}
95156
aria-label={`Filter to API results, ${apiResultsCount} available`}
96157
aria-pressed={selectedFilter === 'api'}

src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ export const SearchResults = ({
6565
<SearchFilters
6666
counts={counts}
6767
isLoading={isInitialLoading}
68+
inputRef={inputRef}
69+
itemRefs={itemRefs}
70+
resultsCount={results.length}
6871
/>
6972

7073
<EuiSpacer size="m" />

src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchKeyboardNavigation.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ export const useSearchKeyboardNavigation = (
2727
}
2828
}
2929

30+
const focusNextItem = () => {
31+
if (resultsCount > 1) {
32+
// First item is already visually selected, so go to second item
33+
const targetIndex = Math.min(selectedIndex + 1, resultsCount - 1)
34+
itemRefs.current[targetIndex]?.focus()
35+
} else {
36+
// Only 1 or 0 results, go to button
37+
buttonRef.current?.focus()
38+
}
39+
}
40+
3041
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
3142
if (e.key === 'Enter') {
3243
e.preventDefault()
@@ -36,19 +47,12 @@ export const useSearchKeyboardNavigation = (
3647
} else {
3748
askAi()
3849
}
39-
} else if (e.key === 'ArrowDown') {
50+
} else if (
51+
e.key === 'ArrowDown' ||
52+
(e.key === 'Tab' && !e.shiftKey && selectedIndex !== NO_SELECTION)
53+
) {
4054
e.preventDefault()
41-
if (resultsCount > 1) {
42-
// First item is already visually selected, so go to second item
43-
const targetIndex = Math.min(
44-
selectedIndex + 1,
45-
resultsCount - 1
46-
)
47-
itemRefs.current[targetIndex]?.focus()
48-
} else {
49-
// Only 1 or 0 results, go to button
50-
buttonRef.current?.focus()
51-
}
55+
focusNextItem()
5256
} else if (
5357
e.key === 'Tab' &&
5458
e.shiftKey &&

0 commit comments

Comments
 (0)