@stainless-api/ui-primitives 0.1.0-beta.19 → 0.1.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @stainless-api/ui-primitives
2
2
 
3
+ ## 0.1.0-beta.20
4
+
5
+ ### Patch Changes
6
+
7
+ - 7ef572c: feat: updated dropdowns
8
+
3
9
  ## 0.1.0-beta.19
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stainless-api/ui-primitives",
3
- "version": "0.1.0-beta.19",
3
+ "version": "0.1.0-beta.20",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,51 @@
1
+ import clsx from 'clsx';
2
+ import type { ComponentProps } from 'react';
3
+ import { Menu } from './DropdownMenu';
4
+
5
+ function Trigger({ className, ...props }: ComponentProps<'button'>) {
6
+ return (
7
+ <button {...props} data-part="trigger" className={clsx('stl-ui-dropdown__button', className)}>
8
+ {props.children}
9
+ </button>
10
+ );
11
+ }
12
+
13
+ function TriggerSelectedItem({ className, ...props }: ComponentProps<'div'>) {
14
+ return (
15
+ <div
16
+ {...props}
17
+ data-part="trigger-selected"
18
+ className={clsx('stl-ui-dropdown__trigger-selected', className)}
19
+ />
20
+ );
21
+ }
22
+
23
+ function TriggerIcon({ className, ...props }: ComponentProps<'span'>) {
24
+ return (
25
+ <span {...props} data-part="trigger-icon" className={clsx('stl-ui-dropdown__trigger-icon', className)} />
26
+ );
27
+ }
28
+
29
+ function Icon({ className, ...props }: ComponentProps<'div'>) {
30
+ return <div {...props} data-part="item-icon" className={clsx('stl-ui-dropdown__icon', className)} />;
31
+ }
32
+
33
+ export function Dropdown({ className, ...props }: ComponentProps<'div'>) {
34
+ return (
35
+ <div
36
+ {...props}
37
+ aria-haspopup="listbox"
38
+ aria-expanded="false"
39
+ className={clsx('stl-ui-dropdown stl-ui-not-prose not-content', className)}
40
+ />
41
+ );
42
+ }
43
+
44
+ Dropdown.Menu = Menu;
45
+ Dropdown.MenuItem = Menu.Item;
46
+ Dropdown.MenuItemText = Menu.ItemText;
47
+ Dropdown.MenuItemTemplate = Menu.ItemTemplate;
48
+ Dropdown.Trigger = Trigger;
49
+ Dropdown.TriggerSelectedItem = TriggerSelectedItem;
50
+ Dropdown.TriggerIcon = TriggerIcon;
51
+ Dropdown.Icon = Icon;
@@ -0,0 +1,54 @@
1
+ import clsx from 'clsx';
2
+ import { ChevronsUpDown } from 'lucide-react';
3
+ import type { ComponentProps } from 'react';
4
+ import { Menu } from './DropdownMenu';
5
+
6
+ function PrimaryActionText({ children }: { children?: React.ReactNode }) {
7
+ return <span data-part="primary-action-text">{children}</span>;
8
+ }
9
+
10
+ function PrimaryAction({ className, ...props }: ComponentProps<'button'>) {
11
+ return (
12
+ <button
13
+ {...props}
14
+ data-part="primary-action"
15
+ className={clsx('stl-ui-dropdown__button stl-ui-dropdown-button--action', className)}
16
+ />
17
+ );
18
+ }
19
+
20
+ function Trigger({ className, ...props }: ComponentProps<'button'>) {
21
+ return (
22
+ <button
23
+ {...props}
24
+ type="button"
25
+ data-part="trigger"
26
+ className={clsx('stl-ui-dropdown__button stl-ui-dropdown-button__trigger', className)}
27
+ >
28
+ <ChevronsUpDown size={16} />
29
+ </button>
30
+ );
31
+ }
32
+
33
+ function Icon({ className, ...props }: ComponentProps<'div'>) {
34
+ return <div data-part="item-icon" {...props} className={clsx('stl-ui-dropdown__icon', className)} />;
35
+ }
36
+
37
+ export function DropdownButton({ className, ...props }: ComponentProps<'div'>) {
38
+ return (
39
+ <div
40
+ {...props}
41
+ aria-haspopup="listbox"
42
+ aria-expanded="false"
43
+ className={clsx('stl-ui-dropdown stl-ui-not-prose not-content', className)}
44
+ />
45
+ );
46
+ }
47
+
48
+ DropdownButton.Menu = Menu;
49
+ DropdownButton.MenuItem = Menu.Item;
50
+ DropdownButton.MenuItemText = Menu.ItemText;
51
+ DropdownButton.PrimaryAction = PrimaryAction;
52
+ DropdownButton.PrimaryActionText = PrimaryActionText;
53
+ DropdownButton.Trigger = Trigger;
54
+ DropdownButton.Icon = Icon;
@@ -0,0 +1,113 @@
1
+ import clsx from 'clsx';
2
+ import { CheckIcon, ExternalLink } from 'lucide-react';
3
+ import type { ComponentProps } from 'react';
4
+
5
+ export function Menu({ className, ...props }: ComponentProps<'div'>) {
6
+ return (
7
+ <div
8
+ {...props}
9
+ role="listbox"
10
+ data-state="closed"
11
+ data-part="menu"
12
+ className={clsx('stl-ui-dropdown-menu', className)}
13
+ />
14
+ );
15
+ }
16
+
17
+ function MenuItemText({ className, subtle, ...props }: ComponentProps<'span'> & { subtle?: boolean }) {
18
+ return (
19
+ <span
20
+ {...props}
21
+ data-part="item-text"
22
+ className={clsx(
23
+ `stl-ui-dropdown-menu__item-text`,
24
+ {
25
+ 'stl-ui-dropdown-menu__item-text--subtle': subtle,
26
+ },
27
+ className,
28
+ )}
29
+ />
30
+ );
31
+ }
32
+
33
+ type MenuItemBaseProps = {
34
+ children?: React.ReactNode;
35
+ value: string;
36
+ isExternalLink?: boolean;
37
+ isSelected?: boolean;
38
+ };
39
+
40
+ type MenuItemWithHref = MenuItemBaseProps &
41
+ ComponentProps<'a'> & {
42
+ href: string;
43
+ };
44
+
45
+ type MenuItemWithoutHref = MenuItemBaseProps &
46
+ ComponentProps<'button'> & {
47
+ href?: never;
48
+ };
49
+
50
+ type MenuItemProps = MenuItemWithHref | MenuItemWithoutHref;
51
+
52
+ function MenuItem({ children, value, href, isExternalLink, isSelected, ...props }: MenuItemProps) {
53
+ const inner = (
54
+ <>
55
+ <div className="stl-ui-dropdown-menu__item-content">{children}</div>
56
+ {isSelected && (
57
+ <div className="stl-ui-dropdown-menu__item-icon" data-part="item-selected-icon">
58
+ <CheckIcon size={16} />
59
+ </div>
60
+ )}
61
+ {isExternalLink && (
62
+ <div className="stl-ui-dropdown-menu__item-subtle-icon" data-part="item-external-link-icon">
63
+ <ExternalLink size={16} />
64
+ </div>
65
+ )}
66
+ </>
67
+ );
68
+
69
+ if (href) {
70
+ return (
71
+ <a
72
+ role="option"
73
+ data-part="item"
74
+ data-value={value}
75
+ aria-selected={isSelected}
76
+ href={href}
77
+ {...(props as ComponentProps<'a'>)}
78
+ className={clsx('stl-ui-dropdown-menu__item', 'stl-ui-dropdown-menu__item-link', props.className)}
79
+ >
80
+ {inner}
81
+ </a>
82
+ );
83
+ }
84
+
85
+ return (
86
+ <button
87
+ {...(props as ComponentProps<'button'>)}
88
+ role="option"
89
+ data-part="item"
90
+ data-value={value}
91
+ aria-selected={isSelected}
92
+ className={clsx('stl-ui-dropdown-menu__item', props.className)}
93
+ >
94
+ {inner}
95
+ </button>
96
+ );
97
+ }
98
+
99
+ /**
100
+ * A template component that defines the content to be displayed in the dropdown trigger
101
+ * when a menu item is selected. This template is used to customize the appearance of
102
+ * the selected item in the trigger button.
103
+ *
104
+ * @param props - Standard HTML template element props
105
+ * @returns A template element marked with the "selected-template" data part
106
+ */
107
+ function MenuItemTemplate({ ...props }: ComponentProps<'template'>) {
108
+ return <template data-part="selected-template" {...props} />;
109
+ }
110
+
111
+ Menu.Item = MenuItem;
112
+ Menu.ItemText = MenuItemText;
113
+ Menu.ItemTemplate = MenuItemTemplate;
@@ -0,0 +1,231 @@
1
+ .stl-ui-dropdown {
2
+ --stl-ui-dropdown-button-color: var(--stl-ui-foreground);
3
+ --stl-ui-dropdown-button-background-color: var(--stl-ui-card-background);
4
+ --stl-ui-dropdown-button-border-color: var(--stl-ui-border);
5
+ --stl-ui-dropdown-button-border-radius: var(--stl-ui-layout-border-radius-sml);
6
+ --stl-ui-dropdown-button-font-size: var(--stl-ui-type-scale-text-sm);
7
+
8
+ --stl-ui-dropdown-button-height: 32px;
9
+ --stl-ui-dropdown-button-padding: 0 10px;
10
+ --stl-ui-dropdown-button-line-height: 100%;
11
+ --stl-ui-dropdown-button-font-weight: 500;
12
+
13
+ position: relative;
14
+ display: inline-flex;
15
+ align-items: center;
16
+
17
+ color: var(--stl-ui-dropdown-button-color);
18
+ gap: 0;
19
+ font-size: var(--stl-ui-dropdown-button-font-size);
20
+
21
+ hr {
22
+ --stl-ui-dropdown-button__divider-height: 1px;
23
+ --stl-ui-dropdown-button__divider-color: var(--stl-ui-border);
24
+
25
+ height: var(--stl-ui-dropdown-button__divider-height);
26
+ background-color: var(--stl-ui-dropdown-button__divider-color);
27
+ border: none;
28
+
29
+ margin: 4px 0;
30
+ width: calc(100% + 8px);
31
+ transform: translateX(-4px);
32
+ }
33
+
34
+ .stl-ui-dropdown__button {
35
+ background-color: var(--stl-ui-dropdown-button-background-color);
36
+
37
+ border: 1px solid var(--stl-ui-dropdown-button-border-color);
38
+ border-radius: var(--stl-ui-dropdown-button-border-radius);
39
+ height: var(--stl-ui-dropdown-button-height);
40
+ padding: var(--stl-ui-dropdown-button-padding);
41
+ line-height: var(--stl-ui-dropdown-button-line-height);
42
+ font-weight: var(--stl-ui-dropdown-button-font-weight);
43
+ cursor: pointer;
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ color: inherit;
48
+
49
+ &:focus {
50
+ outline: 1px auto Highlight;
51
+ }
52
+
53
+ &:hover {
54
+ background-color: oklch(from var(--stl-ui-foreground) l c h / 0.05);
55
+ border-color: var(--stl-ui-border-emphasis);
56
+ }
57
+
58
+ .stl-ui-dropdown__trigger-icon {
59
+ margin-right: -4px;
60
+ margin-left: 4px;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ }
65
+ }
66
+
67
+ .stl-ui-dropdown__icon {
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+
72
+ svg {
73
+ width: 16px;
74
+ height: 16px;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Dropdown Button
80
+ */
81
+ .stl-ui-dropdown-button--action {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: 8px;
85
+ border-top-right-radius: 0;
86
+ border-bottom-right-radius: 0;
87
+ border-right: none;
88
+
89
+ &:hover {
90
+ background-color: oklch(from var(--stl-ui-foreground) l c h / 0.05);
91
+ border-color: var(--stl-ui-border-emphasis);
92
+ }
93
+
94
+ &.disabled {
95
+ cursor: not-allowed;
96
+ background-color: oklch(from var(--stl-ui-foreground) l c h / 0.05);
97
+ }
98
+ }
99
+
100
+ .stl-ui-dropdown-button__trigger {
101
+ border-left: 1px solid var(--stl-ui-border);
102
+ border-radius: 0;
103
+ border-top-right-radius: var(--stl-ui-dropdown-button-border-radius);
104
+ border-bottom-right-radius: var(--stl-ui-dropdown-button-border-radius);
105
+
106
+ &:hover {
107
+ background-color: oklch(from var(--stl-ui-foreground) l c h / 0.05);
108
+ border-color: var(--stl-ui-border-emphasis);
109
+ }
110
+ }
111
+
112
+ .stl-ui-dropdown__trigger-selected {
113
+ display: flex;
114
+ align-items: center;
115
+ gap: 8px;
116
+ }
117
+
118
+ /**
119
+ * Dropdown Menu
120
+ */
121
+ .stl-ui-dropdown-menu {
122
+ --stl-ui-dropdown-menu-background-color: var(--stl-ui-card-background);
123
+ --stl-ui-dropdown-menu-border-color: var(--stl-ui-border);
124
+ --stl-ui-dropdown-menu-box-shadow: var(--stl-ui-shadow-md);
125
+ --stl-ui-dropdown-menu-border-radius: var(--stl-ui-layout-border-radius-sml);
126
+
127
+ background-color: var(--stl-ui-dropdown-menu-background-color);
128
+ border: 1px solid var(--stl-ui-dropdown-menu-border-color);
129
+ box-shadow: var(--stl-ui-dropdown-menu-box-shadow);
130
+ border-radius: var(--stl-ui-dropdown-menu-border-radius);
131
+
132
+ position: absolute;
133
+ right: 0;
134
+ z-index: 1000;
135
+ min-width: 100%;
136
+ padding: 4px;
137
+ display: none;
138
+
139
+ &.stl-ui-dropdown-menu--above {
140
+ top: auto;
141
+ bottom: 100%;
142
+ margin-bottom: 4px;
143
+ }
144
+
145
+ &.stl-ui-dropdown-menu--below {
146
+ top: 100%;
147
+ bottom: auto;
148
+ margin-top: 4px;
149
+ }
150
+
151
+ &[data-state='open'] {
152
+ display: block;
153
+ }
154
+
155
+ .stl-ui-dropdown-menu__item {
156
+ --stl-ui-dropdown-menu__item-border-radius: var(--stl-ui-dropdown-button-border-radius);
157
+ --stl-ui-dropdown-menu__item-height: var(--stl-ui-dropdown-button-height);
158
+ --stl-ui-dropdown-menu__item-line-height: var(--stl-ui-dropdown-button-line-height);
159
+ --stl-ui-dropdown-menu__item-hover-background-color: oklch(from var(--stl-ui-foreground) l c h / 0.05);
160
+
161
+ border-radius: var(--stl-ui-dropdown-menu__item-border-radius);
162
+ height: var(--stl-ui-dropdown-menu__item-height);
163
+ line-height: var(--stl-ui-dropdown-menu__item-line-height);
164
+
165
+ background: transparent;
166
+ border: none;
167
+
168
+ padding: 8px;
169
+ cursor: pointer;
170
+ display: flex;
171
+ align-items: center;
172
+ justify-content: space-between;
173
+ gap: 16px;
174
+ width: 100%;
175
+
176
+ &:hover,
177
+ &:focus-visible {
178
+ background-color: var(--stl-ui-dropdown-menu__item-hover-background-color);
179
+ }
180
+
181
+ &:hover,
182
+ &:focus-visible,
183
+ &:focus {
184
+ color: var(--stl-ui-foreground);
185
+ }
186
+
187
+ &.stl-ui-dropdown-menu__item-link {
188
+ display: flex;
189
+ align-items: center;
190
+ justify-content: space-between;
191
+ gap: 16px;
192
+ width: 100%;
193
+
194
+ &:hover {
195
+ color: var(--stl-ui-foreground);
196
+ }
197
+ }
198
+
199
+ .stl-ui-dropdown-menu__item-icon {
200
+ display: flex;
201
+ }
202
+
203
+ .stl-ui-dropdown-menu__item-content {
204
+ display: flex;
205
+ align-items: center;
206
+ gap: 8px;
207
+ }
208
+
209
+ .stl-ui-dropdown-menu__item-text {
210
+ white-space: nowrap;
211
+ }
212
+
213
+ .stl-ui-dropdown-menu__item-text--subtle {
214
+ color: var(--stl-ui-foreground-muted);
215
+ }
216
+
217
+ strong {
218
+ color: var(--stl-ui-foreground);
219
+ font-weight: 500;
220
+ }
221
+
222
+ .stl-ui-dropdown-menu__item-subtle-icon {
223
+ --stl-ui-dropdown-menu__item-subtle-icon-color: oklch(from var(--stl-ui-foreground) l c h / 0.25);
224
+
225
+ svg {
226
+ color: var(--stl-ui-dropdown-menu__item-subtle-icon-color);
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './components/Button';
2
- export * from './components/DropdownButton';
2
+ export * from './components/dropdown/Dropdown';
3
+ export * from './components/dropdown/DropdownButton';
3
4
  export * from './components/Callout';
4
5
  export * from './components/Accordion';
@@ -1,3 +1,5 @@
1
+ import { initDropdown } from './dropdown';
2
+
1
3
  export function initDropdownButton({
2
4
  dropdown,
3
5
  onSelect,
@@ -11,42 +13,27 @@ export function initDropdownButton({
11
13
  const menu = dropdown.querySelector('[data-part="menu"]') as HTMLElement | null;
12
14
  const primaryAction = dropdown.querySelector('[data-part="primary-action"]');
13
15
 
14
- if (!trigger || !menu || !primaryAction) return;
15
-
16
- let isOpen = false;
17
-
18
- function toggleDropdown() {
19
- if (!trigger || !menu) return;
20
- isOpen = !isOpen;
21
- menu.dataset.state = isOpen ? 'open' : 'closed';
22
- trigger.setAttribute('aria-expanded', String(isOpen));
16
+ if (!trigger) {
17
+ console.error('Dropdown trigger not found');
18
+ return;
23
19
  }
24
20
 
25
- trigger.addEventListener('click', toggleDropdown);
26
-
27
- document.addEventListener('click', (event) => {
28
- if (!isOpen) return;
29
- if (!dropdown.contains(event.target as Node)) {
30
- toggleDropdown();
31
- }
32
-
33
- if (primaryAction && primaryAction.contains(event.target as Node)) {
34
- toggleDropdown();
35
- }
36
- });
21
+ if (!menu) {
22
+ console.error('Dropdown menu not found');
23
+ return;
24
+ }
37
25
 
38
- const items = dropdown.querySelectorAll('[data-part="item"]');
39
- items.forEach((item) => {
40
- item.addEventListener('click', () => {
41
- const value = item.getAttribute('data-value');
42
- if (value) {
43
- onSelect(value);
44
- }
45
- toggleDropdown();
46
- });
47
- });
26
+ if (!primaryAction) {
27
+ console.error('Dropdown primary action not found');
28
+ return;
29
+ }
48
30
 
49
31
  primaryAction.addEventListener('click', () => {
50
32
  onPrimaryAction(primaryAction);
51
33
  });
34
+
35
+ initDropdown({
36
+ root: dropdown,
37
+ onSelect,
38
+ });
52
39
  }
@@ -0,0 +1,193 @@
1
+ export function initDropdown({
2
+ root,
3
+ onSelect,
4
+ initialValue,
5
+ }: {
6
+ root?: Element | null;
7
+ onSelect?: (value: string) => void;
8
+ initialValue?: string;
9
+ }) {
10
+ if (!root) {
11
+ console.error('Dropdown root element not found');
12
+ return;
13
+ }
14
+ const trigger = root.querySelector<HTMLElement>('[data-part="trigger"]');
15
+ const menu = root.querySelector<HTMLElement>('[data-part="menu"]');
16
+
17
+ if (!trigger) {
18
+ console.error('Dropdown trigger not found');
19
+ return;
20
+ }
21
+
22
+ if (!menu) {
23
+ console.error('Dropdown menu not found');
24
+ return;
25
+ }
26
+
27
+ const selectedSlot = trigger.querySelector('[data-part="trigger-selected"]');
28
+
29
+ const items = Array.from(menu.querySelectorAll<HTMLElement>('[data-part="item"]'));
30
+
31
+ function open() {
32
+ if (!trigger || !menu || !root) return;
33
+ root.setAttribute('aria-expanded', 'true');
34
+ menu.dataset.state = 'open';
35
+ menu.removeAttribute('aria-hidden');
36
+
37
+ // Determine if menu should open above or below
38
+ const triggerRect = trigger.getBoundingClientRect();
39
+ const menuHeight = menu.offsetHeight;
40
+ const viewportHeight = window.innerHeight;
41
+ const spaceBelow = viewportHeight - triggerRect.bottom;
42
+ const spaceAbove = triggerRect.top;
43
+ menu.classList.remove('stl-ui-dropdown-menu--above', 'stl-ui-dropdown-menu--below');
44
+
45
+ if (spaceBelow < menuHeight && spaceAbove > spaceBelow) {
46
+ menu.classList.add('stl-ui-dropdown-menu--above');
47
+ } else {
48
+ menu.classList.add('stl-ui-dropdown-menu--below');
49
+ }
50
+ }
51
+
52
+ function close() {
53
+ if (!trigger || !menu || !root) return;
54
+ root.setAttribute('aria-expanded', 'false');
55
+ menu.dataset.state = 'closed';
56
+ menu.setAttribute('aria-hidden', 'true');
57
+ }
58
+
59
+ function renderSelectedFromItem(item: Element) {
60
+ // If there is no selected slot, do nothing. This allows for dropdowns without a selected display.
61
+ if (!selectedSlot) {
62
+ return;
63
+ }
64
+ const tmpl = item.querySelector<HTMLTemplateElement>('template[data-part="selected-template"]');
65
+
66
+ if (!tmpl) {
67
+ console.error('Dropdown item template not found');
68
+ return;
69
+ }
70
+
71
+ selectedSlot.innerHTML = '';
72
+ selectedSlot.appendChild(tmpl.content.cloneNode(true));
73
+ selectedSlot.removeAttribute('data-placeholder');
74
+ }
75
+
76
+ function selectItem(item: Element) {
77
+ if (!trigger) {
78
+ console.error('Dropdown trigger not found');
79
+ return;
80
+ }
81
+ items.forEach((i) => i.setAttribute('aria-selected', String(i === item)));
82
+ trigger.dataset.value = item.getAttribute('data-value') || '';
83
+ renderSelectedFromItem(item);
84
+ }
85
+
86
+ function handleItemSelection(e: MouseEvent | KeyboardEvent) {
87
+ const item = (e.target as Element).closest('[data-part="item"]');
88
+ if (!item) {
89
+ console.error('Dropdown item not found');
90
+ return;
91
+ }
92
+ if (!trigger) {
93
+ console.error('Dropdown trigger not found');
94
+ return;
95
+ }
96
+
97
+ selectItem(item);
98
+ onSelect?.(item.getAttribute('data-value') || '');
99
+ close();
100
+ trigger.focus();
101
+ }
102
+
103
+ function handleTriggerClick() {
104
+ if (!root) return false;
105
+ const isOpen = root.getAttribute('aria-expanded') === 'true';
106
+ if (isOpen) {
107
+ close();
108
+ return false;
109
+ } else {
110
+ open();
111
+ return true;
112
+ }
113
+ }
114
+
115
+ // Initialize selected item
116
+ const initial =
117
+ (initialValue
118
+ ? items.find((i) => i.getAttribute('data-value') === initialValue)
119
+ : items.find((i) => i.getAttribute('aria-selected') === 'true')) ?? items[0];
120
+
121
+ if (initial) selectItem(initial);
122
+
123
+ // add event listeners
124
+ trigger.addEventListener('click', () => {
125
+ handleTriggerClick();
126
+ });
127
+
128
+ // When using the keyboard to open the dropdown, we focus the first item
129
+ trigger.addEventListener('keydown', (e) => {
130
+ if (e.key !== 'Enter' && e.key !== ' ') return;
131
+ e.preventDefault();
132
+ const didOpen = handleTriggerClick();
133
+ if (didOpen) {
134
+ items[0]?.focus();
135
+ }
136
+ });
137
+
138
+ menu.addEventListener('click', (e) => {
139
+ handleItemSelection(e);
140
+ });
141
+
142
+ document.addEventListener('keydown', (e) => {
143
+ const isOpen = root.getAttribute('aria-expanded') === 'true';
144
+ if (!isOpen) return;
145
+
146
+ if (e.key === 'Escape') {
147
+ close();
148
+ trigger.focus();
149
+ return;
150
+ }
151
+
152
+ if (e.key === 'ArrowDown') {
153
+ e.preventDefault();
154
+ const activeElement = document.activeElement;
155
+
156
+ // if the active item is the button itself, focus the first item
157
+ if (activeElement === trigger) {
158
+ items[0]?.focus();
159
+ return;
160
+ }
161
+ let nextSibling = activeElement?.nextElementSibling;
162
+ while (nextSibling && nextSibling.tagName.toLowerCase() === 'hr') {
163
+ nextSibling = nextSibling.nextElementSibling;
164
+ }
165
+ if (nextSibling instanceof HTMLElement && items.includes(nextSibling)) {
166
+ nextSibling.focus();
167
+ }
168
+ return;
169
+ }
170
+
171
+ if (e.key === 'ArrowUp') {
172
+ e.preventDefault();
173
+ const activeElement = document.activeElement;
174
+ // if the active item is the button itself, focus the last item
175
+ if (activeElement === trigger) {
176
+ items[items.length - 1]?.focus();
177
+ return;
178
+ }
179
+ let prevSibling = activeElement?.previousElementSibling;
180
+ while (prevSibling && prevSibling.tagName.toLowerCase() === 'hr') {
181
+ prevSibling = prevSibling.previousElementSibling;
182
+ }
183
+ if (prevSibling instanceof HTMLElement && items.includes(prevSibling)) {
184
+ prevSibling.focus();
185
+ }
186
+ return;
187
+ }
188
+ });
189
+
190
+ document.addEventListener('click', (e) => {
191
+ if (!root.contains(e.target as Element)) close();
192
+ });
193
+ }
@@ -1 +1,2 @@
1
1
  export * from './dropdown-button';
2
+ export * from './dropdown';
package/src/styles.css CHANGED
@@ -6,6 +6,6 @@
6
6
  @import './styles/starlight-compat.css';
7
7
 
8
8
  @import './components/button.css';
9
- @import './components/dropdown-button.css';
9
+ @import './components/dropdown/dropdown.css';
10
10
  @import './components/callout.css';
11
11
  @import './components/accordion.css';
@@ -1,116 +0,0 @@
1
- import clsx from 'clsx';
2
- import { ChevronsUpDown, ExternalLink } from 'lucide-react';
3
- import type { ComponentPropsWithoutRef } from 'react';
4
-
5
- function PrimaryActionText({ children }: { children?: React.ReactNode }) {
6
- return <span data-part="primary-action-text">{children}</span>;
7
- }
8
-
9
- function PrimaryAction({ className, ...props }: ComponentPropsWithoutRef<'button'>) {
10
- return (
11
- <button
12
- data-part="primary-action"
13
- {...props}
14
- className={clsx('stl-ui-dropdown-button__button stl-ui-dropdown-button--action', className)}
15
- />
16
- );
17
- }
18
-
19
- function Trigger({ className, ...props }: ComponentPropsWithoutRef<'button'>) {
20
- return (
21
- <button
22
- aria-haspopup="listbox"
23
- aria-expanded="false"
24
- data-part="trigger"
25
- {...props}
26
- className={clsx('stl-ui-dropdown-button__button stl-ui-dropdown-button__trigger', className)}
27
- >
28
- <ChevronsUpDown size={16} />
29
- </button>
30
- );
31
- }
32
-
33
- function Menu({ className, ...props }: ComponentPropsWithoutRef<'div'>) {
34
- return (
35
- <div
36
- data-state="closed"
37
- data-part="menu"
38
- {...props}
39
- className={clsx('stl-ui-dropdown-button__menu', className)}
40
- />
41
- );
42
- }
43
-
44
- function MenuItemIcon({ className, ...props }: ComponentPropsWithoutRef<'div'>) {
45
- return (
46
- <div
47
- data-part="item-icon"
48
- {...props}
49
- className={clsx('stl-ui-dropdown-button__menu-item-icon', className)}
50
- />
51
- );
52
- }
53
-
54
- function MenuItemText({
55
- className,
56
- subtle,
57
- ...props
58
- }: ComponentPropsWithoutRef<'span'> & { subtle?: boolean }) {
59
- return (
60
- <span
61
- data-part="item-text"
62
- className={clsx(
63
- `stl-ui-dropdown-button__menu-item-text`,
64
- {
65
- 'stl-ui-dropdown-button__menu-item-text--subtle': subtle,
66
- },
67
- className,
68
- )}
69
- {...props}
70
- />
71
- );
72
- }
73
-
74
- function MenuItem({
75
- children,
76
- value,
77
- isExternalLink,
78
- ...props
79
- }: ComponentPropsWithoutRef<'div'> & {
80
- children?: React.ReactNode;
81
- value: string;
82
- isExternalLink?: boolean;
83
- }) {
84
- return (
85
- <div
86
- data-part="item"
87
- data-value={value}
88
- {...props}
89
- className={clsx('stl-ui-dropdown-button__menu-item', props.className)}
90
- >
91
- <div className="stl-ui-dropdown-button__menu-item-content">{children}</div>
92
- {isExternalLink && (
93
- <div
94
- className="stl-ui-dropdown-button__menu-item-external-link-icon"
95
- data-part="item-external-link-icon"
96
- >
97
- <ExternalLink size={16} />
98
- </div>
99
- )}
100
- </div>
101
- );
102
- }
103
-
104
- export function DropdownButton({ className, ...props }: ComponentPropsWithoutRef<'div'>) {
105
- return (
106
- <div {...props} className={clsx('stl-ui-dropdown-button stl-ui-not-prose not-content', className)} />
107
- );
108
- }
109
-
110
- DropdownButton.Menu = Menu;
111
- DropdownButton.MenuItem = MenuItem;
112
- DropdownButton.MenuItemIcon = MenuItemIcon;
113
- DropdownButton.MenuItemText = MenuItemText;
114
- DropdownButton.PrimaryAction = PrimaryAction;
115
- DropdownButton.PrimaryActionText = PrimaryActionText;
116
- DropdownButton.Trigger = Trigger;
@@ -1,165 +0,0 @@
1
- .stl-ui-dropdown-button {
2
- --stl-ui-dropdown-button-color: var(--stl-ui-foreground);
3
- --stl-ui-dropdown-button-background-color: var(--stl-ui-card-background);
4
- --stl-ui-dropdown-button-border-color: var(--stl-ui-border);
5
- --stl-ui-dropdown-button-border-radius: var(--stl-ui-layout-border-radius-sml);
6
- --stl-ui-dropdown-button-font-size: var(--stl-ui-type-scale-text-sm);
7
-
8
- --stl-ui-dropdown-button-height: 32px;
9
- --stl-ui-dropdown-button-padding: 8px 10px;
10
- --stl-ui-dropdown-button-line-height: 100%;
11
- --stl-ui-dropdown-button-font-weight: 500;
12
-
13
- position: relative;
14
- display: inline-flex;
15
- align-items: center;
16
-
17
- background-color: var(--stl-ui-dropdown-button-background-color);
18
- border: 1px solid var(--stl-ui-dropdown-button-border-color);
19
- border-radius: var(--stl-ui-dropdown-button-border-radius);
20
- color: var(--stl-ui-dropdown-button-color);
21
- gap: 0;
22
- font-size: var(--stl-ui-dropdown-button-font-size);
23
-
24
- .stl-ui-dropdown-button__button {
25
- border: none;
26
- background: none;
27
- height: var(--stl-ui-dropdown-button-height);
28
- padding: var(--stl-ui-dropdown-button-padding);
29
- line-height: var(--stl-ui-dropdown-button-line-height);
30
- font-weight: var(--stl-ui-dropdown-button-font-weight);
31
- cursor: pointer;
32
- display: flex;
33
- align-items: center;
34
- justify-content: center;
35
- color: inherit;
36
-
37
- &:hover {
38
- background-color: oklch(from var(--stl-ui-foreground) l c h / 0.05);
39
- border-color: var(--stl-ui-border-emphasis);
40
- }
41
- }
42
-
43
- .stl-ui-dropdown-button--action {
44
- display: flex;
45
- align-items: center;
46
- gap: 8px;
47
-
48
- &:hover {
49
- background-color: oklch(from var(--stl-ui-foreground) l c h / 0.05);
50
- border-color: var(--stl-ui-border-emphasis);
51
- }
52
-
53
- &.disabled {
54
- cursor: not-allowed;
55
- background-color: oklch(from var(--stl-ui-foreground) l c h / 0.05);
56
- }
57
- }
58
-
59
- .stl-ui-dropdown-button__trigger {
60
- border-left: 1px solid var(--stl-ui-border);
61
- border-radius: 0;
62
-
63
- &:hover {
64
- background-color: oklch(from var(--stl-ui-foreground) l c h / 0.05);
65
- border-color: var(--stl-ui-border-emphasis);
66
- }
67
- }
68
-
69
- .stl-ui-dropdown-button__menu {
70
- --stl-ui-dropdown-button__menu-background-color: var(--stl-ui-card-background);
71
- --stl-ui-dropdown-button__menu-border-color: var(--stl-ui-border);
72
- --stl-ui-dropdown-button__menu-box-shadow: var(--stl-ui-shadow-md);
73
- --stl-ui-dropdown-button__menu-border-radius: var(--stl-ui-layout-border-radius-sml);
74
-
75
- background-color: var(--stl-ui-dropdown-button__menu-background-color);
76
- border: 1px solid var(--stl-ui-dropdown-button__menu-border-color);
77
- box-shadow: var(--stl-ui-dropdown-button__menu-box-shadow);
78
- border-radius: var(--stl-ui-dropdown-button__menu-border-radius);
79
-
80
- position: absolute;
81
- top: 100%;
82
- right: 0;
83
- margin-top: 4px;
84
- z-index: 1000;
85
- min-width: 100%;
86
- padding: 4px;
87
- display: none;
88
-
89
- &[data-state='open'] {
90
- display: block;
91
- }
92
- }
93
-
94
- .stl-ui-dropdown-button__menu-item {
95
- --stl-ui-dropdown-button__menu-item-border-radius: var(--stl-ui-dropdown-button-border-radius);
96
- --stl-ui-dropdown-button__menu-item-height: var(--stl-ui-dropdown-button-height);
97
- --stl-ui-dropdown-button__menu-item-line-height: var(--stl-ui-dropdown-button-line-height);
98
- --stl-ui-dropdown-button__menu-item-hover-background-color: oklch(
99
- from var(--stl-ui-foreground) l c h / 0.05
100
- );
101
-
102
- border-radius: var(--stl-ui-dropdown-button__menu-item-border-radius);
103
- height: var(--stl-ui-dropdown-button__menu-item-height);
104
- line-height: var(--stl-ui-dropdown-button__menu-item-line-height);
105
-
106
- padding: 8px;
107
- cursor: pointer;
108
- display: flex;
109
- align-items: center;
110
- justify-content: space-between;
111
- gap: 16px;
112
-
113
- &:hover {
114
- background-color: var(--stl-ui-dropdown-button__menu-item-hover-background-color);
115
- }
116
-
117
- .stl-ui-dropdown-button__menu-item-content {
118
- display: flex;
119
- align-items: center;
120
- gap: 8px;
121
- }
122
-
123
- .stl-ui-dropdown-button__menu-item-text {
124
- white-space: nowrap;
125
- }
126
-
127
- .stl-ui-dropdown-button__menu-item-text--subtle {
128
- color: var(--stl-ui-foreground-muted);
129
- }
130
-
131
- strong {
132
- color: var(--stl-ui-foreground);
133
- font-weight: 500;
134
- }
135
-
136
- .stl-ui-dropdown-button__menu-item-icon {
137
- display: flex;
138
- align-items: center;
139
- justify-content: center;
140
- }
141
-
142
- .stl-ui-dropdown-button__menu-item-external-link-icon {
143
- --stl-ui-dropdown-button__menu-item-external-link-icon-color: oklch(
144
- from var(--stl-ui-foreground) l c h / 0.25
145
- );
146
-
147
- svg {
148
- color: var(--stl-ui-dropdown-button__menu-item-external-link-icon-color);
149
- }
150
- }
151
- }
152
-
153
- hr {
154
- --stl-ui-dropdown-button__divider-height: 1px;
155
- --stl-ui-dropdown-button__divider-color: var(--stl-ui-border);
156
-
157
- height: var(--stl-ui-dropdown-button__divider-height);
158
- background-color: var(--stl-ui-dropdown-button__divider-color);
159
- border: none;
160
-
161
- margin: 4px 0;
162
- width: calc(100% + 8px);
163
- transform: translateX(-4px);
164
- }
165
- }