Skip to content

Commit a61f5eb

Browse files
feat(dropdown): first implementation
1 parent 7eb2193 commit a61f5eb

20 files changed

+1548
-2
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { $, QRL, Signal, Slot, component$, useSignal, useTask$ } from '@builder.io/qwik';
2+
import { DropdownItemProps, HDropdownItem } from './dropdown-item';
3+
4+
type DropdownCheckboxItemProps = {
5+
/** A signal that controls the current checked state (controlled). */
6+
'bind:checked'?: Signal<boolean>;
7+
8+
/**
9+
* QRL handler that runs when the user selects an item.
10+
*/
11+
onChange$?: QRL<(checked: boolean) => void>;
12+
} & DropdownItemProps;
13+
14+
export const HDropdownCheckboxItem = component$((props: DropdownCheckboxItemProps) => {
15+
const { disabled = false, closeOnSelect = false, onChange$, ...rest } = props;
16+
17+
const checkedSig = useSignal<boolean>(false);
18+
19+
useTask$(function reactiveUserOpen({ track }) {
20+
const bindCheckedSig = props['bind:checked'];
21+
if (!bindCheckedSig) return;
22+
track(() => bindCheckedSig.value);
23+
24+
checkedSig.value = bindCheckedSig.value ?? checkedSig.value;
25+
});
26+
27+
useTask$(function onChangeTask({ track }) {
28+
track(() => checkedSig.value);
29+
30+
onChange$?.(checkedSig.value);
31+
});
32+
33+
const onSelect = $(() => {
34+
checkedSig.value = !checkedSig.value;
35+
props.onSelect$?.();
36+
});
37+
38+
return (
39+
<HDropdownItem
40+
{...rest}
41+
onSelect$={onSelect}
42+
role="menuitemcheckbox"
43+
closeOnSelect={closeOnSelect}
44+
disabled={disabled}
45+
aria-checked={checkedSig.value ? 'true' : 'false'}
46+
aria-disabled={disabled}
47+
style={{ display: 'flex', alignItems: 'center' }}
48+
data-disabled={disabled}
49+
data-checked={checkedSig.value}
50+
>
51+
<Slot name="dropdown-item-indicator" />
52+
<Slot />
53+
</HDropdownItem>
54+
);
55+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
component$,
3+
useStyles$,
4+
useTask$,
5+
Slot,
6+
type PropsOf,
7+
useContext,
8+
$,
9+
} from '@builder.io/qwik';
10+
import { dropdownContextId } from './dropdown-context';
11+
import styles from './dropdown.css?inline';
12+
import { isServer } from '@builder.io/qwik/build';
13+
14+
type DropdownContentProps = PropsOf<'div'>;
15+
16+
export const HDropdownContent = component$<DropdownContentProps>((props) => {
17+
useStyles$(styles);
18+
19+
const context = useContext(dropdownContextId);
20+
const contentId = `${context.localId}-content`;
21+
22+
const isOutside = $((rect: DOMRect, x: number, y: number) => {
23+
return x < rect.left || x > rect.right || y < rect.top || y > rect.bottom;
24+
});
25+
26+
const handleDismiss$ = $(async (e: PointerEvent) => {
27+
if (!context.isOpenSig.value) {
28+
return;
29+
}
30+
31+
if (!context.contentRef.value || !context.triggerRef.value) {
32+
return;
33+
}
34+
35+
const contentRect = context.contentRef.value.getBoundingClientRect();
36+
const triggerRect = context.triggerRef.value.getBoundingClientRect();
37+
const { clientX, clientY } = e;
38+
39+
const isOutsideContent = await isOutside(contentRect, clientX, clientY);
40+
const isOutsideTrigger = await isOutside(triggerRect, clientX, clientY);
41+
42+
if (isOutsideContent && isOutsideTrigger) {
43+
context.isOpenSig.value = false;
44+
}
45+
});
46+
47+
// Dismiss code should only matter when the content is open
48+
useTask$(({ track, cleanup }) => {
49+
track(() => context.isOpenSig.value);
50+
51+
if (isServer) return;
52+
53+
if (context.isOpenSig.value) {
54+
window.addEventListener('pointerdown', handleDismiss$);
55+
}
56+
57+
cleanup(() => {
58+
window.removeEventListener('pointerdown', handleDismiss$);
59+
});
60+
});
61+
62+
return (
63+
<div
64+
{...props}
65+
id={contentId}
66+
role="content"
67+
ref={context.contentRef}
68+
data-open={context.isOpenSig.value ? true : undefined}
69+
data-closed={!context.isOpenSig.value ? true : undefined}
70+
>
71+
<Slot />
72+
</div>
73+
);
74+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Signal, createContextId } from '@builder.io/qwik';
2+
3+
import { TItemsMap } from './dropdown-root';
4+
5+
export const dropdownContextId = createContextId<DropdownContext>('qui-dropdown');
6+
7+
export type DropdownContext = {
8+
// core state
9+
isOpenSig: Signal<boolean>;
10+
itemsMapSig: Readonly<Signal<TItemsMap>>;
11+
highlightedIndexSig: Signal<number | null>;
12+
currDisplayValueSig: Signal<string | string[] | undefined>;
13+
localId: string;
14+
15+
// refs
16+
triggerRef: Signal<HTMLButtonElement | undefined>;
17+
popoverRef: Signal<HTMLElement | undefined>;
18+
contentRef: Signal<HTMLElement | undefined>;
19+
highlightedItemRef: Signal<HTMLLIElement | undefined>;
20+
21+
// user configurable
22+
scrollOptions?: ScrollIntoViewOptions;
23+
loop: boolean;
24+
};
25+
26+
export const dropdownRadioGroupContextId = createContextId<DropdownRadioGroupContext>(
27+
'qui-dropdown-radio-group',
28+
);
29+
30+
export type DropdownRadioGroupContext = {
31+
valueSig: Signal<string>;
32+
disabled: boolean;
33+
};
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { type JSXNode, Component } from '@builder.io/qwik';
2+
import { HDropdownImpl, type DropdownProps } from './dropdown-root';
3+
import { HDropdownItem as InternalDropdownItem } from './dropdown-item';
4+
import { HDropdownRadioItem as InternalDropdownRadioItem } from './dropdown-radio-item';
5+
import { HDropdownCheckboxItem as InternalDropdownCheckboxItem } from './dropdown-checkbox-item';
6+
7+
type InlineCompProps = {
8+
dropdownRadioItemComponent?: typeof InternalDropdownRadioItem;
9+
dropdownItemComponent?: typeof InternalDropdownItem;
10+
dropdownCheckboxItemComponent?: typeof InternalDropdownCheckboxItem;
11+
};
12+
13+
/*
14+
This is an inline component. An example use case of an inline component to get the proper indexes with CSR. See issue #4757
15+
for more information.
16+
*/
17+
export const HDropdownRoot: Component<DropdownProps & InlineCompProps> = (
18+
props: DropdownProps & InlineCompProps,
19+
) => {
20+
const {
21+
children: myChildren,
22+
dropdownRadioItemComponent: UserRadioItem,
23+
dropdownItemComponent: UserItem,
24+
dropdownCheckboxItemComponent: UserCheckboxItem,
25+
...rest
26+
} = props;
27+
28+
/**
29+
* When creating reusable component pieces, DropdownRoot needs to know the existence of these components. See the styled tabs for as an example.
30+
**/
31+
const DropdownRadioItem = UserRadioItem ?? InternalDropdownRadioItem;
32+
const DropdownItem = UserItem ?? InternalDropdownItem;
33+
const DropdownCheckboxItem = UserCheckboxItem ?? InternalDropdownCheckboxItem;
34+
35+
// source of truth
36+
const itemsMap = new Map();
37+
let currItemIndex = 0;
38+
let isItemDisabled = false;
39+
40+
const childrenToProcess = (
41+
Array.isArray(myChildren) ? [...myChildren] : [myChildren]
42+
) as Array<JSXNode>;
43+
44+
while (childrenToProcess.length) {
45+
const child = childrenToProcess.shift();
46+
47+
if (!child) {
48+
continue;
49+
}
50+
51+
if (Array.isArray(child)) {
52+
childrenToProcess.unshift(...child);
53+
continue;
54+
}
55+
56+
switch (child.type) {
57+
case DropdownRadioItem:
58+
case DropdownCheckboxItem:
59+
case DropdownItem: {
60+
// get the index of the current option
61+
child.props._index = currItemIndex;
62+
63+
isItemDisabled = child.props.disabled === true;
64+
65+
// add the item to the map
66+
itemsMap.set(currItemIndex, {
67+
value: child.props.value,
68+
disabled: isItemDisabled,
69+
});
70+
71+
// increment after processing children
72+
currItemIndex++;
73+
74+
// the default case isn't handled here, so we need to process the children to get to the label component
75+
if (child.props.children) {
76+
const childChildren = Array.isArray(child.props.children)
77+
? [...child.props.children]
78+
: [child.props.children];
79+
childrenToProcess.unshift(...childChildren);
80+
}
81+
82+
break;
83+
}
84+
85+
default: {
86+
if (child) {
87+
const anyChildren = Array.isArray(child.children)
88+
? [...child.children]
89+
: [child.children];
90+
childrenToProcess.unshift(...(anyChildren as JSXNode[]));
91+
}
92+
93+
break;
94+
}
95+
}
96+
}
97+
98+
return (
99+
<HDropdownImpl {...rest} _itemsMap={itemsMap}>
100+
{props.children}
101+
</HDropdownImpl>
102+
);
103+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { PropsOf, Slot, component$ } from '@builder.io/qwik';
2+
3+
type DropdownItemIndicatorProps = PropsOf<'div'>;
4+
5+
export const HDropdownItemIndicator = component$((props: DropdownItemIndicatorProps) => {
6+
return (
7+
<div data-indicator {...props} q:slot="dropdown-item-indicator">
8+
<Slot />
9+
</div>
10+
);
11+
});

0 commit comments

Comments
 (0)