Skip to content

Commit 0b890cc

Browse files
feat(select): scrollable features, and config option
1 parent 0f359ce commit 0b890cc

File tree

8 files changed

+114
-5
lines changed

8 files changed

+114
-5
lines changed

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ import {
1010
} from '@qwik-ui/headless';
1111

1212
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();
13+
const usersSig = useSignal<string[]>(['Tim', 'Ryan', 'Jim', 'Abby']);
14+
const animalsSig = useSignal<string[]>(['Dog', 'Cat', 'Bird']);
1715

1816
return (
1917
<Select class="relative min-w-40">
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
16+
return (
17+
<Select class="relative min-w-40">
18+
<SelectTrigger class="w-full border-2 border-dashed border-red-400">
19+
<SelectValue placeholder="Select an option" />
20+
</SelectTrigger>
21+
<SelectListbox class="absolute z-10 max-h-60 w-full overflow-y-auto border-2 border-dashed border-green-400 bg-slate-900 p-2">
22+
<SelectGroup>
23+
<SelectLabel class="text-sm text-slate-400">People</SelectLabel>
24+
{usersSig.value.map((user) => (
25+
<SelectOption
26+
class="border-dashed border-blue-400 data-[highlighted]:border-2"
27+
key={user}
28+
>
29+
{user}
30+
</SelectOption>
31+
))}
32+
</SelectGroup>
33+
<SelectGroup>
34+
<SelectLabel class="text-sm text-slate-400">Animals</SelectLabel>
35+
{animalsSig.value.map((animal) => (
36+
<SelectOption
37+
class="border-dashed border-blue-400 data-[highlighted]:border-2"
38+
key={animal}
39+
>
40+
{animal}
41+
</SelectOption>
42+
))}
43+
</SelectGroup>
44+
</SelectListbox>
45+
</Select>
46+
);
47+
});

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ This element is used to create a drop-down list, it's often used in a form, to c
7070
<Showcase name="group" />
7171
</div>
7272

73+
## Scrolling
74+
75+
<div data-testid="select-scroll-test">
76+
<Showcase name="scrollable" />
77+
</div>
78+
79+
> Qwik UI does not provide virtual scrolling. However, we have found a [Qwik community member's package](https://github.com/literalpie/qwik-virtual-scroll) that has an implementation of virtual scrolling for Qwik based on [Tanstack Virtual](https://github.com/TanStack/virtual).
80+
7381
## Building blocks
7482

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

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,21 @@ test.describe('Keyboard Behavior', () => {
569569
await expect(highlightedOpt).toContainText('dog', { ignoreCase: true });
570570
});
571571
});
572+
573+
test(`GIVEN an open select with multiple groups and a scrollable listbox
574+
AND the last option is not visible
575+
WHEN the end key is pressed
576+
THEN the last option should be visible`, async ({ page }) => {
577+
const { getTrigger, getRoot, openListbox } = await setup(page, 'select-scroll-test');
578+
579+
await openListbox('Enter');
580+
581+
await getTrigger().focus();
582+
await getTrigger().press('End');
583+
const lastOption = getRoot().getByRole('option', { includeHidden: false }).last();
584+
585+
await expect(lastOption).toBeInViewport();
586+
});
572587
});
573588

574589
test.describe('Disabled', () => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ What do they all have in common? How do people use them? What are the most impor
3535
- [x] Grouped options
3636
- [x] Typeahead support (user typing / filter)
3737
- [ ] RTL support
38-
- [ ] Scrollable
38+
- [x] Scrollable
3939
- [ ] Aria (controls, roles, etc)
4040

4141
## Props:

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export type SelectContext = {
1919
highlightedIndexSig: Signal<number | null>;
2020
isListboxOpenSig: Signal<boolean>;
2121
selectedIndexSig: Signal<number | null>;
22+
23+
// user configurable
24+
scrollOptions?: ScrollIntoViewOptions;
2225
};
2326

2427
export const groupContextId = createContextId<GroupContext>('Select-Group');

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
useComputed$,
1010
} from '@builder.io/qwik';
1111
import SelectContextId from './select-context';
12+
import { isServer } from '@builder.io/qwik/build';
1213

1314
export type SelectOptionProps = PropsOf<'li'> & {
1415
index?: number;
@@ -52,6 +53,36 @@ export const SelectOption = component$<SelectOptionProps>((props) => {
5253
}
5354
});
5455

56+
useTask$(function scrollableTask({ track, cleanup }) {
57+
track(() => context.highlightedIndexSig.value);
58+
59+
if (isServer) return;
60+
61+
let observer: IntersectionObserver;
62+
63+
const checkVisibility = (entries: IntersectionObserverEntry[]) => {
64+
const [entry] = entries;
65+
66+
// if the option is not visible, scroll it into view
67+
if (isHighlightedSig.value && !entry.isIntersecting) {
68+
optionRef.value?.scrollIntoView(context.scrollOptions);
69+
}
70+
};
71+
72+
cleanup(() => observer?.disconnect());
73+
74+
if (typeof window !== 'undefined') {
75+
observer = new IntersectionObserver(checkVisibility, {
76+
root: context.listboxRef.value,
77+
threshold: 1.0,
78+
});
79+
80+
if (optionRef.value) {
81+
observer.observe(optionRef.value);
82+
}
83+
}
84+
});
85+
5586
return (
5687
<li
5788
{...rest}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export type SelectProps = PropsOf<'div'> & {
2626

2727
onChange$?: QRL<() => void>;
2828
onOpenChange$?: QRL<() => void>;
29+
30+
scrollOptions?: ScrollIntoViewOptions;
2931
};
3032

3133
/* root component in select-inline.tsx */
@@ -56,6 +58,10 @@ export const SelectImpl = component$<SelectProps>((props) => {
5658
const selectedIndexSig = useSignal<number | null>(props._valuePropIndex ?? null);
5759
const highlightedIndexSig = useSignal<number | null>(props._valuePropIndex ?? null);
5860
const isListboxOpenSig = useSignal<boolean>(false);
61+
const scrollOptions = props.scrollOptions ?? {
62+
behavior: 'instant',
63+
block: 'nearest',
64+
};
5965

6066
// Maps are apparently great for this index accessing. Will learn more about them this week and refactor this to have a more consistent API and eliminate redundancy / duplication.
6167
useTask$(function controlledValueTask({ track }) {
@@ -93,6 +99,7 @@ export const SelectImpl = component$<SelectProps>((props) => {
9399
highlightedIndexSig,
94100
isListboxOpenSig,
95101
selectedIndexSig,
102+
scrollOptions,
96103
};
97104

98105
useContextProvider(SelectContextId, context);

0 commit comments

Comments
 (0)