Skip to content

Commit 15d5924

Browse files
thejacksheltonwmertens
authored andcommitted
feat(combobox): new keyboard navigation, selecting options, prevent default on click
1 parent 189bd8b commit 15d5924

File tree

6 files changed

+318
-126
lines changed

6 files changed

+318
-126
lines changed

apps/website/src/routes/docs/headless/(components)/combobox/examples.tsx

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { component$, Slot, useSignal } from '@builder.io/qwik';
1+
import { $, component$, Slot, useSignal } from '@builder.io/qwik';
22
import {
33
Combobox,
44
ComboboxControl,
@@ -25,10 +25,33 @@ const trainers = [
2525
'Elizabeth',
2626
];
2727

28+
interface Trainer {
29+
value: string;
30+
label: string;
31+
disabled: boolean;
32+
}
33+
34+
const ALL_OPTIONS: Array<Trainer> = [
35+
{ value: 'alice', label: 'Alice', disabled: false },
36+
{ value: 'joana', label: 'Joana', disabled: false },
37+
{ value: 'malcolm', label: 'Malcolm', disabled: false },
38+
{ value: 'zack', label: 'Zack', disabled: true },
39+
{ value: 'brian', label: 'Brian', disabled: false }
40+
];
41+
2842
export const Example01 = component$(() => {
29-
const trainersSig = useSignal(trainers);
43+
// const trainersSig = useSignal(trainers);
44+
const optionsSig = useSignal(ALL_OPTIONS);
3045
const showExample = useSignal(true);
3146

47+
const onInputChange$ = $((value: string) => {
48+
optionsSig.value = ALL_OPTIONS.filter((option) => {
49+
return option.label.toLowerCase().includes(value.toLowerCase());
50+
});
51+
52+
console.log(optionsSig.value);
53+
});
54+
3255
return (
3356
<PreviewCodeExample>
3457
<div class="flex flex-col gap-4" q:slot="actualComponent">
@@ -40,12 +63,25 @@ export const Example01 = component$(() => {
4063
Show them
4164
</button>
4265
{showExample.value === true && (
43-
<Combobox class="relative">
66+
<Combobox
67+
options={optionsSig}
68+
onInputChange$={onInputChange$}
69+
optionComponent$={$((option: string | Trainer, index: number) => (
70+
<ComboboxOption
71+
index={index}
72+
option={option}
73+
class="rounded-sm px-2 hover:bg-[#496080] focus:bg-[#496080]"
74+
>
75+
{typeof option === 'string' ? option : option.label}
76+
</ComboboxOption>
77+
))}
78+
class="relative"
79+
>
4480
<ComboboxLabel class=" font-semibold dark:text-white text-[#333333]">
4581
Personal Trainers ⚡
4682
</ComboboxLabel>
4783
<ComboboxControl class="bg-[#1f2532] flex items-center rounded-sm border-[#7d95b3] border-[1px] relative">
48-
<ComboboxInput class="w-44 bg-inherit px-d2 pr-6 text-white" />
84+
<ComboboxInput class="px-2 w-44 bg-inherit px-d2 pr-6 text-white" />
4985
<ComboboxTrigger class="w-6 h-6 group absolute right-0">
5086
<svg
5187
xmlns="http://www.w3.org/2000/svg"
@@ -61,23 +97,14 @@ export const Example01 = component$(() => {
6197
</ComboboxTrigger>
6298
</ComboboxControl>
6399
<ComboboxPortal>
64-
<ComboboxListbox class="text-white w-44 bg-[#1f2532] px-4 py-2 mt-2 rounded-sm border-[#7d95b3] border-[1px]">
65-
{trainersSig.value.map((trainer) => (
66-
<ComboboxOption
67-
key={trainer}
68-
class="rounded-sm px-2 hover:bg-[#496080] focus:bg-[#496080]"
69-
>
70-
{trainer}
71-
</ComboboxOption>
72-
))}
73-
</ComboboxListbox>
100+
<ComboboxListbox class="text-white w-44 bg-[#1f2532] px-4 py-2 rounded-sm border-[#7d95b3] border-[1px]" />
74101
</ComboboxPortal>
75102
</Combobox>
76103
)}
77104
<button
78-
onClick$={() => {
79-
trainersSig.value = ['One', 'Two', 'Three', 'Four', 'Five'];
80-
}}
105+
// onClick$={() => {
106+
// trainersSig.value = ['One', 'Two', 'Three', 'Four', 'Five'];
107+
// }}
81108
>
82109
Change them
83110
</button>

packages/kit-headless/src/components/combobox/combobox-context.type.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Signal } from '@builder.io/qwik';
1+
import { JSXNode, QRL, Signal } from '@builder.io/qwik';
22

33
export interface ComboboxContext {
44
selectedOptionIndexSig: Signal<number>;
@@ -8,4 +8,8 @@ export interface ComboboxContext {
88
listboxRef: Signal<HTMLUListElement | undefined>;
99
inputRef: Signal<HTMLInputElement | undefined>;
1010
triggerRef: Signal<HTMLButtonElement | undefined>;
11+
optionComponent$?: QRL<(option: any, index: number) => JSXNode>;
12+
onInputChange$?: QRL<(value: string) => void>;
13+
options: Signal<Array<string | Record<string, any>>>;
14+
highlightedIndexSig: Signal<number>;
1115
}

packages/kit-headless/src/components/combobox/combobox-input.tsx

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,89 @@
1-
import { component$, useContext, type QwikIntrinsicElements } from '@builder.io/qwik';
1+
import {
2+
$,
3+
QwikKeyboardEvent,
4+
component$,
5+
useContext,
6+
useVisibleTask$,
7+
type QwikIntrinsicElements
8+
} from '@builder.io/qwik';
9+
import { KeyCode } from '../../utils';
210
import ComboboxContextId from './combobox-context-id';
311

12+
const preventedKeys = [KeyCode.Home, KeyCode.End, KeyCode.PageDown, KeyCode.ArrowUp];
13+
414
export type ComboboxInputProps = QwikIntrinsicElements['input'];
515

616
// Add required context here
717
export const ComboboxInput = component$((props: ComboboxInputProps) => {
818
const context = useContext(ComboboxContextId);
919

20+
const onKeydownBehavior$ = $((e: QwikKeyboardEvent) => {
21+
const highlightedOption = context.options.value[context.highlightedIndexSig.value];
22+
const highlightedOptionLabel = highlightedOption
23+
? (highlightedOption as any).label
24+
: undefined;
25+
26+
if (e.key === 'ArrowDown') {
27+
// If the listbox is already open, move down
28+
if (context.isListboxOpenSig.value) {
29+
context.highlightedIndexSig.value === context.options.value.length - 1
30+
? (context.highlightedIndexSig.value = 0)
31+
: context.highlightedIndexSig.value++;
32+
}
33+
context.isListboxOpenSig.value = true;
34+
}
35+
36+
if (e.key === 'ArrowUp') {
37+
context.highlightedIndexSig.value === 0
38+
? (context.highlightedIndexSig.value = context.options.value.length - 1)
39+
: context.highlightedIndexSig.value--;
40+
}
41+
42+
if (e.key === 'Enter') {
43+
const inputElement = e.target as HTMLInputElement;
44+
inputElement.value = highlightedOptionLabel;
45+
context.isListboxOpenSig.value = false;
46+
}
47+
48+
if (e.key === 'Home') {
49+
context.highlightedIndexSig.value = 0;
50+
}
51+
52+
if (e.key === 'End') {
53+
context.highlightedIndexSig.value = context.options.value.length - 1;
54+
}
55+
});
56+
57+
useVisibleTask$(function preventDefaultTask({ cleanup }) {
58+
function keyHandler(e: KeyboardEvent) {
59+
if (preventedKeys.includes(e.key as KeyCode)) {
60+
e.preventDefault();
61+
}
62+
}
63+
64+
context.inputRef?.value?.addEventListener('keydown', keyHandler);
65+
cleanup(() => {
66+
context.inputRef?.value?.removeEventListener('keydown', keyHandler);
67+
});
68+
});
69+
1070
return (
1171
<input
1272
ref={context.inputRef}
1373
type="text"
14-
onInput$={() => (context.isListboxOpenSig.value = true)}
15-
onKeyDown$={(e) => {
16-
if (e.key === 'ArrowDown') {
17-
context.isListboxOpenSig.value = true;
74+
onInput$={(e: InputEvent) => {
75+
context.isListboxOpenSig.value = true;
76+
77+
// Deselect the currently selected option
78+
context.highlightedIndexSig.value = -1;
79+
80+
const inputElement = e.target as HTMLInputElement;
81+
82+
if (context.onInputChange$) {
83+
context.onInputChange$(inputElement.value);
1884
}
1985
}}
86+
onKeyDown$={[onKeydownBehavior$, props.onKeyDown$]}
2087
{...props}
2188
/>
2289
);
Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,54 @@
11
import {
2-
$,
3-
Slot,
42
component$,
53
useContext,
64
useVisibleTask$,
75
type QwikIntrinsicElements,
86
} from '@builder.io/qwik';
9-
import { computePosition, flip } from '@floating-ui/dom';
7+
import {
8+
ReferenceElement,
9+
autoUpdate,
10+
computePosition,
11+
flip,
12+
offset,
13+
} from '@floating-ui/dom';
1014
import ComboboxContextId from './combobox-context-id';
1115

1216
export type ComboboxListboxProps = QwikIntrinsicElements['ul'];
1317

1418
export const ComboboxListbox = component$((props: ComboboxListboxProps) => {
1519
const context = useContext(ComboboxContextId);
1620

17-
const updatePosition$ = $(
18-
(referenceEl: HTMLInputElement, floatingEl: HTMLUListElement) => {
19-
computePosition(referenceEl, floatingEl, {
20-
placement: 'bottom',
21-
middleware: [flip()],
22-
}).then(({ x, y }) => {
23-
Object.assign(floatingEl.style, {
24-
left: `${x}px`,
25-
top: `${y}px`,
26-
});
21+
useVisibleTask$(function setListboxPosition({ cleanup }) {
22+
function updatePosition() {
23+
computePosition(
24+
context.inputRef.value as ReferenceElement,
25+
context.listboxRef.value as HTMLElement,
26+
{
27+
placement: 'bottom',
28+
middleware: [offset(8), flip()],
29+
},
30+
).then(({ x, y }) => {
31+
if (context.listboxRef.value) {
32+
Object.assign(context.listboxRef.value.style, {
33+
left: `${x}px`,
34+
top: `${y}px`,
35+
});
36+
}
2737
});
28-
},
29-
);
38+
}
3039

31-
useVisibleTask$(async function updateListboxPosition() {
3240
if (context.inputRef.value && context.listboxRef.value) {
33-
await updatePosition$(context.inputRef.value, context.listboxRef.value);
41+
updatePosition();
42+
43+
const cleanupFunc = autoUpdate(
44+
context.inputRef.value,
45+
context.listboxRef.value,
46+
updatePosition,
47+
);
48+
49+
cleanup(() => {
50+
cleanupFunc();
51+
});
3452
}
3553
});
3654

@@ -42,7 +60,10 @@ export const ComboboxListbox = component$((props: ComboboxListboxProps) => {
4260
role="listbox"
4361
{...props}
4462
>
45-
<Slot />
63+
{context.options.value.map(
64+
(option, index) =>
65+
context.optionComponent$ && context.optionComponent$(option, index),
66+
)}
4667
</ul>
4768
);
4869
});

0 commit comments

Comments
 (0)