Skip to content

Commit a3a2489

Browse files
Merge pull request #635 from thejackshelton/feat/select
Feat/select
2 parents e3bf9d3 + 875d197 commit a3a2489

File tree

13 files changed

+225
-50
lines changed

13 files changed

+225
-50
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { $, component$, useSignal, useStyles$ } from '@builder.io/qwik';
2+
import {
3+
Select,
4+
SelectListbox,
5+
SelectOption,
6+
SelectTrigger,
7+
SelectValue,
8+
} from '@qwik-ui/headless';
9+
import styles from './select.css?inline';
10+
export default component$(() => {
11+
useStyles$(styles);
12+
const users = ['Tim', 'Ryan', 'Jim', 'Jessie', 'Abby'];
13+
const selected = useSignal<string>('Ryan');
14+
15+
return (
16+
<>
17+
<Select
18+
onChange$={$((value: string) => {
19+
selected.value = value;
20+
})}
21+
bind:value={selected}
22+
class="select"
23+
>
24+
<SelectTrigger class="select-trigger">
25+
<SelectValue placeholder="Select an option" />
26+
</SelectTrigger>
27+
<SelectListbox class="select-listbox">
28+
{users.map((user, index) => (
29+
<SelectOption value={index.toString()} class="select-option" key={user}>
30+
{user}
31+
</SelectOption>
32+
))}
33+
</SelectListbox>
34+
</Select>
35+
<button onClick$={$(() => (selected.value = '4'))}>Change to Abby</button>
36+
</>
37+
);
38+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default component$(() => {
1616
<>
1717
<Select
1818
onChange$={$((value: string) => {
19+
console.log('value: ', value);
1920
selected.value = value;
2021
})}
2122
bind:value={selected}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { $, component$, useSignal, useStyles$ } from '@builder.io/qwik';
2+
import {
3+
Select,
4+
SelectListbox,
5+
SelectOption,
6+
SelectTrigger,
7+
SelectValue,
8+
} from '@qwik-ui/headless';
9+
import styles from './select.css?inline';
10+
export default component$(() => {
11+
useStyles$(styles);
12+
const users = ['Tim', 'Ryan', 'Jim', 'Jessie', 'Abby'];
13+
const selected = useSignal<string | null>(null);
14+
15+
const handleChange$ = $((value: string) => {
16+
selected.value = value;
17+
});
18+
19+
return (
20+
<>
21+
<Select onChange$={handleChange$} class="select">
22+
<SelectTrigger class="select-trigger">
23+
<SelectValue placeholder="Select an option" />
24+
</SelectTrigger>
25+
<SelectListbox class="select-listbox">
26+
{users.map((user, index) => (
27+
<SelectOption value={index.toString()} class="select-option" key={user}>
28+
{user}
29+
</SelectOption>
30+
))}
31+
</SelectListbox>
32+
</Select>
33+
<p>The selected value is: {selected.value ?? 'null'}</p>
34+
</>
35+
);
36+
});

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ Reveals a list of options to choose from, often triggered by a button.
4040
<Showcase name="controlled" />
4141
</div>
4242

43+
## Passing a value
44+
45+
<div data-testid="select-option-value-test">
46+
<Showcase name="option-value" />
47+
</div>
48+
49+
## Controlled + The value prop
50+
51+
<div data-testid="select-controlled-value-test">
52+
<Showcase name="controlled-value" />
53+
</div>
54+
4355
## Adding Users
4456

4557
<div data-testid="select-add-users-test">

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,20 @@ test.describe('Mouse Behavior', () => {
117117
// await getTrigger().blur();
118118
// await expect(getTrigger()).toBeFocused();
119119
// });
120+
121+
test(`GIVEN an open hero select
122+
WHEN clikcking on the group label
123+
THEN the listbox should remain open`, async ({ page }) => {
124+
const { getRoot, openListbox, getListbox } = await setup(page, 'select-group-test');
125+
126+
await openListbox('click');
127+
128+
const label = getRoot().getByRole('listitem').first();
129+
130+
await expect(label).toBeVisible();
131+
await label.click();
132+
await expect(getListbox()).toBeVisible();
133+
});
120134
});
121135

122136
test.describe('Keyboard Behavior', () => {
@@ -1058,6 +1072,40 @@ test.describe('Props', () => {
10581072
await expect(options[1]).toHaveAttribute('aria-selected', 'true');
10591073
});
10601074
});
1075+
1076+
test.describe('option value', () => {
1077+
test(`GIVEN a select with distinct display and option values
1078+
WHEN the 2nd option is selected
1079+
THEN the selected value matches the 2nd option's value`, async ({ page }) => {
1080+
const { openListbox, getTrigger, getRoot } = await setup(
1081+
page,
1082+
'select-option-value-test',
1083+
);
1084+
1085+
await openListbox('Enter');
1086+
const changeStr = getRoot().locator('+ p');
1087+
await expect(changeStr).toContainText('The selected value is: null');
1088+
await getTrigger().focus();
1089+
await getTrigger().press('ArrowDown');
1090+
await getTrigger().press('Enter');
1091+
1092+
await expect(changeStr).toContainText('The selected value is: 1');
1093+
});
1094+
1095+
test(`GIVEN a select with distinct display and option values
1096+
WHEN a controlled value is set to the 5th option
1097+
THEN the selected value matches the 5th option's value`, async ({ page }) => {
1098+
const { getTrigger, getRoot, getOptions } = await setup(
1099+
page,
1100+
'select-controlled-value-test',
1101+
);
1102+
1103+
await expect(getTrigger()).toHaveText('Select an option');
1104+
await getRoot().locator('+ button').click();
1105+
const options = await getOptions();
1106+
await expect(getTrigger()).toHaveText(`${await options[4].textContent()}`);
1107+
});
1108+
});
10611109
});
10621110

10631111
test.describe('A11y', () => {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ resource: https://joshwayne.com/posts/the-problem-with-dropdowns/
6262
- [x] Typeahead support (user typing / filter)
6363
- [x] Looping
6464
- [ ] RTL support
65+
- [ ] Form support
6566
- [x] Scrollable
6667
- [x] Aria (controls, roles, etc)
6768

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type JSXNode, type FunctionComponent } from '@builder.io/qwik';
1+
import { type JSXNode, Component } from '@builder.io/qwik';
22
import { SelectImpl, type SelectProps } from './select';
33
import { SelectListbox } from './select-listbox';
44
import { SelectOption } from './select-option';
@@ -7,13 +7,14 @@ import { SelectGroup } from './select-group';
77
export type Opt = {
88
isDisabled: boolean;
99
value: string;
10+
displayValue?: string;
1011
};
1112

1213
/*
1314
This is an inline component. We create an inline component to get the proper indexes with CSR. See issue #4757
1415
for more information.
1516
*/
16-
export const Select: FunctionComponent<SelectProps> = (props) => {
17+
export const Select: Component<SelectProps> = (props: SelectProps) => {
1718
const { children: myChildren, ...rest } = props;
1819
let valuePropIndex = null;
1920
const childrenToProcess = (
@@ -65,15 +66,22 @@ export const Select: FunctionComponent<SelectProps> = (props) => {
6566
.props.children}.`,
6667
);
6768
}
69+
6870
child.props.index = currentIndex;
71+
const isDisabled = child.props.disabled === true;
72+
const value = (
73+
child.props.value ? child.props.value : child.props.children
74+
) as string;
75+
6976
const opt: Opt = {
70-
isDisabled: child.props.disabled === true,
71-
value: child.props.children as string,
77+
isDisabled,
78+
value,
79+
displayValue: child.props.children as string,
7280
};
7381

7482
opts.push(opt);
7583

76-
if (child.props.children === props.value) {
84+
if (value === props.value) {
7785
valuePropIndex = currentIndex;
7886
}
7987

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { component$, useStyles$, Slot, type PropsOf, useContext } from '@builder.io/qwik';
1+
import {
2+
component$,
3+
useStyles$,
4+
Slot,
5+
type PropsOf,
6+
useContext,
7+
useOnDocument,
8+
$,
9+
} from '@builder.io/qwik';
210
import SelectContextId from './select-context';
311
import styles from './select.css?inline';
412

@@ -9,6 +17,34 @@ export const SelectListbox = component$<SelectListboxProps>((props) => {
917

1018
const context = useContext(SelectContextId);
1119
const listboxId = `${context.localId}-listbox`;
20+
21+
const isOutside = $((rect: DOMRect, x: number, y: number) => {
22+
return x < rect.left || x > rect.right || y < rect.top || y > rect.bottom;
23+
});
24+
25+
const handleDismiss$ = $(async (e: PointerEvent) => {
26+
if (!context.isListboxOpenSig.value) {
27+
return;
28+
}
29+
30+
if (!context.listboxRef.value || !context.triggerRef.value) {
31+
return;
32+
}
33+
34+
const listboxRect = context.listboxRef.value.getBoundingClientRect();
35+
const triggerRect = context.triggerRef.value.getBoundingClientRect();
36+
const { clientX, clientY } = e;
37+
38+
const isOutsideListbox = await isOutside(listboxRect, clientX, clientY);
39+
const isOutsideTrigger = await isOutside(triggerRect, clientX, clientY);
40+
41+
if (isOutsideListbox && isOutsideTrigger) {
42+
context.isListboxOpenSig.value = false;
43+
}
44+
});
45+
46+
useOnDocument('pointerdown', handleDismiss$);
47+
1248
return (
1349
<ul
1450
{...props}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import {
88
useTask$,
99
type PropsOf,
1010
} from '@builder.io/qwik';
11-
import { isServer } from '@builder.io/qwik/build';
11+
import { isServer, isBrowser } from '@builder.io/qwik/build';
1212
import SelectContextId from './select-context';
1313

1414
export type SelectOptionProps = PropsOf<'li'> & {
1515
index?: number;
1616
disabled?: boolean;
17+
value?: string;
1718
};
1819

1920
export const SelectOption = component$<SelectOptionProps>((props) => {
@@ -24,6 +25,8 @@ export const SelectOption = component$<SelectOptionProps>((props) => {
2425
const localIndexSig = useSignal<number | null>(null);
2526
const optionId = `${context.localId}-${index}`;
2627

28+
console.log('value: ', props.value);
29+
2730
const isSelectedSig = useComputed$(() => {
2831
return !disabled && context.selectedIndexSig.value === index;
2932
});
@@ -57,7 +60,7 @@ export const SelectOption = component$<SelectOptionProps>((props) => {
5760

5861
cleanup(() => observer?.disconnect());
5962

60-
if (typeof window !== 'undefined') {
63+
if (isBrowser) {
6164
observer = new IntersectionObserver(checkVisibility, {
6265
root: context.listboxRef.value,
6366
threshold: 1.0,

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

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
117117
}
118118

119119
if (context.isListboxOpenSig.value && !shouldOpen) {
120-
console.log('heyyy');
120+
if (e.key === 'Tab') {
121+
context.isListboxOpenSig.value = false;
122+
}
123+
121124
// select options
122125
if (e.key === 'Enter' || e.key === ' ') {
123126
context.selectedIndexSig.value = context.highlightedIndexSig.value;
@@ -145,26 +148,16 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
145148
}
146149
});
147150

148-
const handleBlur$ = $((event: FocusEvent) => {
149-
const focusOutsideListbox = !context.listboxRef.value?.contains(
150-
event.relatedTarget as Element,
151-
);
152-
153-
if (focusOutsideListbox) {
154-
context.isListboxOpenSig.value = false;
155-
}
156-
});
157-
158151
return (
159152
<button
160153
{...props}
161154
ref={context.triggerRef}
162155
onClick$={[handleClick$, props.onClick$]}
163156
onKeyDown$={[handleKeyDownSync$, handleKeyDown$, props.onKeyDown$]}
164-
onBlur$={[handleBlur$, props.onBlur$]}
165157
data-open={context.isListboxOpenSig.value ? '' : undefined}
166158
data-closed={!context.isListboxOpenSig.value ? '' : undefined}
167159
aria-expanded={context.isListboxOpenSig.value}
160+
preventdefault:blur
168161
>
169162
<Slot />
170163
</button>

0 commit comments

Comments
 (0)