Skip to content

Commit b95bfbb

Browse files
feat(select): feat, select options with left and right arrow keys
1 parent 0b890cc commit b95bfbb

File tree

5 files changed

+128
-27
lines changed

5 files changed

+128
-27
lines changed

apps/website/src/routes/docs/headless/select/examples/controlled.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,7 @@ export default component$(() => {
1212

1313
return (
1414
<>
15-
<Select
16-
onChange$={$(() => console.log('Changed!'))}
17-
bind:value={selectedVal}
18-
class="relative min-w-40"
19-
>
15+
<Select bind:value={selectedVal} class="relative min-w-40">
2016
<SelectTrigger class="w-full border-2 border-dashed border-red-400">
2117
<SelectValue placeholder="Select an option" />
2218
</SelectTrigger>

apps/website/src/routes/docs/headless/select/select.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,59 @@ test.describe('Keyboard Behavior', () => {
463463

464464
expect(optStr).toEqual(await getValue());
465465
});
466+
467+
test(`GIVEN a basic select
468+
WHEN pressing the right arrow key
469+
AND the placeholder is the selected value
470+
THEN select the first enabled option
471+
AND the first enabled option should have aria-selected`, async ({ page }) => {
472+
const { getTrigger, getOptions, getValue } = await setup(page, 'select-hero-test');
473+
474+
const options = await getOptions();
475+
await getTrigger().focus();
476+
await getTrigger().press('ArrowRight', { delay: 250 });
477+
478+
expect(await getValue()).toEqual(await options[0].textContent());
479+
await expect(options[0]).toHaveAttribute('aria-selected', 'true');
480+
await expect(options[0]).toHaveAttribute('data-highlighted');
481+
});
482+
483+
test(`GIVEN a basic select
484+
WHEN pressing the right arrow key
485+
AND there is a selected value
486+
THEN select the next enabled option
487+
AND the next enabled option should have aria-selected`, async ({ page }) => {
488+
const { getTrigger, getOptions, getValue } = await setup(page, 'select-hero-test');
489+
490+
const options = await getOptions();
491+
await getTrigger().focus();
492+
await getTrigger().press('ArrowRight', { delay: 250 });
493+
await getTrigger().press('ArrowRight', { delay: 250 });
494+
495+
expect(await getValue()).toEqual(await options[1].textContent());
496+
await expect(options[1]).toHaveAttribute('aria-selected', 'true');
497+
await expect(options[1]).toHaveAttribute('data-highlighted');
498+
});
499+
500+
test(`GIVEN a basic select
501+
WHEN pressing the left arrow key
502+
AND there is a selected value
503+
THEN select the previous enabled option
504+
AND the previous enabled option should have
505+
aria-selected & data-highlighted`, async ({ page }) => {
506+
const { getTrigger, getOptions, getValue } = await setup(page, 'select-hero-test');
507+
508+
// get initial selected value
509+
const options = await getOptions();
510+
await getTrigger().focus();
511+
await getTrigger().press('ArrowRight', { delay: 250 });
512+
await getTrigger().press('ArrowRight', { delay: 250 });
513+
514+
await getTrigger().press('ArrowLeft', { delay: 250 });
515+
expect(await getValue()).toEqual(await options[0].textContent());
516+
await expect(options[0]).toHaveAttribute('aria-selected', 'true');
517+
await expect(options[0]).toHaveAttribute('data-highlighted');
518+
});
466519
});
467520

468521
test.describe('typeahead', () => {

packages/kit-headless/src/components/select/select-option.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,6 @@ export const SelectOption = component$<SelectOptionProps>((props) => {
3838
localIndexSig.value = index;
3939
});
4040

41-
const handleClick$ = $(() => {
42-
if (disabled) return;
43-
44-
context.selectedIndexSig.value = localIndexSig.value;
45-
context.isListboxOpenSig.value = false;
46-
});
47-
48-
const handlePointerOver$ = $(() => {
49-
if (disabled) return;
50-
51-
if (localIndexSig.value !== null) {
52-
context.highlightedIndexSig.value = localIndexSig.value;
53-
}
54-
});
55-
5641
useTask$(function scrollableTask({ track, cleanup }) {
5742
track(() => context.highlightedIndexSig.value);
5843

@@ -83,6 +68,21 @@ export const SelectOption = component$<SelectOptionProps>((props) => {
8368
}
8469
});
8570

71+
const handleClick$ = $(() => {
72+
if (disabled) return;
73+
74+
context.selectedIndexSig.value = localIndexSig.value;
75+
context.isListboxOpenSig.value = false;
76+
});
77+
78+
const handlePointerOver$ = $(() => {
79+
if (disabled) return;
80+
81+
if (localIndexSig.value !== null) {
82+
context.highlightedIndexSig.value = localIndexSig.value;
83+
}
84+
});
85+
8686
return (
8787
<li
8888
{...rest}

packages/kit-headless/src/components/select/select-trigger.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
1818
});
1919

2020
const handleKeyDownSync$ = sync$((e: KeyboardEvent) => {
21-
const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'PageDown', 'PageUp'];
21+
const keys = [
22+
'ArrowUp',
23+
'ArrowDown',
24+
'ArrowRight',
25+
'ArrowLeft',
26+
'Home',
27+
'End',
28+
'PageDown',
29+
'PageUp',
30+
];
2231
if (keys.includes(e.key)) {
2332
e.preventDefault();
2433
}
@@ -52,6 +61,46 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
5261
context.highlightedIndexSig.value = lastEnabledOptionIndex;
5362
}
5463

64+
if (!context.isListboxOpenSig.value) {
65+
if (e.key === 'ArrowRight' && context.highlightedIndexSig.value === null) {
66+
context.selectedIndexSig.value = getNextEnabledOptionIndex(
67+
-1,
68+
context.optionsSig.value,
69+
);
70+
71+
context.highlightedIndexSig.value = context.selectedIndexSig.value;
72+
return;
73+
}
74+
75+
if (e.key === 'ArrowRight' && context.highlightedIndexSig.value !== null) {
76+
context.selectedIndexSig.value = getNextEnabledOptionIndex(
77+
context.selectedIndexSig.value!,
78+
context.optionsSig.value,
79+
);
80+
81+
context.highlightedIndexSig.value = context.selectedIndexSig.value;
82+
}
83+
84+
if (e.key === 'ArrowLeft' && context.highlightedIndexSig.value === null) {
85+
context.selectedIndexSig.value = getPrevEnabledOptionIndex(
86+
context.optionsSig.value.length,
87+
context.optionsSig.value,
88+
);
89+
90+
context.highlightedIndexSig.value = context.selectedIndexSig.value;
91+
return;
92+
}
93+
94+
if (e.key === 'ArrowLeft' && context.highlightedIndexSig.value !== null) {
95+
context.selectedIndexSig.value = getPrevEnabledOptionIndex(
96+
context.highlightedIndexSig.value,
97+
context.optionsSig.value,
98+
);
99+
100+
context.highlightedIndexSig.value = context.selectedIndexSig.value;
101+
}
102+
}
103+
55104
/** When initially opening the listbox, we want to grab the first enabled option index */
56105
if (context.highlightedIndexSig.value === null) {
57106
context.highlightedIndexSig.value = getNextEnabledOptionIndex(
Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { component$, useContext, type PropsOf } from '@builder.io/qwik';
1+
import { component$, useContext, type PropsOf, useComputed$ } from '@builder.io/qwik';
22

33
import SelectContextId from './select-context';
44

@@ -10,14 +10,17 @@ export const SelectValue = component$((props: SelectValueProps) => {
1010
const context = useContext(SelectContextId);
1111
if (!context.optionsSig.value) return;
1212

13-
const selectedOptStr =
14-
context.selectedIndexSig.value !== null
15-
? context.optionsSig.value[context.selectedIndexSig.value].value
16-
: props.placeholder;
13+
const displayStrSig = useComputed$(() => {
14+
if (context.selectedIndexSig.value !== null) {
15+
return context.optionsSig.value[context.selectedIndexSig.value].value;
16+
} else {
17+
return props.placeholder;
18+
}
19+
});
1720

1821
return (
1922
<span data-value {...props}>
20-
{selectedOptStr}
23+
{displayStrSig.value}
2124
</span>
2225
);
2326
});

0 commit comments

Comments
 (0)