Skip to content

Commit acbde64

Browse files
feat(select): typeahead support for a closed listbox
1 parent 8dd6dc1 commit acbde64

File tree

5 files changed

+44
-3
lines changed

5 files changed

+44
-3
lines changed

apps/website/src/routes/docs/headless/contributing/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ What I like to do is look at other places around the web and see how things work
7878

7979
We also take inspiration from awesome headless libraries in other communities. For example, like the popular headless libraries below:
8080

81-
- [Radix UI](https://www.radix-ui.com/primitives/docs/components/accordion) is a React Headless library.
8281
- [React Aria](https://react-spectrum.adobe.com/react-aria/components.html) is a React Headless library.
82+
- [Radix UI](https://www.radix-ui.com/primitives/docs/components/accordion) is a React Headless library.
8383
- [Melt UI](https://melt-ui.com/docs/builders/accordion) is a Svelte headless library.
8484
- [Kobalte](https://kobalte.dev/docs/core/components/accordion) is a Solid JS headless library
8585
- [Headless UI](https://headlessui.com/) is a React and Vue headless library.

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,30 @@ test.describe('Keyboard Behavior', () => {
645645
await getTrigger().press('d');
646646
await expect(highlightedOpt).toContainText('dog', { ignoreCase: true });
647647
});
648+
649+
test(`GIVEN a closed select with typeahead support
650+
WHEN the user types a letter matching an option
651+
THEN the display value should first matching option`, async ({ page }) => {
652+
const { getTrigger } = await setup(page, 'select-hero-test');
653+
await getTrigger().focus();
654+
await getTrigger().press('j');
655+
await expect(getTrigger()).toHaveText('Jim');
656+
});
657+
658+
test(`GIVEN a closed select with typeahead support
659+
WHEN the user types a letter matching an option
660+
THEN the first matching option should be selected`, async ({ page }) => {
661+
// ideally want to refactor this so that even if the test example is changed, the test will still pass, getting it more programmatically.
662+
const { getRoot, getTrigger } = await setup(page, 'select-hero-test');
663+
await getTrigger().focus();
664+
await getTrigger().press('j');
665+
const firstJOption = getRoot().getByRole('option', {
666+
name: 'Jim',
667+
includeHidden: true,
668+
});
669+
await expect(firstJOption).toHaveAttribute('aria-selected', 'true');
670+
await expect(firstJOption).toHaveAttribute('data-highlighted');
671+
});
648672
});
649673

650674
test.describe('looping', () => {

packages/kit-headless/src/components/select/notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ What do we absolutely need? I am talking about the bare minimum, but powerful fu
22

33
Inspiration:
44

5+
- React Aria
56
- Radix UI
67
- Kobalte UI
78
- Melt UI

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
3333
});
3434

3535
const handleKeyDown$ = $((e: KeyboardEvent) => {
36+
typeahead$(e.key);
3637
const shouldOpen = !context.isListboxOpenSig.value && openKeys.includes(e.key);
3738
const shouldClose = context.isListboxOpenSig.value && closedKeys.includes(e.key);
3839
if (!context.optionsSig.value) return;
@@ -141,8 +142,6 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
141142
context.loop,
142143
);
143144
}
144-
145-
typeahead$(e.key);
146145
}
147146
});
148147

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export function useTypeahead() {
3535
if (indexDiffSig.value === undefined) {
3636
indexDiffSig.value = firstCharIndex + 1;
3737
context.highlightedIndexSig.value = firstCharIndex;
38+
39+
if (!context.isListboxOpenSig.value) {
40+
context.selectedIndexSig.value = firstCharIndex;
41+
}
42+
3843
return;
3944
}
4045

@@ -54,16 +59,25 @@ export function useTypeahead() {
5459
const nextIndex = repeatIndex + indexDiffSig.value;
5560

5661
context.highlightedIndexSig.value = nextIndex;
62+
if (!context.isListboxOpenSig.value) {
63+
context.selectedIndexSig.value = nextIndex;
64+
}
5765
indexDiffSig.value = nextIndex + 1;
5866
return;
5967
}
6068

6169
indexDiffSig.value = undefined;
6270
context.highlightedIndexSig.value = firstCharIndex;
71+
if (!context.isListboxOpenSig.value) {
72+
context.selectedIndexSig.value = firstCharIndex;
73+
}
6374
return;
6475
}
6576
indexDiffSig.value = firstCharIndex + 1;
6677
context.highlightedIndexSig.value = firstCharIndex;
78+
if (!context.isListboxOpenSig.value) {
79+
context.selectedIndexSig.value = firstCharIndex;
80+
}
6781

6882
return;
6983
});
@@ -81,6 +95,9 @@ export function useTypeahead() {
8195
});
8296
if (firstPossibleOpt !== -1) {
8397
context.highlightedIndexSig.value = firstPossibleOpt;
98+
if (!context.isListboxOpenSig.value) {
99+
context.selectedIndexSig.value = firstPossibleOpt;
100+
}
84101
return;
85102
}
86103
inputStrSig.value = key;

0 commit comments

Comments
 (0)