Skip to content

Commit 4b4ae1b

Browse files
thejacksheltonwmertens
authored andcommitted
feat(combobox): new prop support such as default Value, better navigation, bigger test suite
1 parent 1bff0a0 commit 4b4ae1b

File tree

13 files changed

+593
-369
lines changed

13 files changed

+593
-369
lines changed

.vscode/settings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
{
22
"git.pullTags": false,
3-
"conventionalCommits.scopes": ["tailwind"]
3+
"conventionalCommits.scopes": ["tailwind"],
4+
"editor.codeActionsOnSave": {
5+
"source.removeUnusedImports": true
6+
}
47
}

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

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,16 @@ type Trainer = {
3232
};
3333

3434
const objectExample: Array<Trainer> = [
35-
{ testValue: 'alice', testLabel: 'Alice', disabled: false },
36-
{ testValue: 'joana', testLabel: 'Joana', disabled: false },
35+
{ testValue: 'alice', testLabel: 'Alice', disabled: true },
36+
{ testValue: 'joana', testLabel: 'Joana', disabled: true },
3737
{ testValue: 'malcolm', testLabel: 'Malcolm', disabled: false },
3838
{ testValue: 'zack', testLabel: 'Zack', disabled: true },
39-
{ testValue: 'brian', testLabel: 'Brian', disabled: false }
39+
{ testValue: 'brian', testLabel: 'Brian', disabled: false },
40+
{ testValue: 'ryan', testLabel: 'Ryan', disabled: false },
41+
{ testValue: 'joe', testLabel: 'Joe', disabled: false },
42+
{ testValue: 'randy', testLabel: 'Randy', disabled: false },
43+
{ testValue: 'david', testLabel: 'David', disabled: true },
44+
{ testValue: 'joseph', testLabel: 'Joseph', disabled: false }
4045
];
4146

