Skip to content

Commit 6098f49

Browse files
feat(select): looped options
1 parent b95bfbb commit 6098f49

File tree

8 files changed

+287
-30
lines changed

8 files changed

+287
-30
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { component$, useSignal } from '@builder.io/qwik';
2+
import {
3+
Select,
4+
SelectListbox,
5+
SelectOption,
6+
SelectTrigger,
7+
SelectValue,
8+
} from '@qwik-ui/headless';
9+
10+
export default component$(() => {
11+
const usersSig = useSignal<string[]>(['Tim', 'Ryan', 'Jim', 'Jessie', 'Abby']);
12+
13+
return (
14+
<Select loop={true} class="relative min-w-40">
15+
<SelectTrigger class="w-full border-2 border-dashed border-red-400">
16+
<SelectValue placeholder="Select an option" />
17+
</SelectTrigger>
18+
<SelectListbox class="absolute w-full border-2 border-dashed border-green-400 bg-slate-900 p-2">
19+
{usersSig.value.map((user) => (
20+
<SelectOption
21+
class="border-dashed border-blue-400 data-[highlighted]:border-2"
22+
key={user}
23+
>
24+
{user}
25+
</SelectOption>
26+
))}
27+
</SelectListbox>
28+
</Select>
29+
);
30+
});

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ This element is used to create a drop-down list, it's often used in a form, to c
7878

7979
> 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).
8080
81+
## Looping
82+
83+
<div data-testid="select-loop-test">
84+
<Showcase name="loop" />
85+
</div>
86+
8187
## Building blocks
8288

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

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Locator, Page, expect } from '@playwright/test';
2-
import { type OpenKeys } from '@qwik-ui/headless';
2+
type OpenKeys = 'ArrowUp' | 'Enter' | 'Space' | 'ArrowDown';
33
export type DriverLocator = Locator | Page;
44

55
export function createTestDriver<T extends DriverLocator>(locator: T) {

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

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,196 @@ test.describe('Keyboard Behavior', () => {
623623
});
624624
});
625625

