Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const InfoBanner = () => {
`}
>
<EuiBetaBadge
tabIndex={-1}
css={css`
display: inherit;
`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ describe('Search Component', () => {
expect(searchStore.getState().selectedIndex).toBe(0)
})

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

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

await user.keyboard('{ArrowDown}')

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

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

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

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

await user.keyboard('{ArrowUp}')

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

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

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

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

it('should render isSelected prop on the selected item', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -109,14 +104,11 @@ export const Search = () => {
iconType="cross"
color="text"
onClick={handleCloseModal}
tabIndex={-1}
/>
</div>

<SearchResults
inputRef={inputRef}
buttonRef={buttonRef}
itemRefs={itemRefs}
/>
<SearchResults itemRefs={itemRefs} filterRefs={filterRefs} />
{!showLoadingSpinner && <EuiHorizontalRule margin="none" />}
{searchTerm && (
<div
Expand All @@ -139,7 +131,6 @@ export const Search = () => {
ref={buttonRef}
term={searchTerm}
onAsk={askAi}
onArrowUp={focusLastAvailable}
/>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TypeFilter, string> = {
all: 'All',
doc: 'Docs',
api: 'API',
}
const FILTER_ICONS: Record<TypeFilter, string> = {
all: 'globe',
doc: 'documentation',
api: 'code',
}

interface SearchFiltersProps {
isLoading: boolean
inputRef?: React.RefObject<HTMLInputElement>
itemRefs?: MutableRefObject<(HTMLAnchorElement | null)[]>
resultsCount?: number
filterRefs?: MutableRefObject<(HTMLButtonElement | null)[]>
}

export const SearchFilters = ({
isLoading,
inputRef,
itemRefs,
resultsCount = 0,
filterRefs,
}: SearchFiltersProps) => {
if (isLoading) {
return null
Expand All @@ -24,71 +32,90 @@ 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<HTMLButtonElement>, 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<HTMLButtonElement>, 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};
}
&: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};
}
}
`
Expand All @@ -101,63 +128,43 @@ export const SearchFilters = ({
gap: ${euiTheme.size.s};
padding-inline: ${euiTheme.size.base};
`}
role="group"
role="toolbar"
aria-label="Search filters"
>
<EuiButton
color="text"
iconType="globe"
iconSize="m"
size="s"
onClick={() => setTypeFilter('all')}
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) =>
handleFilterKeyDown(e, 0)
}
buttonRef={(el: HTMLButtonElement | null) => {
filterRefs.current[0] = el
}}
css={buttonStyle}
aria-label={`Show all results`}
aria-pressed={selectedFilter === 'all'}
>
{`All`}
</EuiButton>
<EuiButton
color="text"
iconType="documentation"
iconSize="m"
size="s"
onClick={() => setTypeFilter('doc')}
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) =>
handleFilterKeyDown(e, 1)
}
buttonRef={(el: HTMLButtonElement | null) => {
filterRefs.current[1] = el
}}
css={buttonStyle}
aria-label={`Filter to documentation results`}
aria-pressed={selectedFilter === 'doc'}
>
{`Docs`}
</EuiButton>
<EuiButton
color="text"
iconType="code"
iconSize="s"
size="s"
onClick={() => setTypeFilter('api')}
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) =>
handleFilterKeyDown(e, 2)
}
buttonRef={(el: HTMLButtonElement | null) => {
filterRefs.current[2] = el
}}
css={buttonStyle}
aria-label={`Filter to API results`}
aria-pressed={selectedFilter === 'api'}
>
{`API`}
</EuiButton>
{FILTERS.map((filter, index) => {
const isSelected = selectedFilter === filter
return (
<EuiButton
key={filter}
color="text"
iconType={FILTER_ICONS[filter]}
iconSize={filter === 'api' ? 's' : 'm'}
size="s"
onClick={() => handleFilterClick(filter, index)}
onFocus={() => handleFilterFocus(index)}
onKeyDown={(
e: React.KeyboardEvent<HTMLButtonElement>
) => 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]}
</EuiButton>
)
})}
</div>
<EuiSpacer size="m" />
</div>
Expand Down
Loading
Loading