1- import { useTypeFilter , useSearchActions } from '../search.store'
1+ import { TypeFilter , useTypeFilter , useSearchActions } from '../search.store'
22import { useEuiTheme , EuiButton , EuiSpacer } from '@elastic/eui'
33import { 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
618interface SearchFiltersProps {
719 isLoading : boolean
8- inputRef ?: React . RefObject < HTMLInputElement >
9- itemRefs ?: MutableRefObject < ( HTMLAnchorElement | null ) [ ] >
10- resultsCount ?: number
20+ filterRefs ?: MutableRefObject < ( HTMLButtonElement | null ) [ ] >
1121}
1222
1323export 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