Skip to content

Commit dbe0eda

Browse files
feat(select): dynamic options
1 parent a5b0bdc commit dbe0eda

File tree

7 files changed

+102
-16
lines changed

7 files changed

+102
-16
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { component$, useSignal, $ } from '@builder.io/qwik';
2+
import {
3+
Select,
4+
SelectListbox,
5+
SelectOption,
6+
SelectTrigger,
7+
SelectValue,
8+
} from '@qwik-ui/headless';
9+
10+
export default component$(() => {
11+
const usersSig = useSignal<string[]>(['Tim', 'Ryan', 'Jim', 'Jessie', 'Abby']);
12+
const hasAddedUsersSig = useSignal<boolean>(false);
13+
14+
return (
15+
<>
16+
<Select class="relative mb-2 min-w-40">
17+
<SelectTrigger class="w-full border-2 border-dashed border-red-400">
18+
<SelectValue placeholder="Select an option" />
19+
</SelectTrigger>
20+
<SelectListbox class="absolute w-full border-2 border-dashed border-green-400 bg-slate-900 p-2">
21+
{usersSig.value.map((user) => (
22+
<SelectOption
23+
class="border-dashed border-blue-400 data-[highlighted]:border-2"
24+
key={user}
25+
>
26+
{user}
27+
</SelectOption>
28+
))}
29+
</SelectListbox>
30+
</Select>
31+
<button
32+
class="bg-background border-2 border-dashed border-red-400"
33+
onClick$={$(() => {
34+
if (!hasAddedUsersSig.value) {
35+
usersSig.value = [...usersSig.value, 'John', 'Jane', 'Bob'];
36+
hasAddedUsersSig.value = true;
37+
}
38+
})}
39+
>
40+
Add Users
41+
</button>
42+
</>
43+
);
44+
});

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ This element is used to create a drop-down list, it's often used in a form, to c
4040
<Showcase name="controlled" />
4141
</div>
4242

43+
## Adding Users
44+
45+
<div data-testid="select-add-users-test">
46+
<Showcase name="add-users" />
47+
</div>
48+
4349
## Building blocks
4450

4551
<CodeSnippet name="building-blocks" />

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ async function setup(page: Page, selector: string) {
55

66
const driver = createTestDriver(page.getByTestId(selector));
77

8-
const { getListbox, getTrigger, getOptions, getValue, openListbox } = driver;
8+
const { getRoot, getListbox, getTrigger, getOptions, getValue, openListbox } = driver;
99

1010
return {
1111
driver,
12+
getRoot,
1213
getListbox,
1314
getTrigger,
1415
getOptions,
@@ -85,6 +86,26 @@ test.describe('Mouse Behavior', () => {
8586
await expect(options[2]).toHaveAttribute('aria-selected', 'true');
8687
expect(thirdOptStr).toEqual(await getValue());
8788
});
89+
90+
test(`GIVEN a select
91+
WHEN adding new users and selecting a new user
92+
THEN the new user should be the selected value`, async ({ page }) => {
93+
const { getRoot, getOptions, openListbox, getValue } = await setup(
94+
page,
95+
'select-add-users-test',
96+
);
97+
98+
const sibling = getRoot().locator('+ button');
99+
100+
await expect(sibling).toHaveText('Add Users');
101+
await sibling.click();
102+
103+
await openListbox('click');
104+
const options = await getOptions();
105+
await expect(options[7]).toHaveText('Bob');
106+
await options[7].click();
107+
expect(await getValue()).toEqual(await options[7].textContent());
108+
});
88109
});
89110

