Skip to content

Commit 1b27bb3

Browse files
feat(select): initial form logic
1 parent 7f774e1 commit 1b27bb3

File tree

7 files changed

+123
-77
lines changed

7 files changed

+123
-77
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { component$, useStyles$ } from '@builder.io/qwik';
2+
import {
3+
Select,
4+
SelectListbox,
5+
SelectOption,
6+
SelectPopover,
7+
SelectTrigger,
8+
SelectValue,
9+
} from '@qwik-ui/headless';
10+
import styles from '../snippets/select.css?inline';
11+
12+
export default component$(() => {
13+
useStyles$(styles);
14+
const users = ['Tim', 'Ryan', 'Jim', 'Jessie', 'Abby'];
15+
16+
return (
17+
<form preventdefault:submit>
18+
<Select required class="select" aria-label="hero">
19+
<SelectTrigger class="select-trigger">
20+
<SelectValue placeholder="Select an option" />
21+
</SelectTrigger>
22+
<SelectPopover class="select-popover">
23+
<SelectListbox class="select-listbox">
24+
{users.map((user) => (
25+
<SelectOption key={user}>{user}</SelectOption>
26+
))}
27+
</SelectListbox>
28+
</SelectPopover>
29+
</Select>
30+
<label style={{ display: 'flex', flexDirection: 'column' }}>
31+
Your favorite cat name
32+
<input />
33+
</label>
34+
<button type="submit">Submit my form!</button>
35+
</form>
36+
);
37+
});

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

Lines changed: 18 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ import { statusByComponent } from '~/_state/component-statuses';
1111

1212
Reveals a list of options to choose from, often triggered by a button.
1313

14-
<div data-testid="select-hero-test">
15-
<Showcase name="hero" />
16-
</div>
14+
<Showcase name="hero" />
1715

1816
## ✨ Features
1917

@@ -137,9 +135,7 @@ You are in full control of how the data is rendered. Map over the data, or rende
137135
138136
### Passing a distinct value
139137

140-
<div data-testid="select-option-value-test">
141-
<Showcase name="option-value" />
142-
</div>
138+
<Showcase name="option-value" />
143139

144140
Sometimes we want to display one thing to the user, but pass another value to the option.
145141

@@ -149,9 +145,7 @@ By adding the `value` prop to the `<SelectOption />` component, we can pass a di
149145
150146
### Handling selection changes
151147

152-
<div data-testid="select-change-test">
153-
<Showcase name="change-value" />
154-
</div>
148+
<Showcase name="change-value" />
155149

156150
We can listen to changes in the selected value by using the `onChange$` prop. It provides an argument that is the new selected value.
157151

