Skip to content

Commit e3b8dd2

Browse files
Select fixes 0.6.5 (#1042)
* fix: select now shows first item instead of placeholder when initial value is set * fix: prevent native scrolling on focus in select * feat: initial scroll handling logic * feat: correct key navigation * fix: infinite scroll * comment out broken ci check * changeset
1 parent ac52aa1 commit e3b8dd2

File tree

13 files changed

+175
-66
lines changed

13 files changed

+175
-66
lines changed

.changeset/old-ladybugs-pump.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@qwik-ui/headless': patch
3+
---
4+
5+
Select fixes for:
6+
7+
- [#1001](https://github.com/qwikifiers/qwik-ui/issues/1001)
8+
- [#979](https://github.com/qwikifiers/qwik-ui/issues/979)
9+
10+
Also fixes for infinite scroll issues when both keyboard and mouse are used to navigate the listbox.

.github/workflows/test.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ jobs:
4747
env:
4848
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN is provided automatically in any repository
4949

50-
pagefind:
51-
runs-on: ubuntu-latest
52-
steps:
53-
- name: Generate
54-
run: npx pagefind --site dist/apps/website/client && cp -r dist/apps/website/client/pagefind apps/website/public/pagefind
50+
51+
# pagefind:
52+
# runs-on: ubuntu-latest
53+
# steps:
54+
# - name: Generate
55+
# run: npx pagefind --site dist/apps/website/client && cp -r dist/apps/website/client/pagefind apps/website/public/pagefind

apps/website/src/routes/docs/headless/combobox/examples/scrollable.tsx

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import { LuChevronDown } from '@qwikest/icons/lucide';
44

55
export default component$(() => {
66
useStyles$(styles);
7-
const activeUsers = ['Tim', 'Ryan', 'Jim', 'Abby'];
8-
const offlineUsers = ['Joey', 'Bob', 'Jack', 'John'];
7+
const items = Array.from({ length: 100 }, (_, i) => (i + 1).toString());
98

109
return (
1110
<Combobox.Root class="combobox-root">
@@ -17,23 +16,11 @@ export default component$(() => {
1716
</Combobox.Trigger>
1817
</Combobox.Control>
1918
<Combobox.Popover class="combobox-popover combobox-max-height" gutter={8}>
20-
<Combobox.Group>
21-
<Combobox.GroupLabel class="combobox-group-label">Active</Combobox.GroupLabel>
22-
{activeUsers.map((user) => (
23-
<Combobox.Item key={user}>
24-
<Combobox.ItemLabel>{user}</Combobox.ItemLabel>
25-
</Combobox.Item>
26-
))}
27-
</Combobox.Group>
28-
29-
<Combobox.Group>
30-
<Combobox.GroupLabel class="combobox-group-label">Offline</Combobox.GroupLabel>
31-
{offlineUsers.map((user) => (
32-
<Combobox.Item key={user}>
33-
<Combobox.ItemLabel>{user}</Combobox.ItemLabel>
34-
</Combobox.Item>
35-
))}
36-
</Combobox.Group>
19+
{items.map((item) => (
20+
<Combobox.Item key={item}>
21+
<Combobox.ItemLabel>{item}</Combobox.ItemLabel>
22+
</Combobox.Item>
23+
))}
3724
</Combobox.Popover>
3825
</Combobox.Root>
3926
);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export const api = {
2+
select: [
3+
{
4+
'hidden-select-option': [],
5+
},
6+
{
7+
'hidden-select': [],
8+
},
9+
{
10+
'select-description': [],
11+
},
12+
{
13+
'select-display-value': [],
14+
},
15+
{
16+
'select-error-message': [],
17+
},
18+
{
19+
'select-group-label': [],
20+
},
21+
{
22+
'select-group': [],
23+
},
24+
{
25+
'select-inline': [],
26+
},
27+
{
28+
'select-item-indicator': [],
29+
},
30+
{
31+
'select-item-label': [],
32+
},
33+
{
34+
'select-item': [],
35+
},
36+
{
37+
'select-label': [],
38+
},
39+
{
40+
'select-listbox': [],
41+
},
42+
{
43+
'select-popover': [],
44+
},
45+
{
46+
'select-root': [],
47+
},
48+
{
49+
'select-trigger': [],
50+
},
51+
{
52+
'use-select': [],
53+
},
54+
],
55+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { component$, useStyles$ } from '@builder.io/qwik';
2+
import { Select } from '@qwik-ui/headless';
3+
4+
export default component$(() => {
5+
useStyles$(styles);
6+
const users = ['Tim', 'Ryan', 'Jim', 'Jessie', 'Abby'];
7+
8+
return (
9+
<Select.Root value="Tim" class="select">
10+
<Select.Label>Logged in users</Select.Label>
11+
<Select.Trigger class="select-trigger">
12+
<Select.DisplayValue placeholder="Select an option" />
13+
</Select.Trigger>
14+
<Select.Popover class="select-popover">
15+
{users.map((user) => (
16+
<Select.Item key={user}>
17+
<Select.ItemLabel>{user}</Select.ItemLabel>
18+
</Select.Item>
19+
))}
20+
</Select.Popover>
21+
</Select.Root>
22+
);
23+
});
24+
25+
// internal
26+
import styles from '../snippets/select.css?inline';

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

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,19 @@ import { Select } from '@qwik-ui/headless';
33

44
export default component$(() => {
55
useStyles$(styles);
6-
const users = ['Tim', 'Ryan', 'Jim', 'Jessie', 'Abby'];
7-
const animals = ['Dog', 'Cat', 'Bird', 'Fish', 'Snake'];
6+
const items = Array.from({ length: 100 }, (_, i) => (i + 1).toString());
87

98
return (
109
<Select.Root class="select">
1110
<Select.Trigger class="select-trigger">
1211
<Select.DisplayValue placeholder="Select an option" />
1312
</Select.Trigger>
1413
<Select.Popover class="select-popover select-max-height">
15-
<Select.Group>
16-
<Select.GroupLabel class="select-label">People</Select.GroupLabel>
17-
{users.map((user) => (
18-
<Select.Item key={user}>
19-
<Select.ItemLabel>{user}</Select.ItemLabel>
20-
</Select.Item>
21-
))}
22-
</Select.Group>
23-
<Select.Group>
24-
<Select.GroupLabel class="select-label">Animals</Select.GroupLabel>
25-
{animals.map((animal) => (
26-
<Select.Item key={animal}>
27-
<Select.ItemLabel>{animal}</Select.ItemLabel>
28-
</Select.Item>
29-
))}
30-
</Select.Group>
14+
{items.map((item) => (
15+
<Select.Item key={item} class="highlight-hover">
16+
<Select.ItemLabel>{item}</Select.ItemLabel>
17+
</Select.Item>
18+
))}
3119
</Select.Popover>
3220
</Select.Root>
3321
);

packages/kit-headless/src/components/combobox/combobox-item.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export const HComboboxItem = component$((props: HComboboxItemProps) => {
141141

142142
const observer = new IntersectionObserver(checkVisibility$, {
143143
root: context.panelRef.value,
144-
threshold: 1.0,
144+
threshold: 0,
145145
});
146146

147147
cleanup(() => observer?.disconnect());

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export type SelectContext = {
2121
selectedIndexSetSig: Signal<Set<number>>;
2222
highlightedIndexSig: Signal<number | null>;
2323
currDisplayValueSig: Signal<string | string[] | undefined>;
24+
isKeyboardFocusSig: Signal<boolean>;
25+
isMouseOverPopupSig: Signal<boolean>;
2426
isListboxOpenSig: Signal<boolean>;
2527
isDisabledSig: Signal<boolean>;
2628
localId: string;

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

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ import {
1212
useOnWindow,
1313
QRL,
1414
} from '@builder.io/qwik';
15-
import { isServer } from '@builder.io/qwik/build';
1615
import SelectContextId, {
1716
SelectItemContext,
1817
selectItemContextId,
1918
} from './select-context';
2019
import { useSelect } from './use-select';
2120
import { useCombinedRef } from '../../hooks/combined-refs';
21+
import { isServer } from '@builder.io/qwik/build';
2222

2323
export type SelectItemProps = PropsOf<'div'> & {
2424
/** Internal index we get from the inline component. Please see select-inline.tsx */
@@ -39,6 +39,7 @@ export const HSelectItem = component$<SelectItemProps>((props) => {
3939
const localIndexSig = useSignal<number | null>(null);
4040
const itemId = `${context.localId}-${_index}`;
4141
const typeaheadFnSig = useSignal<QRL<(key: string) => Promise<void>>>();
42+
const debounceTimeoutSig = useSignal<NodeJS.Timeout>();
4243

4344
const { selectionManager$, getNextEnabledItemIndex$, getPrevEnabledItemIndex$ } =
4445
useSelect();
@@ -60,7 +61,7 @@ export const HSelectItem = component$<SelectItemProps>((props) => {
6061
if (disabled) return;
6162

6263
if (context.highlightedIndexSig.value === _index) {
63-
itemRef.value?.focus();
64+
itemRef.value?.focus({ preventScroll: true });
6465
return true;
6566
} else {
6667
return false;
@@ -81,17 +82,20 @@ export const HSelectItem = component$<SelectItemProps>((props) => {
8182
const containerRect = context.popoverRef.value?.getBoundingClientRect();
8283
const itemRect = itemRef.value?.getBoundingClientRect();
8384

84-
if (!containerRect || !itemRect) return;
85+
if (!containerRect || !itemRect || !context.isKeyboardFocusSig.value) return;
8586

8687
// Calculates the offset to center the item within the container
8788
const offset =
8889
itemRect.top - containerRect.top - containerRect.height / 2 + itemRect.height / 2;
8990

90-
context.popoverRef.value?.scrollBy({ top: offset, ...context.scrollOptions });
91+
context.popoverRef.value?.scrollBy({
92+
top: document.hasFocus() ? offset : undefined,
93+
...context.scrollOptions,
94+
});
9195
}
9296
});
9397

94-
useTask$(async function navigationTask({ track, cleanup }) {
98+
useTask$(function handleScrolling({ track, cleanup }) {
9599
track(() => context.highlightedIndexSig.value);
96100

97101
// update the context with the highlighted item ref
@@ -100,25 +104,36 @@ export const HSelectItem = component$<SelectItemProps>((props) => {
100104
}
101105

102106
if (isServer || !context.popoverRef.value) return;
103-
if (_index !== context.highlightedIndexSig.value) return;
104107

105108
const hasScrollbar =
106109
context.popoverRef.value.scrollHeight > context.popoverRef.value.clientHeight;
107110

108-
if (!hasScrollbar) {
109-
return;
111+
if (!hasScrollbar) return;
112+
113+
if (debounceTimeoutSig.value !== undefined) {
114+
clearTimeout(debounceTimeoutSig.value);
110115
}
111116

112-
const observer = new IntersectionObserver(checkVisibility$, {
113-
root: context.popoverRef.value,
114-
threshold: 1.0,
115-
});
117+
debounceTimeoutSig.value = setTimeout(() => {
118+
if (props._index !== context.highlightedIndexSig.value) return;
116119

117-
cleanup(() => observer?.disconnect());
120+
const observer = new IntersectionObserver(checkVisibility$, {
121+
root: context.popoverRef.value,
122+
threshold: 0,
123+
});
118124

119-
if (itemRef.value) {
120-
observer.observe(itemRef.value);
121-
}
125+
cleanup(() => observer?.disconnect());
126+
127+
if (itemRef.value) {
128+
observer.observe(itemRef.value);
129+
}
130+
}, 100);
131+
132+
cleanup(() => {
133+
if (debounceTimeoutSig.value !== undefined) {
134+
clearTimeout(debounceTimeoutSig.value);
135+
}
136+
});
122137
});
123138

124139
const handleClick$ = $(async () => {
@@ -167,6 +182,7 @@ export const HSelectItem = component$<SelectItemProps>((props) => {
167182

168183
const handleKeyDown$ = $(async (e: KeyboardEvent) => {
169184
typeaheadFnSig.value?.(e.key);
185+
context.isKeyboardFocusSig.value = true;
170186

171187
switch (e.key) {
172188
case 'ArrowDown':

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export const HSelectPopover = component$<PropsOf<typeof HPopoverRoot>>((props) =
8585
initialLoadSig.value = false;
8686
});
8787

88+
const handleMouseEnter$ = $(() => {
89+
context.isKeyboardFocusSig.value = false;
90+
context.isMouseOverPopupSig.value = true;
91+
});
92+
8893
return (
8994
<HPopoverRoot
9095
floating={floating}
@@ -105,6 +110,7 @@ export const HSelectPopover = component$<PropsOf<typeof HPopoverRoot>>((props) =
105110
aria-expanded={context.isListboxOpenSig.value ? 'true' : undefined}
106111
aria-multiselectable={context.multiple ? 'true' : undefined}
107112
aria-labelledby={triggerId}
113+
onMouseEnter$={[handleMouseEnter$, props.onMouseEnter$]}
108114
{...rest}
109115
>
110116
<Slot />

0 commit comments

Comments
 (0)