4247
export const Example01 = component$(() => {
@@ -63,6 +68,7 @@ export const Example01 = component$(() => {
6368
{isComboboxVisibleSig.value && (
6469
<Combobox
6570
options={objectExampleSig}
71+
defaultLabel="Randy"
6672
onInputChange$={onInputChange$}
6773
optionValueKey="testValue"
6874
optionLabelKey="testLabel"
@@ -85,7 +91,10 @@ export const Example01 = component$(() => {
8591
Personal Trainers ⚡
8692
</ComboboxLabel>
8793
<ComboboxControl class="bg-[#1f2532] flex items-center rounded-sm border-[#7d95b3] border-[1px] relative">
88-
<ComboboxInput class="px-2 w-44 bg-inherit px-d2 pr-6 text-white" />
94+
<ComboboxInput
95+
placeholder="Jim"
96+
class="px-2 w-44 bg-inherit px-d2 pr-6 text-white"
97+
/>
8998
<ComboboxTrigger class="w-6 h-6 group absolute right-0">
9099
<svg
91100
xmlns="http://www.w3.org/2000/svg"
@@ -114,8 +123,82 @@ export const Example01 = component$(() => {
114123
);
115124
});
116125

117-
export const Example02 = component$(() => {
118-
return <PreviewCodeExample></PreviewCodeExample>;
126+
export const StringCombobox = component$(() => {
127+
const fruits = [
128+
'Apple',
129+
'Apricot',
130+
'Avocado 🥑',
131+
'Banana',
132+
'Bilberry',
133+
'Blackberry',
134+
'Blackcurrant',
135+
'Blueberry',
136+
'Boysenberry',
137+
'Currant',
138+
'Cherry',
139+
'Coconut',
140+
'Cranberry',
141+
'Cucumber'
142+
];
143+
144+
const fruitsSig = useSignal(fruits);
145+
146+
const onInputChange$ = $((value: string) => {
147+
fruitsSig.value = fruits.filter((option) => {
148+
return option.toLowerCase().includes(value.toLowerCase());
149+
});
150+
});
151+
152+
return (
153+
<PreviewCodeExample>
154+
<div class="flex flex-col gap-4" q:slot="actualComponent">
155+
<Combobox
156+
options={fruitsSig}
157+
defaultLabel="Currant"
158+
onInputChange$={onInputChange$}
159+
optionComponent$={$((option: string, index: number) => (
160+
<ComboboxOption
161+
class="rounded-sm px-2 hover:bg-[#496080] aria-selected:bg-[#496080] border-2 border-transparent aria-selected:border-[#abbbce] group"
162+
index={index}
163+
option={option}
164+
>
165+
{option}
166+
</ComboboxOption>
167+
))}
168+
>
169+
<ComboboxLabel class=" font-semibold dark:text-white text-[#333333]">
170+
Fruits 🍓
171+
</ComboboxLabel>
172+
<ComboboxControl class="bg-[#1f2532] flex items-center rounded-sm border-[#7d95b3] border-[1px] relative">
173+
<ComboboxInput
174+
class="px-2 w-44 bg-inherit px-d2 pr-6 text-white"
175+
placeholder="Papaya"
176+
/>
177+
<ComboboxTrigger class="w-6 h-6 group absolute right-0">
178+
<svg
179+
xmlns="http://www.w3.org/2000/svg"
180+
viewBox="0 0 24 24"
181+
fill="none"
182+
class="stroke-white group-aria-expanded:-rotate-180 transition-transform duration-[450ms]"
183+
stroke-linecap="round"
184+
stroke-width="2"
185+
stroke-linejoin="round"
186+
>
187+
<polyline points="6 9 12 15 18 9"></polyline>
188+
</svg>
189+
</ComboboxTrigger>
190+
</ComboboxControl>
191+
<ComboboxPortal>
192+
<ComboboxListbox class="text-white w-44 bg-[#1f2532] px-4 py-2 rounded-sm border-[#7d95b3] border-[1px]" />
193+
</ComboboxPortal>
194+
</Combobox>
195+
</div>
196+
197+
<div q:slot="codeExample">
198+
<Slot />
199+
</div>
200+
</PreviewCodeExample>
201+
);
119202
});
120203

121204
export const Example03 = component$(() => {

apps/website/src/routes/docs/headless/(components)/combobox/index.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
ComboboxOption,
1212
} from '@qwik-ui/headless';
1313

14-
import { Example01, Example02, Example03 } from './examples';
14+
import { Example01, StringCombobox, Example03 } from './examples';
1515
import { CodeExample } from '../../../_components/code-example/code-example';
1616
import { KeyboardInteractionTable } from '../../../_components/keyboard-interaction-table/keyboard-interaction-table';
1717
import { APITable } from '../../../_components/api-table/api-table';
@@ -98,7 +98,7 @@ import {statusByComponent} from '../../../../../_state/component-statuses';
9898

9999
### EXAMPLE: Frequently Asked Questions
100100

101-
<Example02>```tsx ```</Example02>
101+
<StringCombobox>```tsx ```</StringCombobox>
102102

103103
## Accessibility
104104

apps/website/src/routes/layout-landing.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Header from './_components/header/header';
55
import { useRootStore } from '../_state/use-root-store';
66
import { Footer } from './_components/footer/footer';
77
import { DocsNavigation } from './docs/_components/navigation-docs/navigation-docs';
8+
import { Link } from '@builder.io/qwik-city';
89

910
export default component$(() => {
1011
// useStyles$(globalStyles);
@@ -22,6 +23,9 @@ export default component$(() => {
2223
<main class="mx-auto pt-28 lg:pt-32 max-w-7xl px-4 md:px-8 mb-24">
2324
<Slot />
2425
</main>
26+
<Link class="text-white" href="/docs/headless/combobox/">
27+
Take me to combobox
28+
</Link>
2529
<Footer />
2630
</>
2731
);

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@ import { JSXNode, QRL, Signal } from '@builder.io/qwik';
33
export interface ComboboxContext {
44
// user's source of truth
55
options: Signal<Array<string | Record<string, any>>>;
6-
optionComponent$?: QRL<(option: any, index: number, ...args: any) => JSXNode>;
6+
optionComponent$?: QRL<(option: any, index: number) => JSXNode>;
77

8-
// refs
8+
// element state
9+
localId: string;
10+
labelRef: Signal<HTMLLabelElement | undefined>;
911
listboxRef: Signal<HTMLUListElement | undefined>;
1012
inputRef: Signal<HTMLInputElement | undefined>;
1113
triggerRef: Signal<HTMLButtonElement | undefined>;
14+
optionIds: Signal<string[]>;
15+
16+
//uncontrolled state
17+
defaultLabel?: string;
1218

1319
// internal state
1420
isInputFocusedSig: Signal<boolean | undefined>;

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

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import {
44
component$,
55
useContext,
66
useVisibleTask$,
7-
type QwikIntrinsicElements
7+
type QwikIntrinsicElements,
8+
useTask$,
9+
useSignal
810
} from '@builder.io/qwik';
911
import { KeyCode } from '../../utils';
1012
import ComboboxContextId from './combobox-context-id';
13+
import { Option } from './combobox-context.type';
1114
import {
1215
isOptionDisabled,
1316
getOptionLabel,
@@ -19,21 +22,33 @@ const preventedKeys = [KeyCode.Home, KeyCode.End, KeyCode.PageDown, KeyCode.Arro
1922

2023
export type ComboboxInputProps = QwikIntrinsicElements['input'];
2124

22-
export const ComboboxInput = component$((props: ComboboxInputProps) => {
25+
export const ComboboxInput = component$(({ ...props }: ComboboxInputProps) => {
2326
const context = useContext(ComboboxContextId);
2427

28+
const inputId = `${context.localId}-input`;
29+
const listboxId = `${context.localId}-listbox`;
30+
31+
const isDefaultLabelNeeded = useSignal<boolean>(true);
32+
2533
const onKeydownBehavior$ = $((e: QwikKeyboardEvent) => {
2634
const highlightedOptionLabel = getOptionLabel(
2735
context.options.value[context.highlightedIndexSig.value],
2836
context
2937
);
3038

3139
if (e.key === 'ArrowDown') {
32-
const nextEnabledOptionIndex = getNextEnabledOptionIndex(
33-
context.highlightedIndexSig.value,
34-
context
35-
);
36-
context.highlightedIndexSig.value = nextEnabledOptionIndex;
40+
if (context.isListboxOpenSig.value) {
41+
const nextEnabledOptionIndex = getNextEnabledOptionIndex(
42+
context.highlightedIndexSig.value,
43+
context
44+
);
45+
46+
context.highlightedIndexSig.value = nextEnabledOptionIndex;
47+
} else if (context.highlightedIndexSig.value === -1) {
48+
// get the first enabled option when there is no highlighted index
49+
const firstEnabledOptionIndex = getNextEnabledOptionIndex(-1, context);
50+
context.highlightedIndexSig.value = firstEnabledOptionIndex;
51+
}
3752

3853
context.isListboxOpenSig.value = true;
3954
}
@@ -58,11 +73,46 @@ export const ComboboxInput = component$((props: ComboboxInputProps) => {
5873
}
5974

6075
if (e.key === 'Home') {
61-
context.highlightedIndexSig.value = 0;
76+
const firstEnabledOptionIndex = getNextEnabledOptionIndex(-1, context);
77+
context.highlightedIndexSig.value = firstEnabledOptionIndex;
6278
}
6379

6480
if (e.key === 'End') {
65-
context.highlightedIndexSig.value = context.options.value.length - 1;
81+
const lastEnabledOptionIndex = getPrevEnabledOptionIndex(
82+
context.options.value.length,
83+
context
84+
);
85+
context.highlightedIndexSig.value = lastEnabledOptionIndex;
86+
}
87+
88+
if (e.key === 'Escape') {
89+
context.isListboxOpenSig.value = false;
90+
}
91+
});
92+
93+
// checks if a defaultLabel has been set on the input
94+
useTask$(function isLabelNeededTask() {
95+
if (!context.inputRef.value || !context.defaultLabel) {
96+
return;
97+
}
98+
99+
if (context.inputRef.value.value === context.defaultLabel) {
100+
isDefaultLabelNeeded.value = false;
101+
}
102+
});
103+
104+
useTask$(function highlightDefaultLabelTask() {
105+
const defaultIndex = context.options.value.findIndex((option: Option) => {
106+
if (typeof option === 'string') {
107+
return option === context.defaultLabel;
108+
} else if (context.optionLabelKey && option[context.optionLabelKey]) {
109+
return option[context.optionLabelKey] === context.defaultLabel;
110+
}
111+
return false;
112+
});
113+
114+
if (defaultIndex !== -1) {
115+
context.highlightedIndexSig.value = defaultIndex;
66116
}
67117
});
68118

@@ -81,8 +131,15 @@ export const ComboboxInput = component$((props: ComboboxInputProps) => {
81131

82132
return (
83133
<input
134+
id={inputId}
84135
ref={context.inputRef}
85136
type="text"
137+
role="combobox"
138+
aria-expanded={context.isListboxOpenSig.value}
139+
aria-haspopup="listbox"
140+
aria-autocomplete="list"
141+
aria-activedescendant={context.optionIds.value[context.highlightedIndexSig.value]}
142+
aria-controls={listboxId}
86143
onInput$={(e: InputEvent) => {
87144
context.isListboxOpenSig.value = true;
88145

@@ -95,6 +152,7 @@ export const ComboboxInput = component$((props: ComboboxInputProps) => {
95152
context.onInputChange$(inputElement.value);
96153
}
97154
}}
155+
value={isDefaultLabelNeeded && context.defaultLabel}
98156
onBlur$={() => (context.isListboxOpenSig.value = false)}
99157
onKeyDown$={[onKeydownBehavior$, props.onKeyDown$]}
100158
{...props}

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
import { Slot, component$, type QwikIntrinsicElements } from '@builder.io/qwik';
1+
import {
2+
Slot,
3+
component$,
4+
useContext,
5+
type QwikIntrinsicElements
6+
} from '@builder.io/qwik';
7+
import ComboboxContextId from './combobox-context-id';
28

39
export type ComboboxLabelProps = QwikIntrinsicElements['label'];
410

511
export const ComboboxLabel = component$((props: ComboboxLabelProps) => {
12+
const context = useContext(ComboboxContextId);
13+
const inputId = `${context.localId}-input`;
14+
615
return (
7-
<label {...props}>
16+
<label for={inputId} ref={context.labelRef} {...props}>
817
<Slot />
918
</label>
1019
);

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ export type ComboboxListboxProps = QwikIntrinsicElements['ul'];
1717

1818
export const ComboboxListbox = component$((props: ComboboxListboxProps) => {
1919
const context = useContext(ComboboxContextId);
20+
const listboxId = `${context.localId}-listbox`;
2021

2122
useVisibleTask$(function setListboxPosition({ cleanup }) {
23+
// Our settings from Floating UI
2224
function updatePosition() {
2325
computePosition(
2426
context.inputRef.value as ReferenceElement,
@@ -54,10 +56,16 @@ export const ComboboxListbox = component$((props: ComboboxListboxProps) => {
5456

5557
return (
5658
<ul
59+
id={listboxId}
5760
ref={context.listboxRef}
61+
aria-label={
62+
context.labelRef.value
63+
? context.labelRef.value?.innerText
64+
: context.inputRef.value?.value
65+
}
66+
role="listbox"
5867
style={{ position: 'absolute' }}
5968
hidden={!context.isListboxOpenSig.value}
60-
role="listbox"
6169
{...props}
6270
>
6371
{context.options.value.map(

0 commit comments

Comments
 (0)