@@ -163,9 +157,7 @@ We can select an initial uncontrolled value by passing the `value` prop to the `
163157

164158
### Uncontrolled / Initial value
165159

166-
<div data-testid="select-uncontrolled-test">
167-
<Showcase name="uncontrolled" />
168-
</div>
160+
<Showcase name="uncontrolled" />
169161

170162
The above example passes one of the option values "Jessie" as the initial value. As a result, the matching value is selected and focused.
171163

@@ -175,9 +167,7 @@ The above example passes one of the option values "Jessie" as the initial value.
175167

176168
We can pass reactive state by using the `bind:value` prop to the `<Select />` root component.
177169

178-
<div data-testid="select-controlled-test">
179-
<Showcase name="controlled" />
180-
</div>
170+
<Showcase name="controlled" />
181171

182172
`bind:value` is a signal prop, it allows us to programmatically control the selected value of the select component.
183173

@@ -187,19 +177,15 @@ We can pass reactive state by using the `bind:value` prop to the `<Select />` ro
187177

188178
To combine some of our previous knowledge, let's use the `onChange$` handler and `bind:value` prop in tandem.
189179

190-
<div data-testid="select-controlled-value-test">
191-
<Showcase name="controlled-value" />
192-
</div>
180+
<Showcase name="controlled-value" />
193181

194182
In the above example, we can programmatically change the selected value by clicking on the "Change to Abby" button.
195183

196184
This allows us to update the signal value and trigger the `onChange$` handler.
197185

198186
### Disabled options
199187

200-
<div data-testid="select-disabled-test">
201-
<Showcase name="disabled" />
202-
</div>
188+
<Showcase name="disabled" />
203189

204190
Options can be disabled by adding the `disabled` prop to the `<SelectOption />` component.
205191

@@ -209,9 +195,7 @@ Disabled options are not selectable or focusable. They are also skipped when usi
209195

210196
A common use case is the addition of options dynamically. For example, an infinite scrolling list of users.
211197

212-
<div data-testid="select-add-users-test">
213-
<Showcase name="add-users" />
214-
</div>
198+
<Showcase name="add-users" />
215199

216200
Clicking the `Add Users` button adds a couple new users mapped to the list. Taking this further, we could grab more data from the server and add it to the list, or even hitting a database to get more users.
217201

@@ -221,9 +205,7 @@ Clicking the `Add Users` button adds a couple new users mapped to the list. Taki
221205

222206
The select offers a typeahead feature that allows users to quickly find options by typing.
223207

224-
<div data-testid="select-typeahead-test">
225-
<Showcase name="typeahead" />
226-
</div>
208+
<Showcase name="typeahead" />
227209

228210
It reduces the need to scroll through the available options. Typeahead is particularly handy for expected data sets, such as a list of countries.
229211

@@ -233,29 +215,23 @@ It reduces the need to scroll through the available options. Typeahead is partic
233215

234216
We may want to handle the open / close of the listbox. For example, we may want to show a loading indicator when the listbox is open.
235217

236-
<div data-testid="select-open-change-test">
237-
<Showcase name="open-change" />
238-
</div>
218+
<Showcase name="open-change" />
239219

240220
To do that, we can use the `onOpenChange$` prop. A parameter is passed to the handler, which is a boolean indicating whether the listbox is open or closed.
241221

242222
### Looping
243223

244224
To loop through the options, we can use the `loop` boolean prop on the `<Select />` root component.
245225

246-
<div data-testid="select-loop-test">
247-
<Showcase name="loop" />
248-
</div>
226+
<Showcase name="loop" />
249227

250228
- Pressing the down arrow key will move focus to the first option in the list.
251229

252230
- Pressing the up arrow key will move focus to the last option in the list.
253231

254232
### Grouped options
255233

256-
<div data-testid="select-group-test">
257-
<Showcase name="group" />
258-
</div>
234+
<Showcase name="group" />
259235

260236
The `<SelectGroup />` and `<SelectLabel />` components are used to group and label options.
261237

@@ -265,9 +241,7 @@ Wrap the options in a group, add a Label, and you're good to go!
265241

266242
Because focus remains on the select trigger when the listbox is open, it's important to handle scrolling in the listbox.
267243

268-
<div data-testid="select-scroll-test">
269-
<Showcase name="scrollable" />
270-
</div>
244+
<Showcase name="scrollable" />
271245

272246
The native `scrollIntoView` method is used to scroll the options into view when the user highlights an option.
273247

@@ -279,16 +253,18 @@ To customize the scroll behavior, add the `scrollOptions` prop to the `<Select /
279253

280254
We can provide a custom placeholder to the `<SelectValue />` component by adding the `placeholder` prop.
281255

282-
<div data-testid="select-wrong-value-test">
283-
<Showcase name="wrong-value" />
284-
</div>
256+
<Showcase name="wrong-value" />
285257

286258
When a value is not selected, the placeholder is displayed. The example above shows a placeholder fallback when the value is not selected.
287259

288260
The consumer misspelled the the value, and so the fallback is displayed.
289261

290262
> Side note: would appreciate some help on TypeScript to make misspelled option values a thing of the past. Ideally, a union of the user's passed in options.
291263
264+
## Forms
265+
266+
<Showcase name="form" />
267+
292268
## Example CSS
293269

294270
Every code example uses the following CSS:

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

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
import { component$, useContext } from '@builder.io/qwik';
1010
import { Opt } from './select-inline';
1111
import SelectContextId from './select-context';
12+
import { VisuallyHidden } from '../../utils/visually-hidden';
1213

