Skip to content

Commit ebe2ff9

Browse files
committed
refactor(select): preliminary refactoring work
refactor #132
1 parent fe901bb commit ebe2ff9

13 files changed

+468
-0
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { component$ } from '@builder.io/qwik';
2+
import {
3+
SelectListBox,
4+
SelectMarker,
5+
SelectRoot,
6+
SelectTrigger,
7+
SelectValue,
8+
SelectOption,
9+
SelectLabel,
10+
SelectGroup,
11+
} from './refactor';
12+
import SelectTestData from './select-test-data';
13+
14+
const BasicSelect = component$(() => {
15+
const { groups, options } = SelectTestData();
16+
17+
return (
18+
<>
19+
<SelectLabel id="basic-select">Fruits, Vegetables or Meat</SelectLabel>
20+
<SelectRoot data-testid="select-root">
21+
<SelectTrigger aria-labelledby="basic-select">
22+
<SelectValue placeholder="Select an item" />
23+
<SelectMarker>
24+
<svg
25+
xmlns="http://www.w3.org/2000/svg"
26+
viewBox="0 0 24 24"
27+
fill="none"
28+
stroke="currentColor"
29+
stroke-width="2"
30+
stroke-linecap="round"
31+
stroke-linejoin="round"
32+
style="width: 20px; height: 20px;"
33+
>
34+
<polyline points="6 9 12 15 18 9"></polyline>
35+
</svg>
36+
</SelectMarker>
37+
</SelectTrigger>
38+
<SelectListBox aria-labelledby="basic-select">
39+
{groups.map((group) => (
40+
<>
41+
<SelectLabel id={group}>{group}</SelectLabel>
42+
<SelectGroup aria-labelledby={group}>
43+
{options.map(
44+
(option, index) =>
45+
option.type === group && (
46+
<SelectOption optionValue={option.name} key={index} />
47+
)
48+
)}
49+
</SelectGroup>
50+
</>
51+
))}
52+
<SelectOption optionValue="disabled" disabled />
53+
</SelectListBox>
54+
</SelectRoot>
55+
</>
56+
);
57+
});
58+
59+
describe('Select', () => {
60+
it('INIT', () => {
61+
cy.mount(<BasicSelect />);
62+
cy.checkA11yForComponent();
63+
});
64+
65+
// it(`GIVEN a 'required' attribute and value is undefined,
66+
// WHEN the button is clicked,
67+
// THEN the submit event should fail with a native prompt autofocusing the Select.`, () => {
68+
// cy.mount(<BasicSelect />);
69+
// });
70+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export * from './select-context-id';
2+
export * from './select-context.type';
3+
export * from './select-root';
4+
export * from './select-trigger';
5+
export * from './select-value';
6+
export * from './select-marker';
7+
export * from './select-listbox';
8+
export * from './select-label';
9+
export * from './select-group';
10+
export * from './select-option';
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { createContextId } from '@builder.io/qwik';
2+
import { SelectContext } from './select-context.type';
3+
4+
const SelectContextId = createContextId<SelectContext>('select-root');
5+
6+
export default SelectContextId;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Signal } from '@builder.io/qwik';
2+
3+
export type SelectContext = {
4+
options: HTMLElement[];
5+
selection: Signal<string | null | undefined>;
6+
isExpanded: Signal<boolean>;
7+
triggerRef: Signal<HTMLElement | undefined>;
8+
listBoxRef: Signal<HTMLElement | undefined>;
9+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { QwikIntrinsicElements, Slot, component$ } from '@builder.io/qwik';
2+
3+
export type SelectGroupProps = {
4+
disabled?: boolean;
5+
} & QwikIntrinsicElements['div'];
6+
7+
export const SelectGroup = component$(
8+
({ disabled, ...props }: SelectGroupProps) => {
9+
return (
10+
<div role="group" aria-disabled={disabled} {...props}>
11+
<Slot />
12+
</div>
13+
);
14+
}
15+
);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { component$, QwikIntrinsicElements, Slot } from '@builder.io/qwik';
2+
3+
export type SelectLabelProps = QwikIntrinsicElements['label'];
4+
5+
export const SelectLabel = component$(({ ...props }: SelectLabelProps) => {
6+
return (
7+
<label {...props}>
8+
<Slot />
9+
</label>
10+
);
11+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
QwikIntrinsicElements,
3+
Slot,
4+
component$,
5+
useContext,
6+
useSignal,
7+
} from '@builder.io/qwik';
8+
import SelectContextId from './select-context-id';
9+
10+
export type SelectListBoxProps = QwikIntrinsicElements['ul'];
11+
12+
export const SelectListBox = component$((props: SelectListBoxProps) => {
13+
const ref = useSignal<HTMLElement>();
14+
const selectContext = useContext(SelectContextId);
15+
selectContext.listBoxRef = ref;
16+
return (
17+
<ul
18+
ref={ref}
19+
role="listbox"
20+
tabIndex={0}
21+
style={`
22+
display: ${selectContext.isExpanded.value ? 'block' : 'none'};
23+
position: absolute;
24+
z-index: 1;
25+
${props.style}
26+
`}
27+
class={props.class}
28+
onKeyDown$={(e) => {
29+
const availableOptions = selectContext.options.filter(
30+
(option) => !(option?.getAttribute('aria-disabled') === 'true')
31+
);
32+
const target = e.target as HTMLElement;
33+
const currentIndex = availableOptions.indexOf(target);
34+
35+
if (e.key === 'ArrowDown') {
36+
if (currentIndex === availableOptions.length - 1) {
37+
availableOptions[0]?.focus();
38+
} else {
39+
availableOptions[currentIndex + 1]?.focus();
40+
}
41+
}
42+
43+
if (e.key === 'ArrowUp') {
44+
if (currentIndex <= 0) {
45+
availableOptions[availableOptions.length - 1]?.focus();
46+
} else {
47+
availableOptions[currentIndex - 1]?.focus();
48+
}
49+
}
50+
}}
51+
>
52+
<Slot />
53+
</ul>
54+
);
55+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { QwikIntrinsicElements, Slot, component$ } from '@builder.io/qwik';
2+
3+
export type SelectMarkerProps = QwikIntrinsicElements['span'];
4+
5+
export const SelectMarker = component$((props: SelectMarkerProps) => {
6+
return (
7+
<span aria-hidden="true" {...props}>
8+
<Slot />
9+
</span>
10+
);
11+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {
2+
QwikIntrinsicElements,
3+
component$,
4+
useContext,
5+
useSignal,
6+
} from '@builder.io/qwik';
7+
import { OptionProps } from '../autocomplete';
8+
import SelectContextId from './select-context-id';
9+
10+
export type SelectOptionProps = {
11+
disabled?: boolean;
12+
optionValue: string;
13+
} & QwikIntrinsicElements['li'];
14+
15+
export const SelectOption = component$(
16+
({ disabled, optionValue, ...props }: OptionProps) => {
17+
const selectContext = useContext(SelectContextId);
18+
const ref = useSignal<HTMLElement>();
19+
return (
20+
<li
21+
ref={ref}
22+
role="option"
23+
tabIndex={disabled ? -1 : 0}
24+
aria-disabled={disabled}
25+
aria-selected={optionValue === selectContext.selection.value}
26+
onClick$={() => {
27+
if (!disabled) {
28+
selectContext.selection.value = optionValue;
29+
selectContext.isExpanded.value = false;
30+
}
31+
}}
32+
onKeyUp$={(e) => {
33+
const target = e.target as HTMLElement;
34+
if (
35+
!disabled &&
36+
(e.key === 'Enter' || e.key === ' ') &&
37+
target.innerText === optionValue
38+
) {
39+
selectContext.selection.value = optionValue;
40+
selectContext.isExpanded.value = false;
41+
}
42+
}}
43+
onKeyDown$={(e) => {
44+
const target = e.target as HTMLElement;
45+
if (
46+
!disabled &&
47+
e.key === 'Tab' &&
48+
target.innerText === optionValue
49+
) {
50+
selectContext.selection.value = optionValue;
51+
selectContext.isExpanded.value = false;
52+
}
53+
}}
54+
onMouseEnter$={(e) => {
55+
if (!disabled) {
56+
const target = e.target as HTMLElement;
57+
target.focus();
58+
console.log('focus');
59+
}
60+
}}
61+
{...props}
62+
>
63+
{optionValue}
64+
</li>
65+
);
66+
}
67+
);
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import {
2+
$,
3+
QwikIntrinsicElements,
4+
Slot,
5+
component$,
6+
useContextProvider,
7+
useOn,
8+
useOnDocument,
9+
useSignal,
10+
useStore,
11+
useVisibleTask$,
12+
} from '@builder.io/qwik';
13+
import { SelectContext } from './select-context.type';
14+
import SelectContextId from './select-context-id';
15+
import { computePosition, flip } from '@floating-ui/dom';
16+
17+
export type SelectRootProps = {
18+
required?: boolean;
19+
} & QwikIntrinsicElements['div'];
20+
21+
export const SelectRoot = component$((props: SelectRootProps) => {
22+
const options = useStore([]);
23+
const selection = useSignal(null);
24+
const isExpanded = useSignal(false);
25+
const triggerRef = useSignal<HTMLElement>();
26+
const listBoxRef = useSignal<HTMLElement>();
27+
28+
const selectContext: SelectContext = {
29+
options,
30+
selection,
31+
isExpanded,
32+
triggerRef,
33+
listBoxRef,
34+
};
35+
36+
useContextProvider(SelectContextId, selectContext);
37+
useCollateOptions(selectContext);
38+
useUpdatePosition(selectContext);
39+
useDismiss(selectContext);
40+
41+
return (
42+
<div {...props}>
43+
<Slot />
44+
</div>
45+
);
46+
});
47+
48+
function useDismiss(context: SelectContext) {
49+
useOnDocument(
50+
'click',
51+
$((e) => {
52+
const target = e.target as HTMLElement;
53+
if (
54+
context.isExpanded.value === true &&
55+
!context.listBoxRef.value?.contains(target) &&
56+
!context.triggerRef.value?.contains(target)
57+
) {
58+
context.isExpanded.value = false;
59+
}
60+
})
61+
);
62+
63+
useOn(
64+
'keydown',
65+
$((e) => {
66+
const event = e as KeyboardEvent;
67+
if (event.key === 'Escape') {
68+
context.isExpanded.value = false;
69+
}
70+
})
71+
);
72+
}
73+
74+
function useUpdatePosition(context: SelectContext) {
75+
const updatePosition$ = $(
76+
(referenceEl: HTMLElement, floatingEl: HTMLElement) => {
77+
computePosition(referenceEl, floatingEl, {
78+
placement: 'bottom',
79+
middleware: [flip()],
80+
}).then(({ x, y }) => {
81+
Object.assign(floatingEl.style, {
82+
left: `${x}px`,
83+
top: `${y}px`,
84+
});
85+
});
86+
}
87+
);
88+
89+
useVisibleTask$(async ({ track }) => {
90+
const trigger = track(() => context.triggerRef.value);
91+
const listBox = track(() => context.listBoxRef.value);
92+
const expanded = track(() => context.isExpanded.value);
93+
94+
if (!trigger || !listBox) return;
95+
96+
if (expanded === true) {
97+
listBox.style.visibility = 'hidden';
98+
99+
await updatePosition$(trigger, listBox);
100+
101+
listBox.style.visibility = 'visible';
102+
103+
listBox?.focus();
104+
}
105+
106+
if (expanded === false) {
107+
trigger?.focus();
108+
}
109+
});
110+
}
111+
112+
function useCollateOptions(context: SelectContext) {
113+
useVisibleTask$(({ track }) => {
114+
const listBox = track(() => context.listBoxRef.value);
115+
116+
if (listBox) {
117+
const collatedOptions = Array.from(
118+
listBox.querySelectorAll('[role="option"]')
119+
) as HTMLElement[];
120+
121+
context.options.push(...collatedOptions);
122+
}
123+
});
124+
}

0 commit comments

Comments
 (0)