Skip to content

Commit 1f800d5

Browse files
feat(select): repeated chars support
1 parent 119c553 commit 1f800d5

File tree

4 files changed

+65
-15
lines changed

4 files changed

+65
-15
lines changed

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -467,8 +467,8 @@ test.describe('Keyboard Behavior', () => {
467467

468468
test.describe('typeahead', () => {
469469
test(`GIVEN an open select with a typeahead support
470-
WHEN the user types in the letter "r"
471-
THEN the first option starting with the letter "r" should have data-highlighted`, async ({
470+
WHEN the user types in the letter "j"
471+
THEN the first option starting with the letter "j" should have data-highlighted`, async ({
472472
page,
473473
}) => {
474474
const { getRoot, getTrigger, openListbox } = await setup(
@@ -480,6 +480,21 @@ test.describe('Keyboard Behavior', () => {
480480
const highlightedOpt = getRoot().locator('[data-highlighted]');
481481
await expect(highlightedOpt).toContainText('j', { ignoreCase: true });
482482
});
483+
484+
test(`GIVEN an open select with a typeahead support
485+
WHEN the user types in the letter "j" twice
486+
THEN the second option starting with the letter "j" should have data-highlighted`, async ({
487+
page,
488+
}) => {
489+
const { getRoot, getTrigger, openListbox } = await setup(
490+
page,
491+
'select-typeahead-test',
492+
);
493+
await openListbox('ArrowDown');
494+
await getTrigger().pressSequentially('jj', { delay: 250 });
495+
const highlightedOpt = getRoot().locator('[data-highlighted]');
496+
await expect(highlightedOpt).toContainText('jessie', { ignoreCase: true });
497+
});
483498
});
484499
});
485500

packages/kit-headless/src/components/select/select-context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type SelectContext = {
1414
listboxRef: Signal<HTMLUListElement | undefined>;
1515

1616
// core state
17-
optionsSig: Signal<Opt[] | undefined>;
17+
optionsSig: Signal<Opt[]>;
1818
highlightedIndexSig: Signal<number | null>;
1919
isListboxOpenSig: Signal<boolean>;
2020
selectedIndexSig: Signal<number | null>;

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ export const SelectImpl = component$<SelectProps>((props) => {
4040
* Updates the options when the options change
4141
* (for example, when a new option is added)
4242
**/
43-
const optionsSig = useComputed$(() => props._options);
43+
const optionsSig = useComputed$(() => {
44+
if (props._options === undefined || props._options.length === 0) {
45+
return [];
46+
}
47+
return props._options;
48+
});
4449

4550
const optionsIndexMap = new Map(
4651
optionsSig.value?.map((option, index) => [option.value, index]),

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

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,56 @@
1-
import { useContext, $ } from '@builder.io/qwik';
1+
import { useContext, useSignal, $, useComputed$ } from '@builder.io/qwik';
22
import SelectContextId from './select-context';
33

44
export function useTypeahead() {
55
const context = useContext(SelectContextId);
6+
const inputStrSig = useSignal('');
7+
const indexDiffSig = useSignal<number | undefined>(undefined);
8+
9+
const firstCharOptionsSig = useComputed$(() => {
10+
return context.optionsSig.value.map((opt) => opt.value.slice(0, 1).toLowerCase());
11+
});
612

713
const typeahead$ = $((key: string): void => {
814
if (key.length > 1) {
9-
return null;
15+
return;
1016
}
1117

12-
const singleInputChar = key.toLowerCase();
13-
const firstCharOptions = context.optionsSig.value?.map((opt) =>
14-
opt.value.slice(0, 1).toLowerCase(),
15-
);
18+
inputStrSig.value += key;
1619

17-
const charIndex = firstCharOptions.indexOf(singleInputChar);
20+
const firstCharOnly$ = $(() => {
21+
// First opens the listbox if it is not already displayed and then moves visual focus to the first option that matches the typed character.
22+
const singleInputChar = key.toLowerCase();
1823

19-
if (charIndex === -1) {
20-
return null;
21-
}
24+
const charIndex = firstCharOptionsSig.value.indexOf(singleInputChar);
25+
26+
if (charIndex === -1 || charIndex === undefined) {
27+
return null;
28+
}
29+
if (indexDiffSig.value === undefined) {
30+
console.log('Is key length 1?', charIndex);
31+
indexDiffSig.value = charIndex + 1;
32+
context.highlightedIndexSig.value = charIndex;
33+
return;
34+
}
35+
36+
// If the same character is typed in succession, visual focus cycles among the options starting with that character.
37+
const isRepeatedChar = firstCharOptionsSig.value[indexDiffSig.value - 1] === key;
38+
39+
if (isRepeatedChar) {
40+
const nextChars = firstCharOptionsSig.value.slice(indexDiffSig.value);
41+
const repeatIndex = nextChars.indexOf(key);
42+
if (repeatIndex !== -1) {
43+
const nextIndex = repeatIndex + indexDiffSig.value;
44+
45+
context.highlightedIndexSig.value = nextIndex;
46+
indexDiffSig.value = nextIndex + 1;
47+
}
48+
}
49+
50+
return;
51+
});
2252

23-
context.highlightedIndexSig.value = charIndex;
53+
firstCharOnly$();
2454
});
2555

2656
return { typeahead$ };

0 commit comments

Comments
 (0)