90111
test.describe('Keyboard Behavior', () => {

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-
options: Opt[] | undefined;
17+
optionsSig: Signal<Opt[] | undefined>;
1818
highlightedIndexSig: Signal<number | null>;
1919
isListboxOpenSig: Signal<boolean>;
2020
selectedIndexSig: Signal<number | null>;

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
2424
const handleKeyDown$ = $((e: KeyboardEvent) => {
2525
const shouldOpen = !context.isListboxOpenSig.value && openKeys.includes(e.key);
2626
const shouldClose = context.isListboxOpenSig.value && closedKeys.includes(e.key);
27-
if (!context.options) return;
27+
if (!context.optionsSig.value) return;
2828

2929
if (shouldOpen) {
3030
context.isListboxOpenSig.value = true;
@@ -35,35 +35,41 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
3535
}
3636

3737
if (e.key === 'Home') {
38-
context.highlightedIndexSig.value = getNextEnabledOptionIndex(-1, context.options);
38+
context.highlightedIndexSig.value = getNextEnabledOptionIndex(
39+
-1,
40+
context.optionsSig.value,
41+
);
3942
}
4043

4144
if (e.key === 'End') {
4245
const lastEnabledOptionIndex = getPrevEnabledOptionIndex(
43-
context.options.length,
44-
context.options,
46+
context.optionsSig.value.length,
47+
context.optionsSig.value,
4548
);
4649
context.highlightedIndexSig.value = lastEnabledOptionIndex;
4750
}
4851

4952
/** When initially opening the listbox, we want to grab the first enabled option index */
5053
if (context.highlightedIndexSig.value === null) {
51-
context.highlightedIndexSig.value = getNextEnabledOptionIndex(-1, context.options);
54+
context.highlightedIndexSig.value = getNextEnabledOptionIndex(
55+
-1,
56+
context.optionsSig.value,
57+
);
5258
return;
5359
}
5460

5561
if (context.isListboxOpenSig.value && !shouldOpen) {
5662
if (e.key === 'ArrowDown') {
5763
context.highlightedIndexSig.value = getNextEnabledOptionIndex(
5864
context.highlightedIndexSig.value,
59-
context.options,
65+
context.optionsSig.value,
6066
);
6167
}
6268

6369
if (e.key === 'ArrowUp') {
6470
context.highlightedIndexSig.value = getPrevEnabledOptionIndex(
6571
context.highlightedIndexSig.value,
66-
context.options,
72+
context.optionsSig.value,
6773
);
6874
}
6975

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ type SelectValueProps = PropsOf<'span'> & {
88

99
export const SelectValue = component$((props: SelectValueProps) => {
1010
const context = useContext(SelectContextId);
11-
if (!context.options) return;
11+
if (!context.optionsSig.value) return;
1212

1313
const selectedOptStr =
1414
context.selectedIndexSig.value !== null
15-
? context.options[context.selectedIndexSig.value].value
15+
? context.optionsSig.value[context.selectedIndexSig.value].value
1616
: props.placeholder;
1717

1818
return (

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useContextProvider,
77
Signal,
88
useTask$,
9+
useComputed$,
910
} from '@builder.io/qwik';
1011
import { type SelectContext } from './select-context';
1112
import SelectContextId from './select-context';
@@ -30,8 +31,15 @@ export const SelectImpl = component$<SelectProps>((props) => {
3031
const popoverRef = useSignal<HTMLElement>();
3132
const listboxRef = useSignal<HTMLUListElement>();
3233

33-
const options = props._options;
34-
const optionsIndex = new Map(options.map((option, index) => [option.value, index]));
34+
/**
35+
* Updates the options when the options change
36+
* (for example, when a new option is added)
37+
**/
38+
const optionsSig = useComputed$(() => props._options);
39+
40+
const optionsIndexMap = new Map(
41+
optionsSig.value?.map((option, index) => [option.value, index]),
42+
);
3543

3644
// core state
3745
const selectedIndexSig = useSignal<number | null>(props._valuePropIndex ?? null);
@@ -40,9 +48,10 @@ export const SelectImpl = component$<SelectProps>((props) => {
4048

4149
// Maps are apparently great for this index accessing. Will learn more about them this week and refactor this to have a more consistent API and eliminate redundancy / duplication.
4250
useTask$(({ track }) => {
43-
const bindValue = track(() => props['bind:value']?.value);
51+
const controlledValue = track(() => props['bind:value']?.value);
52+
if (!controlledValue) return;
4453

45-
const matchingIndex = optionsIndex.get(bindValue) ?? -1;
54+
const matchingIndex = optionsIndexMap.get(controlledValue) ?? -1;
4655
if (matchingIndex !== -1) {
4756
selectedIndexSig.value = matchingIndex;
4857
highlightedIndexSig.value = matchingIndex;
@@ -53,7 +62,7 @@ export const SelectImpl = component$<SelectProps>((props) => {
5362
triggerRef,
5463
popoverRef,
5564
listboxRef,
56-
options,
65+
optionsSig,
5766
highlightedIndexSig,
5867
isListboxOpenSig,
5968
selectedIndexSig,

0 commit comments

Comments
 (0)