Skip to content

Commit 2440215

Browse files
feat(select): better dismiss handling
1 parent 9ea6365 commit 2440215

File tree

7 files changed

+84
-43
lines changed

7 files changed

+84
-43
lines changed

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

Lines changed: 14 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', () => {

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: 2 additions & 2 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';
@@ -13,7 +13,7 @@ export type Opt = {
1313
This is an inline component. We create an inline component to get the proper indexes with CSR. See issue #4757
1414
for more information.
1515
*/
16-
export const Select: FunctionComponent<SelectProps> = (props) => {
16+
export const Select: Component<SelectProps> = (props: SelectProps) => {
1717
const { children: myChildren, ...rest } = props;
1818
let valuePropIndex = null;
1919
const childrenToProcess = (

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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ 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'> & {
@@ -57,7 +57,7 @@ export const SelectOption = component$<SelectOptionProps>((props) => {
5757

5858
cleanup(() => observer?.disconnect());
5959

60-
if (typeof window !== 'undefined') {
60+
if (isBrowser) {
6161
observer = new IntersectionObserver(checkVisibility, {
6262
root: context.listboxRef.value,
6363
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>

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

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { Opt } from './select-inline';
1616
import { isBrowser } from '@builder.io/qwik/build';
1717
import { getActiveDescendant } from './utils';
1818

19-
export type SelectProps = Omit<PropsOf<'div'>, 'onChange$'> & {
19+
export type SelectProps = PropsOf<'div'> & {
2020
value?: string;
2121
'bind:value'?: Signal<string>;
2222

@@ -34,7 +34,7 @@ export type SelectProps = Omit<PropsOf<'div'>, 'onChange$'> & {
3434
};
3535

3636
/* root component in select-inline.tsx */
37-
export const SelectImpl = component$<SelectProps>((props) => {
37+
export const SelectImpl = component$<SelectProps>((props: SelectProps) => {
3838
// refs
3939
const rootRef = useSignal<HTMLDivElement>();
4040
const triggerRef = useSignal<HTMLButtonElement>();
@@ -113,29 +113,26 @@ export const SelectImpl = component$<SelectProps>((props) => {
113113
useContextProvider(SelectContextId, context);
114114

115115
return (
116-
<>
117-
{/* @ts-expect-error Qwik expects onChange$ types */}
118-
<div
119-
role="combobox"
120-
ref={rootRef}
121-
data-open={context.isListboxOpenSig.value ? '' : undefined}
122-
data-closed={!context.isListboxOpenSig.value ? '' : undefined}
123-
aria-activedescendant={
124-
context.isListboxOpenSig.value
125-
? getActiveDescendant(
126-
context.highlightedIndexSig.value ?? -1,
127-
context.optionsSig.value,
128-
context.localId,
129-
)
130-
: ''
131-
}
132-
aria-controls={listboxId}
133-
aria-expanded={context.isListboxOpenSig.value}
134-
aria-haspopup="listbox"
135-
{...props}
136-
>
137-
<Slot />
138-
</div>
139-
</>
116+
<div
117+
role="combobox"
118+
ref={rootRef}
119+
data-open={context.isListboxOpenSig.value ? '' : undefined}
120+
data-closed={!context.isListboxOpenSig.value ? '' : undefined}
121+
aria-activedescendant={
122+
context.isListboxOpenSig.value
123+
? getActiveDescendant(
124+
context.highlightedIndexSig.value ?? -1,
125+
context.optionsSig.value,
126+
context.localId,
127+
)
128+
: ''
129+
}
130+
aria-controls={listboxId}
131+
aria-expanded={context.isListboxOpenSig.value}
132+
aria-haspopup="listbox"
133+
{...props}
134+
>
135+
<Slot />
136+
</div>
140137
);
141138
});

0 commit comments

Comments
 (0)