Skip to content

Commit ee9dc32

Browse files
authored
Implement roving tab index for search experience (#2335)
* Implement roving tab index for search experience * Fix tests * Only show a single selected/active state in search result list * Fix spacing
1 parent aea56e9 commit ee9dc32

File tree

9 files changed

+231
-248
lines changed

9 files changed

+231
-248
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const InfoBanner = () => {
2222
`}
2323
>
2424
<EuiBetaBadge
25+
tabIndex={-1}
2526
css={css`
2627
display: inherit;
2728
`}

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

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ describe('Search Component', () => {
318318
expect(searchStore.getState().selectedIndex).toBe(0)
319319
})
320320

321-
it('should move focus to second result on ArrowDown from input (first is already visually selected)', async () => {
321+
it('should move selection to second result on ArrowDown from input (focus stays on input)', async () => {
322322
// Arrange
323323
const user = userEvent.setup()
324324

@@ -337,9 +337,9 @@ describe('Search Component', () => {
337337

338338
await user.keyboard('{ArrowDown}')
339339

340-
// Assert - focus moved to second result (first is already visually selected)
341-
const secondResult = screen.getByText('Test Result 2').closest('a')
342-
expect(secondResult).toHaveFocus()
340+
// Assert - selection moved to second result, focus stays on input (Pattern B)
341+
expect(searchStore.getState().selectedIndex).toBe(1)
342+
expect(input).toHaveFocus()
343343
})
344344

345345
it('should move focus between results with ArrowDown/ArrowUp', async () => {
@@ -372,7 +372,7 @@ describe('Search Component', () => {
372372
expect(searchStore.getState().selectedIndex).toBe(0)
373373
})
374374

375-
it('should clear selection when ArrowUp from first item goes to input', async () => {
375+
it('should stay at first item when ArrowUp from first item (no wrap)', async () => {
376376
// Arrange
377377
const user = userEvent.setup()
378378

@@ -394,12 +394,12 @@ describe('Search Component', () => {
394394

395395
await user.keyboard('{ArrowUp}')
396396

397-
// Assert - focus goes to input, selection is cleared
398-
expect(input).toHaveFocus()
399-
expect(searchStore.getState().selectedIndex).toBe(NO_SELECTION)
397+
// Assert - stays at first item (no wrap around)
398+
expect(firstResult).toHaveFocus()
399+
expect(searchStore.getState().selectedIndex).toBe(0)
400400
})
401401

402-
it('should clear selection when ArrowDown from last item goes to button', async () => {
402+
it('should stay at last item when ArrowDown from last item (no wrap)', async () => {
403403
// Arrange
404404
const user = userEvent.setup()
405405

@@ -422,12 +422,9 @@ describe('Search Component', () => {
422422
// Try to go down from last item
423423
await user.keyboard('{ArrowDown}')
424424

425-
// Assert - focus moves to button, selection is cleared
426-
const button = screen.getByRole('button', {
427-
name: /tell me more about/i,
428-
})
429-
expect(button).toHaveFocus()
430-
expect(searchStore.getState().selectedIndex).toBe(NO_SELECTION)
425+
// Assert - stays at last item (no wrap around)
426+
expect(lastResult).toHaveFocus()
427+
expect(searchStore.getState().selectedIndex).toBe(2)
431428
})
432429

433430
it('should render isSelected prop on the selected item', async () => {

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

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,8 @@ export const Search = () => {
4646
closeModal()
4747
}
4848

49-
const {
50-
inputRef,
51-
buttonRef,
52-
itemRefs,
53-
handleInputKeyDown,
54-
focusLastAvailable,
55-
} = useSearchKeyboardNavigation(resultsCount)
49+
const { inputRef, buttonRef, itemRefs, filterRefs, handleInputKeyDown } =
50+
useSearchKeyboardNavigation(resultsCount)
5651

5752
// Listen for Cmd+K to focus input
5853
useEffect(() => {
@@ -109,14 +104,11 @@ export const Search = () => {
109104
iconType="cross"
110105
color="text"
111106
onClick={handleCloseModal}
107+
tabIndex={-1}
112108
/>
113109
</div>
114110

115-
<SearchResults
116-
inputRef={inputRef}
117-
buttonRef={buttonRef}
118-
itemRefs={itemRefs}
119-
/>
111+
<SearchResults itemRefs={itemRefs} filterRefs={filterRefs} />
120112
{!showLoadingSpinner && <EuiHorizontalRule margin="none" />}
121113
{searchTerm && (
122114
<div
@@ -139,7 +131,6 @@ export const Search = () => {
139131
ref={buttonRef}
140132
term={searchTerm}
141133
onAsk={askAi}
142-
onArrowUp={focusLastAvailable}
143134
/>
144135
</div>
145136
)}

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

Lines changed: 99 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
1-
import { useTypeFilter, useSearchActions } from '../search.store'
1+
import { TypeFilter, useTypeFilter, useSearchActions } from '../search.store'
22
import { useEuiTheme, EuiButton, EuiSpacer } from '@elastic/eui'
33
import { css } from '@emotion/react'
4-
import { useRef, useCallback, MutableRefObject } from 'react'
4+
import { useCallback, useState, MutableRefObject } from 'react'
5+
6+
const FILTERS: TypeFilter[] = ['all', 'doc', 'api']
7+
const FILTER_LABELS: Record<TypeFilter, string> = {
8+
all: 'All',
9+
doc: 'Docs',
10+
api: 'API',
11+
}
12+
const FILTER_ICONS: Record<TypeFilter, string> = {
13+
all: 'globe',
14+
doc: 'documentation',
15+
api: 'code',
16+
}
517

618
interface SearchFiltersProps {
719
isLoading: boolean
8-
inputRef?: React.RefObject<HTMLInputElement>
9-
itemRefs?: MutableRefObject<(HTMLAnchorElement | null)[]>
10-
resultsCount?: number
20+
filterRefs?: MutableRefObject<(HTMLButtonElement | null)[]>
1121
}
1222

1323
export const SearchFilters = ({
1424
isLoading,
15-
inputRef,
16-
itemRefs,
17-
resultsCount = 0,
25+
filterRefs,
1826
}: SearchFiltersProps) => {
1927
if (isLoading) {
2028
return null
@@ -24,71 +32,90 @@ export const SearchFilters = ({
2432
const selectedFilter = useTypeFilter()
2533
const { setTypeFilter } = useSearchActions()
2634

27-
const filterRefs = useRef<(HTMLButtonElement | null)[]>([])
35+
// Track which filter is focused for roving tabindex within the toolbar
36+
const [focusedIndex, setFocusedIndex] = useState(() =>
37+
FILTERS.indexOf(selectedFilter)
38+
)
2839

29-
const handleFilterKeyDown = useCallback(
30-
(e: React.KeyboardEvent<HTMLButtonElement>, filterIndex: number) => {
31-
const filterCount = 3 // ALL, DOCS, API
40+
// Only the focused filter is tabbable (roving tabindex within toolbar)
41+
const getTabIndex = (index: number): 0 | -1 => {
42+
return index === focusedIndex ? 0 : -1
43+
}
3244

33-
if (e.key === 'ArrowUp') {
34-
e.preventDefault()
35-
// Go back to input
36-
inputRef?.current?.focus()
37-
} else if (e.key === 'ArrowDown') {
38-
e.preventDefault()
39-
// Go to first result if available
40-
if (resultsCount > 0) {
41-
itemRefs?.current[0]?.focus()
42-
}
43-
} else if (e.key === 'ArrowLeft') {
45+
// Arrow keys navigate within the toolbar
46+
const handleFilterKeyDown = useCallback(
47+
(e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
48+
if (e.key === 'ArrowLeft' && index > 0) {
4449
e.preventDefault()
45-
if (filterIndex > 0) {
46-
filterRefs.current[filterIndex - 1]?.focus()
47-
}
48-
} else if (e.key === 'ArrowRight') {
50+
const newIndex = index - 1
51+
setFocusedIndex(newIndex)
52+
filterRefs?.current[newIndex]?.focus()
53+
} else if (e.key === 'ArrowRight' && index < FILTERS.length - 1) {
4954
e.preventDefault()
50-
if (filterIndex < filterCount - 1) {
51-
filterRefs.current[filterIndex + 1]?.focus()
52-
}
55+
const newIndex = index + 1
56+
setFocusedIndex(newIndex)
57+
filterRefs?.current[newIndex]?.focus()
5358
}
59+
// Tab naturally exits the toolbar
5460
},
55-
[inputRef, itemRefs, resultsCount]
61+
[filterRefs]
5662
)
5763

58-
const buttonStyle = css`
64+
const handleFilterClick = useCallback(
65+
(filter: TypeFilter, index: number) => {
66+
setTypeFilter(filter)
67+
setFocusedIndex(index)
68+
},
69+
[setTypeFilter]
70+
)
71+
72+
const handleFilterFocus = useCallback((index: number) => {
73+
setFocusedIndex(index)
74+
}, [])
75+
76+
const getButtonStyle = (isSelected: boolean) => css`
5977
border-radius: 99999px;
6078
padding-inline: ${euiTheme.size.s};
6179
min-inline-size: auto;
62-
&[aria-pressed='true'] {
80+
${isSelected &&
81+
`
6382
background-color: ${euiTheme.colors.backgroundBaseHighlighted};
6483
border-color: ${euiTheme.colors.borderStrongPrimary};
6584
color: ${euiTheme.colors.textPrimary};
6685
border-width: 1px;
6786
border-style: solid;
87+
`}
88+
${isSelected &&
89+
`
6890
span svg {
6991
fill: ${euiTheme.colors.textPrimary};
7092
}
71-
}
93+
`}
7294
&:hover,
7395
&:hover:not(:disabled)::before {
7496
background-color: ${euiTheme.colors.backgroundBaseHighlighted};
7597
}
7698
&:focus-visible {
7799
background-color: ${euiTheme.colors.backgroundBasePlain};
78100
}
79-
&[aria-pressed='true']:hover,
80-
&[aria-pressed='true']:focus-visible {
101+
${isSelected &&
102+
`
103+
&:hover,
104+
&:focus-visible {
81105
background-color: ${euiTheme.colors.backgroundBaseHighlighted};
82106
border-color: ${euiTheme.colors.borderStrongPrimary};
83107
color: ${euiTheme.colors.textPrimary};
84108
}
109+
`}
85110
span {
86111
gap: 4px;
87112
&.eui-textTruncate {
88113
padding-inline: 4px;
89114
}
90115
svg {
91-
fill: ${euiTheme.colors.borderBaseProminent};
116+
fill: ${isSelected
117+
? euiTheme.colors.textPrimary
118+
: euiTheme.colors.borderBaseProminent};
92119
}
93120
}
94121
`
@@ -101,63 +128,43 @@ export const SearchFilters = ({
101128
gap: ${euiTheme.size.s};
102129
padding-inline: ${euiTheme.size.base};
103130
`}
104-
role="group"
131+
role="toolbar"
105132
aria-label="Search filters"
106133
>
107-
<EuiButton
108-
color="text"
109-
iconType="globe"
110-
iconSize="m"
111-
size="s"
112-
onClick={() => setTypeFilter('all')}
113-
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) =>
114-
handleFilterKeyDown(e, 0)
115-
}
116-
buttonRef={(el: HTMLButtonElement | null) => {
117-
filterRefs.current[0] = el
118-
}}
119-
css={buttonStyle}
120-
aria-label={`Show all results`}
121-
aria-pressed={selectedFilter === 'all'}
122-
>
123-
{`All`}
124-
</EuiButton>
125-
<EuiButton
126-
color="text"
127-
iconType="documentation"
128-
iconSize="m"
129-
size="s"
130-
onClick={() => setTypeFilter('doc')}
131-
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) =>
132-
handleFilterKeyDown(e, 1)
133-
}
134-
buttonRef={(el: HTMLButtonElement | null) => {
135-
filterRefs.current[1] = el
136-
}}
137-
css={buttonStyle}
138-
aria-label={`Filter to documentation results`}
139-
aria-pressed={selectedFilter === 'doc'}
140-
>
141-
{`Docs`}
142-
</EuiButton>
143-
<EuiButton
144-
color="text"
145-
iconType="code"
146-
iconSize="s"
147-
size="s"
148-
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-
}}
155-
css={buttonStyle}
156-
aria-label={`Filter to API results`}
157-
aria-pressed={selectedFilter === 'api'}
158-
>
159-
{`API`}
160-
</EuiButton>
134+
{FILTERS.map((filter, index) => {
135+
const isSelected = selectedFilter === filter
136+
return (
137+
<EuiButton
138+
key={filter}
139+
color="text"
140+
iconType={FILTER_ICONS[filter]}
141+
iconSize={filter === 'api' ? 's' : 'm'}
142+
size="s"
143+
onClick={() => handleFilterClick(filter, index)}
144+
onFocus={() => handleFilterFocus(index)}
145+
onKeyDown={(
146+
e: React.KeyboardEvent<HTMLButtonElement>
147+
) => handleFilterKeyDown(e, index)}
148+
buttonRef={(el: HTMLButtonElement | null) => {
149+
if (filterRefs) {
150+
filterRefs.current[index] = el
151+
}
152+
}}
153+
tabIndex={getTabIndex(index)}
154+
css={getButtonStyle(isSelected)}
155+
aria-label={
156+
filter === 'all'
157+
? 'Show all results'
158+
: filter === 'doc'
159+
? 'Filter to documentation results'
160+
: 'Filter to API results'
161+
}
162+
aria-pressed={isSelected}
163+
>
164+
{FILTER_LABELS[filter]}
165+
</EuiButton>
166+
)
167+
})}
161168
</div>
162169
<EuiSpacer size="m" />
163170
</div>

0 commit comments

Comments
 (0)