@@ -9,11 +9,10 @@ import {
99 QwikIntrinsicElements ,
1010 useStore ,
1111 useVisibleTask$ ,
12- useTask$ ,
1312 $ ,
14- useStylesScoped$ ,
13+ useId ,
14+ useOnWindow ,
1515} from '@builder.io/qwik' ;
16- import { routeAction$ } from '@builder.io/qwik-city' ;
1716
1817import { computePosition , flip } from '@floating-ui/dom' ;
1918
@@ -62,8 +61,8 @@ import { computePosition, flip } from '@floating-ui/dom';
6261 - Listbox toggles - ✅
6362 - Floating UI anchor working - ✅
6463 - Listbox is anchored to a wrapper containing the input and button - ✅
65- - Autocomplete/filter functionality
66- - Select Value, and value is displayed in input
64+ - Autocomplete/filter functionality - ✅
65+ - Select Value, and value is displayed in input - ✅
6766
6867
6968
@@ -114,18 +113,21 @@ import { computePosition, flip } from '@floating-ui/dom';
114113 - sets results to empty array
115114 - if input is not empty set results equal to the search function with our input signal value as param
116115 - showSuggestions function with results and our input signal value as params
117- -
118116
119117
120118*/
121119
122120// Taken similar props from select + input Value
123121interface AutocompleteContext {
124122 options : Signal < HTMLElement | undefined > [ ] ;
123+ filteredOptions : Signal < HTMLElement | undefined > [ ] ;
125124 selectedOption : Signal < string > ;
126125 isExpanded : Signal < boolean > ;
127126 triggerRef : Signal < HTMLElement | undefined > ;
128127 listBoxRef : Signal < HTMLElement | undefined > ;
128+ listBoxId : string ;
129+ inputId : string ;
130+ activeOptionId : Signal < string | null > ;
129131 inputValue : Signal < string > ;
130132}
131133
@@ -138,28 +140,28 @@ export type AutocompleteRootProps = {
138140
139141export const AutocompleteRoot = component$ (
140142 ( { defaultValue, ...props } : AutocompleteRootProps ) => {
141- useStylesScoped$ ( `
142- div {
143- background: blue;
144- width: fit-content;
145- position: relative;
146- }
147- ` ) ;
148-
149143 const options = useStore ( [ ] ) ;
144+ const filteredOptions = useStore ( [ ] ) ;
150145 const selectedOption = useSignal ( defaultValue ? defaultValue : '' ) ;
151146 const isExpanded = useSignal ( false ) ;
152147 const triggerRef = useSignal < HTMLElement > ( ) ;
153148 const listBoxRef = useSignal < HTMLElement > ( ) ;
154149 const inputValue = useSignal ( defaultValue ? defaultValue : '' ) ;
150+ const listBoxId = useId ( ) ;
151+ const inputId = useId ( ) ;
152+ const activeOptionId = useSignal ( null ) ;
155153
156154 const contextService : AutocompleteContext = {
157155 options,
156+ filteredOptions,
158157 selectedOption,
159158 isExpanded,
160159 triggerRef,
161160 listBoxRef,
162161 inputValue,
162+ listBoxId,
163+ inputId,
164+ activeOptionId,
163165 } ;
164166
165167 useContextProvider ( AutocompleteContextId , contextService ) ;
@@ -200,8 +202,31 @@ export const AutocompleteRoot = component$(
200202 }
201203 } ) ;
202204
205+ // useOnWindow(
206+ // 'click',
207+ // $((e) => {
208+ // const target = e.target as HTMLElement;
209+ // if (
210+ // contextService.isExpanded.value === true &&
211+ // !target.contains(contextService.triggerRef.value as Node)
212+ // ) {
213+ // contextService.isExpanded.value = false;
214+ // }
215+ // })
216+ // );
217+
203218 return (
204- < div { ...props } >
219+ < div
220+ onKeyDown$ = { ( e ) => {
221+ if ( e . key === 'Escape' ) {
222+ contextService . isExpanded . value = false ;
223+ const inputElement = contextService . triggerRef . value
224+ ?. firstElementChild as HTMLElement ;
225+ inputElement ?. focus ( ) ;
226+ }
227+ } }
228+ { ...props }
229+ >
205230 < Slot />
206231 </ div >
207232 ) ;
@@ -211,8 +236,9 @@ export const AutocompleteRoot = component$(
211236export type AutocompleteLabelProps = QwikIntrinsicElements [ 'label' ] ;
212237
213238export const AutocompleteLabel = component$ ( ( props : AutocompleteLabelProps ) => {
239+ const contextService = useContext ( AutocompleteContextId ) ;
214240 return (
215- < label { ...props } for = "autocomplete-test" >
241+ < label { ...props } for = { contextService . inputId } >
216242 < Slot />
217243 </ label >
218244 ) ;
@@ -222,12 +248,6 @@ export type AutocompleteTriggerProps = QwikIntrinsicElements['div'];
222248
223249export const AutocompleteTrigger = component$ (
224250 ( props : AutocompleteTriggerProps ) => {
225- // useStylesScoped$(`
226- // div {
227- // margin-left: 80px;
228- // }
229- // `);
230-
231251 const ref = useSignal < HTMLElement > ( ) ;
232252 const contextService = useContext ( AutocompleteContextId ) ;
233253 contextService . triggerRef = ref ;
@@ -246,7 +266,17 @@ export type InputProps = QwikIntrinsicElements['input'];
246266export const AutocompleteInput = component$ ( ( props : InputProps ) => {
247267 const ref = useSignal < HTMLElement > ( ) ;
248268 const contextService = useContext ( AutocompleteContextId ) ;
249- // required prop here
269+
270+ /*
271+
272+ If we save the file, and then type the exact option value,
273+ then click the down arrow key to focus the first item in the array
274+
275+ it will focus the 2nd thing in the entire array, not the first thing.
276+
277+ works fine when we remount the component in storybook
278+
279+ */
250280
251281 useVisibleTask$ ( ( { track } ) => {
252282 track ( ( ) => contextService . inputValue . value ) ;
@@ -258,28 +288,44 @@ export const AutocompleteInput = component$((props: InputProps) => {
258288 contextService . isExpanded . value = true ;
259289 }
260290
291+ contextService . filteredOptions = contextService . options . filter (
292+ ( option : Signal ) => {
293+ const optionValue = option . value . getAttribute ( 'optionValue' ) ;
294+ const inputValue = contextService . inputValue . value ;
295+
296+ return optionValue . match ( new RegExp ( inputValue , 'i' ) ) ;
297+ }
298+ ) ;
299+
300+ console . log ( contextService . filteredOptions ) ;
301+
261302 // Probably better to refactor Signal type later
262303 contextService . options . map ( ( option : Signal ) => {
263304 if (
264305 ! option . value
265- ? .getAttribute ( 'optionValue' )
266- ? .match ( contextService . inputValue . value )
306+ . getAttribute ( 'optionValue' )
307+ . match ( new RegExp ( contextService . inputValue . value , 'i' ) )
267308 ) {
268309 option . value . style . display = 'none' ;
269310 } else {
270311 option . value . style . display = '' ;
271312 }
272313 } ) ;
273-
274- console . log ( contextService . inputValue . value ) ;
275314 } ) ;
276315
277316 return (
278317 < input
279318 ref = { ref }
280- id = "autocomplete-test"
281319 role = "combobox"
320+ id = { contextService . inputId }
321+ aria-autocomplete = "list"
322+ aria-controls = { contextService . listBoxId }
282323 bind :value = { contextService . inputValue }
324+ onKeyDown$ = { ( e ) => {
325+ if ( e . key === 'ArrowDown' && contextService . options ?. [ 0 ] ?. value ) {
326+ contextService . filteredOptions [ 0 ] . value ?. focus ( ) ;
327+ }
328+ } }
283329 { ...props }
284330 />
285331 ) ;
@@ -313,36 +359,61 @@ export type ListboxProps = {
313359} & QwikIntrinsicElements [ 'ul' ] ;
314360
315361export const AutocompleteListbox = component$ ( ( props : ListboxProps ) => {
316- // useStylesScoped$(`
317- // @keyframes opacity {
318- // from {
319- // opacity: 0;
320- // }
321- // to {
322- // opacity: 1;
323- // }
324- // }
325-
326- // ul {
327- // animation: opacity 2000ms ease-in-out;
328- // width: 100%;
329- // }
330- // `);
331-
332362 const ref = useSignal < HTMLElement > ( ) ;
333363 const contextService = useContext ( AutocompleteContextId ) ;
334364 contextService . listBoxRef = ref ;
335365
366+ // useStylesScoped$(`
367+ // ul {
368+ // width: 100%;
369+ // padding-left: 0;
370+ // margin-top: 0px;
371+ // }
372+ // `);
373+
336374 return (
337375 < ul
376+ id = { contextService . listBoxId }
338377 ref = { ref }
339378 style = { `
340- display: ${
341- contextService . isExpanded . value ? 'block' : 'none'
342- } ; background: yellow ; position: absolute; ${ props . style }
379+ display: ${
380+ contextService . isExpanded . value ? 'block' : 'none'
381+ } ; position: absolute; ${ props . style }
343382 ` }
344383 role = "listbox"
345384 { ...props }
385+ onKeyDown$ = { ( e ) => {
386+ const availableOptions = contextService . filteredOptions . map (
387+ ( option ) => option . value
388+ ) ;
389+
390+ const target = e . target as HTMLElement ;
391+ const currentIndex = availableOptions . indexOf ( target ) ;
392+
393+ if ( e . key === 'ArrowDown' ) {
394+ if ( currentIndex === availableOptions . length - 1 ) {
395+ availableOptions [ 0 ] ?. focus ( ) ;
396+ } else {
397+ availableOptions [ currentIndex + 1 ] ?. focus ( ) ;
398+ }
399+ }
400+
401+ if ( e . key === 'ArrowUp' ) {
402+ if ( currentIndex <= 0 ) {
403+ availableOptions [ availableOptions . length - 1 ] ?. focus ( ) ;
404+ } else {
405+ availableOptions [ currentIndex - 1 ] ?. focus ( ) ;
406+ }
407+ }
408+
409+ if ( e . key === 'Home' ) {
410+ availableOptions [ 0 ] ?. focus ( ) ;
411+ }
412+
413+ if ( e . key === 'End' ) {
414+ availableOptions [ availableOptions . length - 1 ] ?. focus ( ) ;
415+ }
416+ } }
346417 >
347418 < Slot />
348419 </ ul >
@@ -364,6 +435,16 @@ export const AutocompleteOption = component$((props: OptionProps) => {
364435 contextService . inputValue . value = props . optionValue ;
365436 contextService . isExpanded . value = false ;
366437 } }
438+ onKeyDown$ = { ( e ) => {
439+ if ( e . key === 'Enter' || e . key === ' ' ) {
440+ contextService . inputValue . value = props . optionValue ;
441+ contextService . isExpanded . value = false ;
442+ const inputElement = contextService . triggerRef . value
443+ ?. firstElementChild as HTMLElement ;
444+ inputElement ?. focus ( ) ;
445+ }
446+ } }
447+ tabIndex = { 0 }
367448 { ...props }
368449 >
369450 < Slot />
0 commit comments