@@ -9,11 +9,10 @@ import {
9
9
QwikIntrinsicElements ,
10
10
useStore ,
11
11
useVisibleTask$ ,
12
- useTask$ ,
13
12
$ ,
14
- useStylesScoped$ ,
13
+ useId ,
14
+ useOnWindow ,
15
15
} from '@builder.io/qwik' ;
16
- import { routeAction$ } from '@builder.io/qwik-city' ;
17
16
18
17
import { computePosition , flip } from '@floating-ui/dom' ;
19
18
@@ -62,8 +61,8 @@ import { computePosition, flip } from '@floating-ui/dom';
62
61
- Listbox toggles - ✅
63
62
- Floating UI anchor working - ✅
64
63
- 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 - ✅
67
66
68
67
69
68
@@ -114,18 +113,21 @@ import { computePosition, flip } from '@floating-ui/dom';
114
113
- sets results to empty array
115
114
- if input is not empty set results equal to the search function with our input signal value as param
116
115
- showSuggestions function with results and our input signal value as params
117
- -
118
116
119
117
120
118
*/
121
119
122
120
// Taken similar props from select + input Value
123
121
interface AutocompleteContext {
124
122
options : Signal < HTMLElement | undefined > [ ] ;
123
+ filteredOptions : Signal < HTMLElement | undefined > [ ] ;
125
124
selectedOption : Signal < string > ;
126
125
isExpanded : Signal < boolean > ;
127
126
triggerRef : Signal < HTMLElement | undefined > ;
128
127
listBoxRef : Signal < HTMLElement | undefined > ;
128
+ listBoxId : string ;
129
+ inputId : string ;
130
+ activeOptionId : Signal < string | null > ;
129
131
inputValue : Signal < string > ;
130
132
}
131
133
@@ -138,28 +140,28 @@ export type AutocompleteRootProps = {
138
140
139
141
export const AutocompleteRoot = component$ (
140
142
( { defaultValue, ...props } : AutocompleteRootProps ) => {
141
- useStylesScoped$ ( `
142
- div {
143
- background: blue;
144
- width: fit-content;
145
- position: relative;
146
- }
147
- ` ) ;
148
-
149
143
const options = useStore ( [ ] ) ;
144
+ const filteredOptions = useStore ( [ ] ) ;
150
145
const selectedOption = useSignal ( defaultValue ? defaultValue : '' ) ;
151
146
const isExpanded = useSignal ( false ) ;
152
147
const triggerRef = useSignal < HTMLElement > ( ) ;
153
148
const listBoxRef = useSignal < HTMLElement > ( ) ;
154
149
const inputValue = useSignal ( defaultValue ? defaultValue : '' ) ;
150
+ const listBoxId = useId ( ) ;
151
+ const inputId = useId ( ) ;
152
+ const activeOptionId = useSignal ( null ) ;
155
153
156
154
const contextService : AutocompleteContext = {
157
155
options,
156
+ filteredOptions,
158
157
selectedOption,
159
158
isExpanded,
160
159
triggerRef,
161
160
listBoxRef,
162
161
inputValue,
162
+ listBoxId,
163
+ inputId,
164
+ activeOptionId,
163
165
} ;
164
166
165
167
useContextProvider ( AutocompleteContextId , contextService ) ;
@@ -200,8 +202,31 @@ export const AutocompleteRoot = component$(
200
202
}
201
203
} ) ;
202
204
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
+
203
218
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
+ >
205
230
< Slot />
206
231
</ div >
207
232
) ;
@@ -211,8 +236,9 @@ export const AutocompleteRoot = component$(
211
236
export type AutocompleteLabelProps = QwikIntrinsicElements [ 'label' ] ;
212
237
213
238
export const AutocompleteLabel = component$ ( ( props : AutocompleteLabelProps ) => {
239
+ const contextService = useContext ( AutocompleteContextId ) ;
214
240
return (
215
- < label { ...props } for = "autocomplete-test" >
241
+ < label { ...props } for = { contextService . inputId } >
216
242
< Slot />
217
243
</ label >
218
244
) ;
@@ -222,12 +248,6 @@ export type AutocompleteTriggerProps = QwikIntrinsicElements['div'];
222
248
223
249
export const AutocompleteTrigger = component$ (
224
250
( props : AutocompleteTriggerProps ) => {
225
- // useStylesScoped$(`
226
- // div {
227
- // margin-left: 80px;
228
- // }
229
- // `);
230
-
231
251
const ref = useSignal < HTMLElement > ( ) ;
232
252
const contextService = useContext ( AutocompleteContextId ) ;
233
253
contextService . triggerRef = ref ;
@@ -246,7 +266,17 @@ export type InputProps = QwikIntrinsicElements['input'];
246
266
export const AutocompleteInput = component$ ( ( props : InputProps ) => {
247
267
const ref = useSignal < HTMLElement > ( ) ;
248
268
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
+ */
250
280
251
281
useVisibleTask$ ( ( { track } ) => {
252
282
track ( ( ) => contextService . inputValue . value ) ;
@@ -258,28 +288,44 @@ export const AutocompleteInput = component$((props: InputProps) => {
258
288
contextService . isExpanded . value = true ;
259
289
}
260
290
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
+
261
302
// Probably better to refactor Signal type later
262
303
contextService . options . map ( ( option : Signal ) => {
263
304
if (
264
305
! option . value
265
- ? .getAttribute ( 'optionValue' )
266
- ? .match ( contextService . inputValue . value )
306
+ . getAttribute ( 'optionValue' )
307
+ . match ( new RegExp ( contextService . inputValue . value , 'i' ) )
267
308
) {
268
309
option . value . style . display = 'none' ;
269
310
} else {
270
311
option . value . style . display = '' ;
271
312
}
272
313
} ) ;
273
-
274
- console . log ( contextService . inputValue . value ) ;
275
314
} ) ;
276
315
277
316
return (
278
317
< input
279
318
ref = { ref }
280
- id = "autocomplete-test"
281
319
role = "combobox"
320
+ id = { contextService . inputId }
321
+ aria-autocomplete = "list"
322
+ aria-controls = { contextService . listBoxId }
282
323
bind :value = { contextService . inputValue }
324
+ onKeyDown$ = { ( e ) => {
325
+ if ( e . key === 'ArrowDown' && contextService . options ?. [ 0 ] ?. value ) {
326
+ contextService . filteredOptions [ 0 ] . value ?. focus ( ) ;
327
+ }
328
+ } }
283
329
{ ...props }
284
330
/>
285
331
) ;
@@ -313,36 +359,61 @@ export type ListboxProps = {
313
359
} & QwikIntrinsicElements [ 'ul' ] ;
314
360
315
361
export 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
-
332
362
const ref = useSignal < HTMLElement > ( ) ;
333
363
const contextService = useContext ( AutocompleteContextId ) ;
334
364
contextService . listBoxRef = ref ;
335
365
366
+ // useStylesScoped$(`
367
+ // ul {
368
+ // width: 100%;
369
+ // padding-left: 0;
370
+ // margin-top: 0px;
371
+ // }
372
+ // `);
373
+
336
374
return (
337
375
< ul
376
+ id = { contextService . listBoxId }
338
377
ref = { ref }
339
378
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 }
343
382
` }
344
383
role = "listbox"
345
384
{ ...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
+ } }
346
417
>
347
418
< Slot />
348
419
</ ul >
@@ -364,6 +435,16 @@ export const AutocompleteOption = component$((props: OptionProps) => {
364
435
contextService . inputValue . value = props . optionValue ;
365
436
contextService . isExpanded . value = false ;
366
437
} }
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 }
367
448
{ ...props }
368
449
>
369
450
< Slot />
0 commit comments