Skip to content

Commit 09689c0

Browse files
feat(keyboard navigation & aria): added keyboard navigation
1 parent f3cd73c commit 09689c0

File tree

3 files changed

+191
-50
lines changed

3 files changed

+191
-50
lines changed

packages/kit-headless/src/components/Autocomplete/autocomplete.stories.tsx

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Meta, StoryObj } from 'storybook-framework-qwik';
22
import { userEvent, within } from '@storybook/testing-library';
33
import { expect } from '@storybook/jest';
4+
import './autocompleteTest.css';
45

56
/*
67
@@ -111,8 +112,8 @@ type Story = StoryObj<AutocompleteRootProps>;
111112

112113
const RegularAutocomplete = () => (
113114
<>
114-
<AutocompleteLabel>Label</AutocompleteLabel>
115-
<AutocompleteRoot>
115+
<AutocompleteRoot style="width: fit-content">
116+
<AutocompleteLabel>Label</AutocompleteLabel>
116117
<AutocompleteTrigger>
117118
<AutocompleteInput />
118119
<AutocompleteButton>
@@ -130,7 +131,61 @@ const RegularAutocomplete = () => (
130131
</svg>
131132
</AutocompleteButton>
132133
</AutocompleteTrigger>
133-
<AutocompleteListbox>
134+
<AutocompleteListbox class="listboxStyle">
135+
{fruits.map((fruit, index) => (
136+
<AutocompleteOption optionValue={fruit} key={index}>
137+
{fruit}
138+
</AutocompleteOption>
139+
))}
140+
</AutocompleteListbox>
141+
</AutocompleteRoot>
142+
<AutocompleteRoot style="width: fit-content">
143+
<AutocompleteLabel>Label</AutocompleteLabel>
144+
<AutocompleteTrigger>
145+
<AutocompleteInput />
146+
<AutocompleteButton>
147+
<svg
148+
xmlns="http://www.w3.org/2000/svg"
149+
viewBox="0 0 24 24"
150+
fill="none"
151+
stroke="currentColor"
152+
stroke-width="2"
153+
stroke-linecap="round"
154+
stroke-linejoin="round"
155+
style="width: 20px; height: 20px;"
156+
>
157+
<polyline points="6 9 12 15 18 9"></polyline>
158+
</svg>
159+
</AutocompleteButton>
160+
</AutocompleteTrigger>
161+
<AutocompleteListbox class="listboxStyle">
162+
{fruits.map((fruit, index) => (
163+
<AutocompleteOption optionValue={fruit} key={index}>
164+
{fruit}
165+
</AutocompleteOption>
166+
))}
167+
</AutocompleteListbox>
168+
</AutocompleteRoot>
169+
<AutocompleteRoot style="width: fit-content">
170+
<AutocompleteLabel>Label</AutocompleteLabel>
171+
<AutocompleteTrigger>
172+
<AutocompleteInput />
173+
<AutocompleteButton>
174+
<svg
175+
xmlns="http://www.w3.org/2000/svg"
176+
viewBox="0 0 24 24"
177+
fill="none"
178+
stroke="currentColor"
179+
stroke-width="2"
180+
stroke-linecap="round"
181+
stroke-linejoin="round"
182+
style="width: 20px; height: 20px;"
183+
>
184+
<polyline points="6 9 12 15 18 9"></polyline>
185+
</svg>
186+
</AutocompleteButton>
187+
</AutocompleteTrigger>
188+
<AutocompleteListbox class="listboxStyle">
134189
{fruits.map((fruit, index) => (
135190
<AutocompleteOption optionValue={fruit} key={index}>
136191
{fruit}

packages/kit-headless/src/components/Autocomplete/autocomplete.tsx

Lines changed: 128 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -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

1817
import { 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
123121
interface 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

139141
export 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$(
211236
export type AutocompleteLabelProps = QwikIntrinsicElements['label'];
212237

213238
export 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

223249
export 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'];
246266
export 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

315361
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-
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 />
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.listboxStyle {
2+
background: yellow;
3+
max-height: 500px;
4+
overflow-y: auto;
5+
}

0 commit comments

Comments
 (0)