1314
export type AriaHiddenSelectProps = {
1415
/**
1516
* Describes the type of autocomplete functionality the input should provide if any. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete).
1617
*/
17-
autoComplete?: string;
18+
autoComplete?: AutoFill;
1819

1920
/** The text label for the select. */
2021
label?: string;
@@ -24,40 +25,46 @@ export type AriaHiddenSelectProps = {
2425

2526
/** Sets the disabled state of the select and input. */
2627
disabled?: boolean;
28+
29+
required?: boolean;
2730
};
2831

2932
export type SelectDataProps = {
3033
options: Opt[] | undefined;
3134
};
3235

33-
// export function useHiddenSelect(props: AriaHiddenSelectProps) {
34-
// const { autoComplete, name, disabled } = props;
35-
// }
36-
3736
export const HiddenSelect = component$(
3837
(props: AriaHiddenSelectProps & SelectDataProps) => {
39-
const { label, options } = props;
38+
const { label, options, autoComplete, name, required, disabled } = props;
4039
const context = useContext(SelectContextId);
4140

4241
// TODO: make conditional logic to show either input or select based on the size of the options.
4342
return (
44-
<div>
45-
<label>
46-
{label}
47-
<select>
48-
<option />
49-
{options?.map((opt: Opt) => (
50-
<option
51-
value={opt.value}
52-
selected={context.selectedIndexSig.value === opt.index}
53-
key={opt.value}
54-
>
55-
{opt.displayValue}
56-
</option>
57-
))}
58-
</select>
59-
</label>
60-
</div>
43+
<VisuallyHidden>
44+
<div aria-hidden="true">
45+
<label>
46+
{label}
47+
<select
48+
tabIndex={-1}
49+
autocomplete={autoComplete}
50+
disabled={disabled}
51+
required={required}
52+
name={name}
53+
>
54+
<option />
55+
{options?.map((opt: Opt) => (
56+
<option
57+
value={opt.value}
58+
selected={context.selectedIndexSig.value === opt.index}
59+
key={opt.value}
60+
>
61+
{opt.displayValue}
62+
</option>
63+
))}
64+
</select>
65+
</label>
66+
</div>
67+
</VisuallyHidden>
6168
);
6269
},
6370
);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export const SelectOption = component$<SelectOptionProps>((props) => {
103103
data-selected={isSelectedSig.value ? '' : undefined}
104104
data-highlighted={isHighlightedSig.value ? '' : undefined}
105105
data-disabled={disabled ? '' : undefined}
106+
data-option
106107
role="option"
107108
>
108109
<Slot />

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,24 @@ export function createTestDriver<T extends DriverLocator>(rootLocator: T) {
1515
return getRoot().getByRole('listbox');
1616
};
1717

18-
const getOptions = (options?: { evenIfHidden?: boolean }) => {
19-
return getRoot().getByRole('option', { includeHidden: options?.evenIfHidden });
18+
// we use data-option so that it doesn't grab native select options.
19+
const getOptions = () => {
20+
return getRoot().locator('[data-option]');
2021
};
2122

22-
const getOptionsLength = async (options?: { evenIfHidden?: boolean }) => {
23-
return getOptions({ evenIfHidden: options?.evenIfHidden }).count();
23+
const getOptionsLength = async () => {
24+
return getOptions().count();
2425
};
2526

2627
const getOptionAt = (index: number | 'last') => {
2728
if (index === 'last') return getOptions().last();
2829
return getOptions().nth(index);
30+
//
2931
};
3032

3133
const getHiddenOptionAt = (index: number | 'last') => {
32-
if (index === 'last') return getOptions({ evenIfHidden: true }).last();
33-
return getOptions({ evenIfHidden: true }).nth(index);
34+
if (index === 'last') return getOptions().last();
35+
return getOptions().nth(index);
3436
};
3537

3638
const getValueElement = () => {

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ test.describe('Mouse Behavior', () => {
105105

106106
await page.getByRole('button', { name: 'Add Users' }).click();
107107

108-
await expect(d.getOptions({ evenIfHidden: true })).toHaveCount(8);
108+
await expect(d.getOptions()).toHaveCount(8);
109109

110110
await d.openListbox('click');
111111
const expectedValue = 'Bob';
@@ -608,11 +608,9 @@ test.describe('Keyboard Behavior', () => {
608608
// ideally want to refactor this so that even if the test example is changed, the test will still pass, getting it more programmatically.
609609
const { getRoot, getTrigger } = await setup(page, 'hero');
610610
await getTrigger().focus();
611-
await getTrigger().press('j');
612-
const firstJOption = getRoot().getByRole('option', {
613-
name: 'Jim',
614-
includeHidden: true,
615-
});
611+
const char = 'j';
612+
await getTrigger().press(char);
613+
const firstJOption = getRoot().locator('li', { hasText: char }).nth(0);
616614
await expect(firstJOption).toHaveAttribute('aria-selected', 'true');
617615
await expect(firstJOption).toHaveAttribute('data-highlighted');
618616
});

0 commit comments

Comments
 (0)