Skip to content

Commit 0f359ce

Browse files
feat(select): grouped options
1 parent fe00b50 commit 0f359ce

File tree

11 files changed

+204
-12
lines changed

11 files changed

+204
-12
lines changed

apps/website/src/routes/docs/headless/select/examples/disabled.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ import {
88
} from '@qwik-ui/headless';
99

1010
export default component$(() => {
11-
const usersSig = useSignal<string[]>(['Tim', 'Ryan', 'Jim', 'Jessie', 'Abby']);
11+
const usersSig = useSignal<string[]>([
12+
'Tim',
13+
'Ryan',
14+
'Jim',
15+
'Bobbie',
16+
'Joan',
17+
'Jessie',
18+
'Abby',
19+
]);
1220

1321
return (
1422
<Select class="relative min-w-40">
@@ -21,7 +29,11 @@ export default component$(() => {
2129
<SelectOption
2230
class="border-dashed border-blue-400 data-[highlighted]:border-2 data-[disabled]:bg-slate-600 data-[disabled]:opacity-30"
2331
key={user}
24-
disabled={index === 0 || index === usersSig.value.length - 1 ? true : false}
32+
disabled={
33+
index === 0 || index === 2 || index === usersSig.value.length - 1
34+
? true
35+
: false
36+
}
2537
>
2638
{user}
2739
</SelectOption>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { component$, useSignal } from '@builder.io/qwik';
2+
import {
3+
Select,
4+
SelectListbox,
5+
SelectOption,
6+
SelectTrigger,
7+
SelectValue,
8+
SelectGroup,
9+
SelectLabel,
10+
} from '@qwik-ui/headless';
11+
12+
export default component$(() => {
13+
const usersSig = useSignal<string[]>(['Tim', 'Ryan', 'Jim', 'Jessie', 'Abby']);
14+
const animalsSig = useSignal<string[]>(['Dog', 'Cat', 'Bird', 'Fish', 'Snake']);
15+
// const animalId = useId();
16+
// const usersId = useId();
17+
18+
return (
19+
<Select class="relative min-w-40">
20+
<SelectTrigger class="w-full border-2 border-dashed border-red-400">
21+
<SelectValue placeholder="Select an option" />
22+
</SelectTrigger>
23+
<SelectListbox class="absolute z-10 w-full border-2 border-dashed border-green-400 bg-slate-900 p-2">
24+
<SelectGroup>
25+
<SelectLabel class="text-sm text-slate-400">People</SelectLabel>
26+
{usersSig.value.map((user) => (
27+
<SelectOption
28+
class="border-dashed border-blue-400 data-[highlighted]:border-2"
29+
key={user}
30+
>
31+
{user}
32+
</SelectOption>
33+
))}
34+
</SelectGroup>
35+
<SelectGroup>
36+
<SelectLabel class="text-sm text-slate-400">Animals</SelectLabel>
37+
{animalsSig.value.map((animal) => (
38+
<SelectOption
39+
class="border-dashed border-blue-400 data-[highlighted]:border-2"
40+
key={animal}
41+
>
42+
{animal}
43+
</SelectOption>
44+
))}
45+
</SelectGroup>
46+
</SelectListbox>
47+
</Select>
48+
);
49+
});

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

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

67+
## Grouped Options
68+
69+
<div data-testid="select-group-test">
70+
<Showcase name="group" />
71+
</div>
72+
6773
## Building blocks
6874

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

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,19 @@ test.describe('Keyboard Behavior', () => {
555555
const highlightedOpt = getRoot().locator('[data-highlighted]');
556556
await expect(highlightedOpt).toContainText('jim', { ignoreCase: true });
557557
});
558+
559+
test(`GIVEN an open select with typeahead support and grouped options
560+
WHEN the user types a letter matching an option in one group
561+
AND the user types a letter matching an option in another group
562+
THEN the data-highlighted value should switch groups`, async ({ page }) => {
563+
const { getRoot, getTrigger, openListbox } = await setup(page, 'select-group-test');
564+
await openListbox('ArrowDown');
565+
await getTrigger().press('j');
566+
const highlightedOpt = getRoot().locator('[data-highlighted]');
567+
await expect(highlightedOpt).toContainText('Jim', { ignoreCase: true });
568+
await getTrigger().press('d');
569+
await expect(highlightedOpt).toContainText('dog', { ignoreCase: true });
570+
});
558571
});
559572
});
560573

@@ -615,6 +628,42 @@ test.describe('Disabled', () => {
615628
const options = await getOptions();
616629
await expect(options[options.length - 2]).toHaveAttribute('data-highlighted');
617630
});
631+
632+
test(`GIVEN an open disabled select
633+
WHEN the second option is highlighted and the down arrow key is pressed
634+
AND the first and third options are disabled
635+
THEN the fourth option should be highlighted`, async ({ page }) => {
636+
const { getTrigger, getOptions, openListbox } = await setup(
637+
page,
638+
'select-disabled-test',
639+
);
640+
641+
await openListbox('ArrowDown');
642+
const options = await getOptions();
643+
await expect(options[1]).toHaveAttribute('data-highlighted');
644+
await getTrigger().press('ArrowDown');
645+
await expect(options[3]).toHaveAttribute('data-highlighted');
646+
});
647+
648+
test(`GIVEN an open disabled select
649+
WHEN the fourth is highlighted and the up key is pressed
650+
AND the first and third options are disabled
651+
THEN the second option should be highlighted`, async ({ page }) => {
652+
const { getTrigger, getOptions, openListbox } = await setup(
653+
page,
654+
'select-disabled-test',
655+
);
656+
657+
// initially the fourh option is highlighted
658+
await openListbox('ArrowDown');
659+
const options = await getOptions();
660+
await expect(options[1]).toHaveAttribute('data-highlighted');
661+
await getTrigger().press('ArrowDown');
662+
await expect(options[3]).toHaveAttribute('data-highlighted');
663+
664+
await getTrigger().press('ArrowUp');
665+
await expect(options[1]).toHaveAttribute('data-highlighted');
666+
});
618667
});
619668

