Skip to content

Commit 22c04e3

Browse files
Merge pull request #867 from GabrielRaposoD/feat/dropdown
feat(headless): adds new dropdown component
2 parents 7eb2193 + 6393210 commit 22c04e3

31 files changed

+1983
-12
lines changed

apps/website/src/_state/component-statuses.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const statusByComponent: ComponentKitsStatuses = {
3939
Collapsible: ComponentStatus.Beta,
4040
Combobox: ComponentStatus.Beta,
4141
Checkbox: ComponentStatus.Draft,
42+
Dropdown: ComponentStatus.Draft,
4243
Label: ComponentStatus.Draft,
4344
Modal: ComponentStatus.Beta,
4445
Pagination: ComponentStatus.Draft,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { component$, useStyles$ } from '@builder.io/qwik';
2+
3+
import { Dropdown } from '@qwik-ui/headless';
4+
import { LuCheck } from '@qwikest/icons/lucide';
5+
import styles from '../snippets/dropdown.css?inline';
6+
7+
export default component$(() => {
8+
useStyles$(styles);
9+
10+
const actions = [
11+
{ label: 'Commit ⌘+K', disabled: false },
12+
{ label: 'Push ⇧+⌘+K', disabled: false },
13+
{ label: 'Update Project ⌘+T', disabled: true },
14+
];
15+
16+
const checkboxItems = ['Show Git Log', 'Show History'];
17+
18+
const radioItems = ['main', 'develop'];
19+
20+
return (
21+
<Dropdown.Root data-testid="dropdown">
22+
<Dropdown.Trigger class="dropdown-trigger">Git Settings</Dropdown.Trigger>
23+
<Dropdown.Popover>
24+
<Dropdown.Arrow class="dropdown-arrow" />
25+
<Dropdown.Content class="dropdown-content">
26+
<Dropdown.Group class="dropdown-group">
27+
<Dropdown.GroupLabel class="dropdown-group-label">
28+
Actions
29+
</Dropdown.GroupLabel>
30+
{actions.map((action) => (
31+
<Dropdown.Item
32+
key={action.label}
33+
class="dropdown-item"
34+
disabled={action.disabled}
35+
>
36+
{action.label}
37+
</Dropdown.Item>
38+
))}
39+
</Dropdown.Group>
40+
<Dropdown.Separator />
41+
{checkboxItems.map((item) => {
42+
return (
43+
<Dropdown.CheckboxItem key={item} class="dropdown-item">
44+
<Dropdown.ItemIndicator>
45+
<LuCheck />
46+
</Dropdown.ItemIndicator>
47+
{item}
48+
</Dropdown.CheckboxItem>
49+
);
50+
})}
51+
<Dropdown.Separator />
52+
<Dropdown.RadioGroup class="dropdown-group" defaultValue="main">
53+
{radioItems.map((item) => {
54+
return (
55+
<Dropdown.RadioItem key={item} class="dropdown-item" value={item}>
56+
<Dropdown.ItemIndicator>
57+
<LuCheck />
58+
</Dropdown.ItemIndicator>
59+
{item}
60+
</Dropdown.RadioItem>
61+
);
62+
})}
63+
</Dropdown.RadioGroup>
64+
</Dropdown.Content>
65+
</Dropdown.Popover>
66+
</Dropdown.Root>
67+
);
68+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
title: Qwik UI | Dropdown
3+
---
4+
5+
import { FeatureList } from '~/components/feature-list/feature-list';
6+
import { statusByComponent } from '~/_state/component-statuses';
7+
8+
<StatusBanner status={statusByComponent.headless.Select} />
9+
10+
# Dropdown
11+
12+
Customizable popover menu.
13+
14+
<Showcase name="hero" />
15+
16+
## Anatomy
17+
18+
<AnatomyTable
19+
propDescriptors={[
20+
{
21+
name: 'Dropdown.Root',
22+
description:
23+
'Defines the component boundary and exposes its internal logic. Must wrap over all other parts.',
24+
},
25+
{
26+
name: 'Dropdown.Trigger',
27+
description:
28+
'Toggles the visibility of the dropdown menu. Should wrap around the button element.',
29+
},
30+
{
31+
name: 'Dropdown.Popover',
32+
description:
33+
'Container for the dropdown menu, responsible for positioning and visibility.',
34+
},
35+
{
36+
name: 'Dropdown.Arrow',
37+
description: 'Optional arrow pointing from the trigger to the dropdown menu.',
38+
},
39+
{
40+
name: 'Dropdown.Content',
41+
description: 'Contains the dropdown options and other interactive elements.',
42+
},
43+
{
44+
name: 'Dropdown.Group',
45+
description: 'Groups multiple dropdown items under a common label.',
46+
},
47+
{
48+
name: 'Dropdown.GroupLabel',
49+
description: 'Label for a group of dropdown items.',
50+
},
51+
{
52+
name: 'Dropdown.Separator',
53+
description:
54+
'Visual separator between different sections or groups of dropdown items.',
55+
},
56+
{
57+
name: 'Dropdown.CheckboxItem',
58+
description: 'Dropdown item that includes a checkbox for multi-select options.',
59+
},
60+
{
61+
name: 'Dropdown.ItemIndicator',
62+
description: 'Indicator that shows the selected state of an item.',
63+
},
64+
{
65+
name: 'Dropdown.RadioGroup',
66+
description: 'Groups radio items, allowing only one to be selected at a time.',
67+
},
68+
{
69+
name: 'Dropdown.RadioItem',
70+
description:
71+
'Dropdown item that includes a radio button for single-select options within a radio group.',
72+
},
73+
]}
74+
/>
75+
76+
{/* Need to create more examples - TBD */}
77+
78+
## API
79+
80+
### Dropdown.Root
81+
82+
<AnatomyTable
83+
propDescriptors={[
84+
{
85+
name: 'bind:open',
86+
description:
87+
'Two-way data bind of the open state of the dropdown to a user-defined signal.',
88+
},
89+
{
90+
name: 'onOpenChange$',
91+
description:
92+
'Callback function that is triggered when the open state of the dropdown changes.',
93+
},
94+
]}
95+
/>
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
:root {
2+
--dropdown-width: 14rem;
3+
}
4+
5+
.dropdown {
6+
min-width: var(--dropdown-width);
7+
}
8+
9+
.dropdown-trigger {
10+
width: 100%;
11+
height: 100%;
12+
border: 2px dotted black;
13+
min-height: 44px;
14+
max-width: var(--dropdown-width);
15+
padding-block: 0.5rem;
16+
display: flex;
17+
justify-content: center;
18+
align-items: center;
19+
margin-top: 0.25rem;
20+
padding: 0.5rem 1rem;
21+
}
22+
23+
.dropdown-trigger:hover {
24+
background: rgba(54, 25, 25, 0.08);
25+
}
26+
27+
.dropdown-trigger:focus-visible {
28+
outline: 2px solid black;
29+
outline-offset: 2px;
30+
}
31+
32+
.dropdown-content {
33+
min-width: var(--dropdown-width);
34+
border: 2px dotted black;
35+
margin-top: 1rem;
36+
flex-direction: column;
37+
display: flex;
38+
row-gap: 0.25rem;
39+
padding: 1rem 1rem 1rem 1.5rem;
40+
}
41+
42+
.dropdown-item {
43+
display: flex;
44+
align-items: center;
45+
position: relative;
46+
}
47+
48+
.dropdown-item [data-indicator] {
49+
position: absolute;
50+
right: 0.5rem;
51+
}
52+
53+
.dropdown-group {
54+
display: flex;
55+
flex-direction: column;
56+
row-gap: 0.25rem;
57+
}
58+
59+
.dropdown-group-label {
60+
font-size: 0.875rem;
61+
line-height: 1.25rem;
62+
color: rgba(54, 25, 25, 0.9);
63+
padding-top: 0.5rem;
64+
}
65+
66+
[data-highlighted] {
67+
background-color: rgba(54, 25, 25, 0.08);
68+
outline: 2px dotted black;
69+
}
70+
71+
[data-disabled] {
72+
opacity: 0.6;
73+
background: hsl(var(--foreground) / 0.05);
74+
}
75+
76+
.dropdown-arrow {
77+
width: 100%;
78+
}
79+
80+
.dropdown-arrow:after {
81+
content: '';
82+
position: absolute;
83+
border-style: solid;
84+
border-width: 0 10px 10px;
85+
border-color: #ffffff transparent;
86+
display: block;
87+
top: 8px;
88+
right: 50%;
89+
left: 50%;
90+
transform: translate(-50%, -50%);
91+
width: 0;
92+
z-index: 1;
93+
}
94+
95+
.dropdown-arrow:before {
96+
content: '';
97+
position: absolute;
98+
border-style: dotted;
99+
border-width: 0 15px 15px;
100+
border-color: #000 transparent;
101+
display: block;
102+
top: 8px;
103+
right: 50%;
104+
left: 50%;
105+
transform: translate(-50%, -50%);
106+
z-index: 0;
107+
}

apps/website/src/routes/docs/headless/menu.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
- [Collapsible](/docs/headless/collapsible)
2020
- [Combobox](/docs/headless/combobox)
2121
- [Checkbox](/docs/headless/checkbox)
22+
- [Dropdown](/docs/headless/dropdown)
2223
- [Label](/docs/headless/label)
2324
- [Modal](/docs/headless/modal)
2425
- [Pagination](/docs/headless/pagination)
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+
import { HPopoverPanelArrow } from '../popover/popover-panel-arrow';
4+
5+
export const HDropdownArrow = component$((props: PropsOf<'div'>) => {
6+
return (
7+
<HPopoverPanelArrow {...props}>
8+
<Slot></Slot>
9+
</HPopoverPanelArrow>
10+
);
11+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {
2+
$,
3+
QRL,
4+
Signal,
5+
Slot,
6+
component$,
7+
sync$,
8+
useSignal,
9+
useTask$,
10+
} from '@builder.io/qwik';
11+
12+
import { CheckboxRoot } from '../checkbox/checkbox';
13+
import { DropdownItemProps } from './dropdown-item';
14+
import { useDropdownItem } from './use-dropdown-item';
15+
16+
export type DropdownCheckboxItemProps = {
17+
/**
18+
* A signal that controls the current checked value (controlled).
19+
*/
20+
'bind:checked'?: Signal<boolean>;
21+
22+
/**
23+
* QRL handler that runs when the checked value changes.
24+
*/
25+
onChange$?: QRL<(checked: boolean) => void>;
26+
} & Omit<DropdownItemProps, 'onChange$'>;
27+
28+
export const HDropdownCheckboxItem = component$((props: DropdownCheckboxItemProps) => {
29+
const { disabled, onChange$, closeOnSelect = false, ...rest } = props;
30+
31+
const checkedSig = useSignal<boolean>(false);
32+
const checkboxRef = useSignal<HTMLDivElement>();
33+
34+
useTask$(function reactiveUserChecked({ track }) {
35+
const bindCheckedSig = props['bind:checked'];
36+
if (!bindCheckedSig) return;
37+
track(() => bindCheckedSig.value);
38+
39+
checkedSig.value = bindCheckedSig.value ?? checkedSig.value;
40+
});
41+
42+
useTask$(function onChangeTask({ track }) {
43+
track(() => checkedSig.value);
44+
45+
onChange$?.(checkedSig.value);
46+
});
47+
48+
// Handle the toggle of the checked state when the item is selected trough the keyboard or click.
49+
const toggleChecked$ = $(() => {
50+
checkedSig.value = !checkedSig.value;
51+
});
52+
53+
const {
54+
handleClick$,
55+
handleKeyDown$,
56+
handlePointerOver$,
57+
itemId,
58+
itemRef,
59+
isHighlightedSig,
60+
} = useDropdownItem({ ...props, onItemSelect: toggleChecked$, closeOnSelect });
61+
62+
//Prevent default behavior for certain keys. This needs to be sync to prevent default behavior and can't be implemented in useDropdownItem.
63+
const handleKeyDownSync$ = sync$((e: KeyboardEvent) => {
64+
const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'Home', 'End', 'Enter', ' '];
65+
if (keys.includes(e.key)) {
66+
e.preventDefault();
67+
}
68+
});
69+
70+
return (
71+
<div
72+
onClick$={[handleClick$, props.onClick$]}
73+
tabIndex={-1}
74+
id={itemId}
75+
onKeyDown$={[handleKeyDownSync$, handleKeyDown$, props.onKeyDown$]}
76+
onPointerOver$={[handlePointerOver$, props.onPointerOver$]}
77+
ref={itemRef}
78+
aria-disabled={disabled === true ? 'true' : 'false'}
79+
data-disabled={disabled}
80+
aria-checked={checkedSig.value ? 'true' : 'false'}
81+
data-highlighted={isHighlightedSig.value}
82+
data-checked={checkedSig.value}
83+
data-close-on-select={props.closeOnSelect}
84+
data-menu-item
85+
>
86+
<CheckboxRoot
87+
bind:checked={checkedSig}
88+
preventdefault:click
89+
ref={checkboxRef}
90+
style={{ pointerEvents: 'none' }}
91+
{...rest}
92+
>
93+
<Slot />
94+
</CheckboxRoot>
95+
</div>
96+
);
97+
});

0 commit comments

Comments
 (0)