626+
test.describe('looping', () => {
627+
test.describe('loop disabled', () => {
628+
test(`GIVEN an open basic select
629+
AND the last option is data-highlighted
630+
WHEN the down key is pressed
631+
THEN data-highlighted should stay on the last option`, async ({ page }) => {
632+
const { getTrigger, getOptions, openListbox } = await setup(
633+
page,
634+
'select-hero-test',
635+
);
636+
637+
// initially last option is highlighted
638+
await openListbox('Enter');
639+
await getTrigger().focus();
640+
await getTrigger().press('End');
641+
const options = await getOptions();
642+
await expect(options[options.length - 1]).toHaveAttribute('data-highlighted');
643+
644+
await getTrigger().focus();
645+
await getTrigger().press('ArrowDown');
646+
await expect(options[options.length - 1]).toHaveAttribute('data-highlighted');
647+
});
648+
649+
test(`GIVEN an open basic select
650+
AND the first option is data-highlighted
651+
WHEN the up arrow key is pressed
652+
THEN data-highlighted should stay on the first option`, async ({ page }) => {
653+
const { getTrigger, getOptions, openListbox } = await setup(
654+
page,
655+
'select-hero-test',
656+
);
657+
658+
await openListbox('Enter');
659+
const options = await getOptions();
660+
await expect(options[0]).toHaveAttribute('data-highlighted');
661+
await getTrigger().focus();
662+
await getTrigger().press('ArrowUp');
663+
await expect(options[0]).toHaveAttribute('data-highlighted');
664+
});
665+
666+
test(`GIVEN a closed basic select
667+
AND the last option is selected
668+
WHEN the right arrow key is pressed
669+
THEN it should stay on the last option`, async ({ page }) => {
670+
const { getTrigger, getOptions, getListbox, openListbox } = await setup(
671+
page,
672+
'select-hero-test',
673+
);
674+
675+
// initially last option is highlighted & listbox closed
676+
await openListbox('Enter');
677+
await getTrigger().focus();
678+
await getTrigger().press('End');
679+
const options = await getOptions();
680+
await expect(options[options.length - 1]).toHaveAttribute('data-highlighted');
681+
await getTrigger().press('Enter');
682+
await expect(options[options.length - 1]).toHaveAttribute(
683+
'aria-selected',
684+
'true',
685+
);
686+
await expect(getListbox()).toBeHidden();
687+
688+
await getTrigger().focus();
689+
await getTrigger().press('ArrowRight');
690+
await expect(options[options.length - 1]).toHaveAttribute('data-highlighted');
691+
await expect(options[options.length - 1]).toHaveAttribute(
692+
'aria-selected',
693+
'true',
694+
);
695+
});
696+
697+
test(`GIVEN a closed basic select
698+
AND the first option is selected
699+
WHEN the left arrow key is pressed
700+
THEN it should stay on the first option`, async ({ page }) => {
701+
const { getTrigger, getOptions, getListbox, openListbox } = await setup(
702+
page,
703+
'select-hero-test',
704+
);
705+
706+
// initially first option is highlighted & listbox closed
707+
await openListbox('Enter');
708+
await getTrigger().focus();
709+
await getTrigger().press('Enter');
710+
const options = await getOptions();
711+
await expect(options[0]).toHaveAttribute('data-highlighted');
712+
await expect(options[0]).toHaveAttribute('aria-selected', 'true');
713+
await expect(getListbox()).toBeHidden();
714+
715+
await getTrigger().focus();
716+
await getTrigger().press('ArrowLeft');
717+
await expect(options[0]).toHaveAttribute('data-highlighted');
718+
await expect(options[0]).toHaveAttribute('aria-selected', 'true');
719+
});
720+
});
721+
722+
test.describe('loop enabled', () => {
723+
test(`GIVEN an open select with loop enabled
724+
AND the last option is data-highlighted
725+
WHEN the down arrow key is pressed
726+
THEN the first option should have data-highlighted`, async ({ page }) => {
727+
const { getTrigger, getOptions, openListbox } = await setup(
728+
page,
729+
'select-loop-test',
730+
);
731+
732+
// initially last option is highlighted
733+
await openListbox('Enter');
734+
await getTrigger().focus();
735+
await getTrigger().press('End');
736+
const options = await getOptions();
737+
await expect(options[options.length - 1]).toHaveAttribute('data-highlighted');
738+
739+
await getTrigger().focus();
740+
await getTrigger().press('ArrowDown');
741+
await expect(options[0]).toHaveAttribute('data-highlighted');
742+
});
743+
744+
test(`GIVEN an open select with loop enabled
745+
AND the first option is data-highlighted
746+
WHEN the up arrow key is pressed
747+
THEN the last option should have data-highlighted`, async ({ page }) => {
748+
const { getTrigger, getOptions, openListbox } = await setup(
749+
page,
750+
'select-loop-test',
751+
);
752+
753+
// initially last option is highlighted
754+
await openListbox('Enter');
755+
const options = await getOptions();
756+
await expect(options[0]).toHaveAttribute('data-highlighted');
757+
758+
await getTrigger().focus();
759+
await getTrigger().press('ArrowUp');
760+
await expect(options[options.length - 1]).toHaveAttribute('data-highlighted');
761+
});
762+
763+
test(`GIVEN a closed select with loop enabled
764+
AND the last option is selected
765+
WHEN the right arrow key is pressed
766+
THEN it should loop to the first option`, async ({ page }) => {
767+
const { getTrigger, getOptions, openListbox } = await setup(
768+
page,
769+
'select-loop-test',
770+
);
771+
772+
// initially last option is highlighted
773+
await openListbox('Enter');
774+
await getTrigger().focus();
775+
await getTrigger().press('End');
776+
await getTrigger().press('Enter');
777+
const options = await getOptions();
778+
await expect(options[options.length - 1]).toHaveAttribute(
779+
'aria-selected',
780+
'true',
781+
);
782+
783+
await getTrigger().focus();
784+
await getTrigger().press('ArrowRight');
785+
await expect(options[0]).toHaveAttribute('data-highlighted');
786+
await expect(options[0]).toHaveAttribute('aria-selected', 'true');
787+
});
788+
789+
test(`GIVEN a closed select with loop enabled
790+
AND the first option is selected
791+
WHEN the right arrow key is pressed
792+
THEN it should loop to the first option`, async ({ page }) => {
793+
const { getTrigger, getOptions, openListbox } = await setup(
794+
page,
795+
'select-loop-test',
796+
);
797+
798+
// initially select first option
799+
await openListbox('Enter');
800+
await getTrigger().focus();
801+
await getTrigger().press('Enter');
802+
const options = await getOptions();
803+
await expect(options[0]).toHaveAttribute('aria-selected', 'true');
804+
805+
await getTrigger().focus();
806+
await getTrigger().press('ArrowLeft');
807+
await expect(options[options.length - 1]).toHaveAttribute('data-highlighted');
808+
await expect(options[options.length - 1]).toHaveAttribute(
809+
'aria-selected',
810+
'true',
811+
);
812+
});
813+
});
814+
});
815+
626816
test(`GIVEN an open select with multiple groups and a scrollable listbox
627817
AND the last option is not visible
628818
WHEN the end key is pressed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type SelectContext = {
2222

2323
// user configurable
2424
scrollOptions?: ScrollIntoViewOptions;
25+
loop: boolean;
2526
};
2627

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

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { component$, type PropsOf, useContext, sync$, $, Slot } from '@builder.i
22
import SelectContextId from './select-context';
33
import { getNextEnabledOptionIndex, getPrevEnabledOptionIndex } from './utils';
44
import { useTypeahead } from './use-select';
5-
export type OpenKeys = 'ArrowUp' | 'Enter' | 'Space' | 'ArrowDown';
65

76
type SelectTriggerProps = PropsOf<'button'>;
87
export const SelectTrigger = component$<SelectTriggerProps>((props) => {
@@ -50,13 +49,15 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
5049
context.highlightedIndexSig.value = getNextEnabledOptionIndex(
5150
-1,
5251
context.optionsSig.value,
52+
context.loop,
5353
);
5454
}
5555

5656
if (e.key === 'End') {
5757
const lastEnabledOptionIndex = getPrevEnabledOptionIndex(
5858
context.optionsSig.value.length,
5959
context.optionsSig.value,
60+
context.loop,
6061
);
6162
context.highlightedIndexSig.value = lastEnabledOptionIndex;
6263
}
@@ -66,6 +67,7 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
6667
context.selectedIndexSig.value = getNextEnabledOptionIndex(
6768
-1,
6869
context.optionsSig.value,
70+
context.loop,
6971
);
7072

7173
context.highlightedIndexSig.value = context.selectedIndexSig.value;
@@ -76,6 +78,7 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
7678
context.selectedIndexSig.value = getNextEnabledOptionIndex(
7779
context.selectedIndexSig.value!,
7880
context.optionsSig.value,
81+
context.loop,
7982
);
8083

8184
context.highlightedIndexSig.value = context.selectedIndexSig.value;
@@ -85,6 +88,7 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
8588
context.selectedIndexSig.value = getPrevEnabledOptionIndex(
8689
context.optionsSig.value.length,
8790
context.optionsSig.value,
91+
context.loop,
8892
);
8993

9094
context.highlightedIndexSig.value = context.selectedIndexSig.value;
@@ -95,6 +99,7 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
9599
context.selectedIndexSig.value = getPrevEnabledOptionIndex(
96100
context.highlightedIndexSig.value,
97101
context.optionsSig.value,
102+
context.loop,
98103
);
99104

100105
context.highlightedIndexSig.value = context.selectedIndexSig.value;
@@ -106,30 +111,36 @@ export const SelectTrigger = component$<SelectTriggerProps>((props) => {
106111
context.highlightedIndexSig.value = getNextEnabledOptionIndex(
107112
-1,
108113
context.optionsSig.value,
114+
context.loop,
109115
);
110116
return;
111117
}
112118

113119
if (context.isListboxOpenSig.value && !shouldOpen) {
120+
console.log('heyyy');
121+
// select options
122+
if (e.key === 'Enter' || e.key === ' ') {
123+
context.selectedIndexSig.value = context.highlightedIndexSig.value;
124+
console.log('selectedIndex', context.selectedIndexSig.value);
125+
console.log('highlightedIndex', context.highlightedIndexSig.value);
126+
}
127+
114128
if (e.key === 'ArrowDown') {
115129
context.highlightedIndexSig.value = getNextEnabledOptionIndex(
116130
context.highlightedIndexSig.value,
117131
context.optionsSig.value,
132+
context.loop,
118133
);
119134
}
120135

121136
if (e.key === 'ArrowUp') {
122137
context.highlightedIndexSig.value = getPrevEnabledOptionIndex(
123138
context.highlightedIndexSig.value,
124139
context.optionsSig.value,
140+
context.loop,
125141
);
126142
}
127143

128-
// select options
129-
if (e.key === 'Enter' || e.key === ' ') {
130-
context.selectedIndexSig.value = context.highlightedIndexSig.value;
131-
}
132-
133144
typeahead$(e.key);
134145
}
135146
});

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type SelectProps = PropsOf<'div'> & {
2828
onOpenChange$?: QRL<() => void>;
2929

3030
scrollOptions?: ScrollIntoViewOptions;
31+
loop?: boolean;
3132
};
3233

3334
/* root component in select-inline.tsx */
@@ -38,6 +39,7 @@ export const SelectImpl = component$<SelectProps>((props) => {
3839
const popoverRef = useSignal<HTMLElement>();
3940
const listboxRef = useSignal<HTMLUListElement>();
4041
const groupRef = useSignal<HTMLDivElement>();
42+
const loop = props.loop ?? false;
4143

4244
/**
4345
* Updates the options when the options change
@@ -100,6 +102,7 @@ export const SelectImpl = component$<SelectProps>((props) => {
100102
isListboxOpenSig,
101103
selectedIndexSig,
102104
scrollOptions,
105+
loop,
103106
};
104107

105108
useContextProvider(SelectContextId, context);

0 commit comments

Comments
 (0)