620669
test.describe('Props', () => {
@@ -741,3 +790,17 @@ test.describe('Props', () => {
741790
});
742791
});
743792
});
793+
794+
test.describe('A11y', () => {
795+
test(`GIVEN a select with a group
796+
WHEN the user adds a new group
797+
THEN the group should have an aria-labelledby attribute
798+
AND its associated label`, async ({ page }) => {
799+
const { getRoot, openListbox } = await setup(page, 'select-group-test');
800+
await openListbox('ArrowDown');
801+
const labelId = await getRoot().getByRole('listitem').first().getAttribute('id');
802+
const group = getRoot().getByRole('group').first();
803+
804+
await expect(group).toHaveAttribute('aria-labelledby', labelId!);
805+
});
806+
});

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ export * from './select-option';
55
export * from './select-popover';
66
export * from './select-trigger';
77
export * from './select-value';
8+
export * from './select-group';
9+
export * from './select-label';

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ What do they all have in common? How do people use them? What are the most impor
1919
<SelectPopover>
2020
<SelectListbox>
2121
<SelectOption />
22-
<SelectGroup>
23-
<SelectLabel />
22+
<SelectGroup label="">
2423
<SelectOption />
2524
</SelectGroup>
2625
</SelectListbox>
@@ -29,14 +28,15 @@ What do they all have in common? How do people use them? What are the most impor
2928

3029
## Features:
3130

32-
- Single Select
33-
- Multi Select
34-
- Controlled or uncontrolled
35-
- Keyboard Interactions
36-
- Grouped options
37-
- Typeahead support (user typing / filter)
38-
- RTL support
39-
- Scrollable
31+
- [x] Single Select
32+
- [ ] Multi Select
33+
- [x] Controlled or uncontrolled
34+
- [ ] Keyboard Interactions
35+
- [x] Grouped options
36+
- [x] Typeahead support (user typing / filter)
37+
- [ ] RTL support
38+
- [ ] Scrollable
39+
- [ ] Aria (controls, roles, etc)
4040

4141
## Props:
4242

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@ export type SelectContext = {
1212
triggerRef: Signal<HTMLButtonElement | undefined>;
1313
popoverRef: Signal<HTMLElement | undefined>;
1414
listboxRef: Signal<HTMLUListElement | undefined>;
15+
groupRef: Signal<HTMLDivElement | undefined>;
1516

1617
// core state
1718
optionsSig: Signal<Opt[]>;
1819
highlightedIndexSig: Signal<number | null>;
1920
isListboxOpenSig: Signal<boolean>;
2021
selectedIndexSig: Signal<number | null>;
2122
};
23+
24+
export const groupContextId = createContextId<GroupContext>('Select-Group');
25+
26+
export type GroupContext = {
27+
labelId: string;
28+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {
2+
PropsOf,
3+
Slot,
4+
component$,
5+
useContext,
6+
useContextProvider,
7+
useId,
8+
} from '@builder.io/qwik';
9+
10+
import SelectContextId, { groupContextId } from './select-context';
11+
12+
type SelectGroupProps = PropsOf<'div'>;
13+
14+
export const SelectGroup = component$<SelectGroupProps>((props) => {
15+
const context = useContext(SelectContextId);
16+
const labelId = useId();
17+
18+
const groupContext = {
19+
labelId,
20+
};
21+
22+
useContextProvider(groupContextId, groupContext);
23+
24+
return (
25+
<div aria-labelledby={labelId} role="group" {...props} ref={context.groupRef}>
26+
<Slot />
27+
</div>
28+
);
29+
});

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type JSXNode, type FunctionComponent } from '@builder.io/qwik';
22
import { SelectImpl, type SelectProps } from './select';
33
import { SelectListbox } from './select-listbox';
44
import { SelectOption } from './select-option';
5+
import { SelectGroup } from './select-group';
56

67
export type Opt = {
78
isDisabled: boolean;
@@ -49,6 +50,13 @@ export const Select: FunctionComponent<SelectProps> = (props) => {
4950
childrenToProcess.unshift(...listboxChildren);
5051
break;
5152
}
53+
case SelectGroup: {
54+
const listboxChildren = Array.isArray(child.props.children)
55+
? [...child.props.children]
56+
: [child.props.children];
57+
childrenToProcess.unshift(...listboxChildren);
58+
break;
59+
}
5260
case SelectOption: {
5361
const isString = typeof child.props.children === 'string';
5462
if (!isString) {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { PropsOf, Slot, component$, useContext } from '@builder.io/qwik';
2+
import { groupContextId } from './select-context';
3+
4+
type SelectLabelProps = PropsOf<'li'>;
5+
6+
export const SelectLabel = component$<SelectLabelProps>((props) => {
7+
const groupContext = useContext(groupContextId);
8+
9+
return (
10+
<li id={groupContext.labelId} {...props}>
11+
<Slot />
12+
</li>
13+
);
14+
});

0 commit comments

Comments
 (0)