premium-ds 0.1.0

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.
Files changed (257) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/dist/alert.d.ts +31 -0
  4. package/dist/alert.js +6 -0
  5. package/dist/alert.js.map +1 -0
  6. package/dist/avatar-group.d.ts +13 -0
  7. package/dist/avatar-group.js +3 -0
  8. package/dist/avatar-group.js.map +1 -0
  9. package/dist/avatar.d.ts +25 -0
  10. package/dist/avatar.js +3 -0
  11. package/dist/avatar.js.map +1 -0
  12. package/dist/badge.d.ts +23 -0
  13. package/dist/badge.js +3 -0
  14. package/dist/badge.js.map +1 -0
  15. package/dist/button.d.ts +20 -0
  16. package/dist/button.js +3 -0
  17. package/dist/button.js.map +1 -0
  18. package/dist/checkbox.d.ts +25 -0
  19. package/dist/checkbox.js +3 -0
  20. package/dist/checkbox.js.map +1 -0
  21. package/dist/chunk-2OWHZ4JT.js +36 -0
  22. package/dist/chunk-2OWHZ4JT.js.map +1 -0
  23. package/dist/chunk-34SIXSYL.js +64 -0
  24. package/dist/chunk-34SIXSYL.js.map +1 -0
  25. package/dist/chunk-37O2ZXD6.js +55 -0
  26. package/dist/chunk-37O2ZXD6.js.map +1 -0
  27. package/dist/chunk-4AZL76UJ.js +89 -0
  28. package/dist/chunk-4AZL76UJ.js.map +1 -0
  29. package/dist/chunk-4HSCN5TZ.js +86 -0
  30. package/dist/chunk-4HSCN5TZ.js.map +1 -0
  31. package/dist/chunk-5DDOOT33.js +258 -0
  32. package/dist/chunk-5DDOOT33.js.map +1 -0
  33. package/dist/chunk-5FVHWIMY.js +117 -0
  34. package/dist/chunk-5FVHWIMY.js.map +1 -0
  35. package/dist/chunk-5K6KRJGX.js +147 -0
  36. package/dist/chunk-5K6KRJGX.js.map +1 -0
  37. package/dist/chunk-5PQMQBQC.js +74 -0
  38. package/dist/chunk-5PQMQBQC.js.map +1 -0
  39. package/dist/chunk-7OCTVQ7C.js +95 -0
  40. package/dist/chunk-7OCTVQ7C.js.map +1 -0
  41. package/dist/chunk-7OPMOET7.js +39 -0
  42. package/dist/chunk-7OPMOET7.js.map +1 -0
  43. package/dist/chunk-BXXS7YRC.js +270 -0
  44. package/dist/chunk-BXXS7YRC.js.map +1 -0
  45. package/dist/chunk-CV2Q4YXX.js +272 -0
  46. package/dist/chunk-CV2Q4YXX.js.map +1 -0
  47. package/dist/chunk-EIMMDWIW.js +282 -0
  48. package/dist/chunk-EIMMDWIW.js.map +1 -0
  49. package/dist/chunk-EZ2CWTBE.js +230 -0
  50. package/dist/chunk-EZ2CWTBE.js.map +1 -0
  51. package/dist/chunk-FGHDG3Y4.js +89 -0
  52. package/dist/chunk-FGHDG3Y4.js.map +1 -0
  53. package/dist/chunk-FPP2XLKX.js +127 -0
  54. package/dist/chunk-FPP2XLKX.js.map +1 -0
  55. package/dist/chunk-G6OY35DI.js +295 -0
  56. package/dist/chunk-G6OY35DI.js.map +1 -0
  57. package/dist/chunk-H6KWJNOE.js +65 -0
  58. package/dist/chunk-H6KWJNOE.js.map +1 -0
  59. package/dist/chunk-HGILYGY3.js +45 -0
  60. package/dist/chunk-HGILYGY3.js.map +1 -0
  61. package/dist/chunk-I3BCB4Z5.js +88 -0
  62. package/dist/chunk-I3BCB4Z5.js.map +1 -0
  63. package/dist/chunk-KBWNUUWM.js +582 -0
  64. package/dist/chunk-KBWNUUWM.js.map +1 -0
  65. package/dist/chunk-KN7JFAZ6.js +113 -0
  66. package/dist/chunk-KN7JFAZ6.js.map +1 -0
  67. package/dist/chunk-MEF7PI6U.js +16 -0
  68. package/dist/chunk-MEF7PI6U.js.map +1 -0
  69. package/dist/chunk-NKGMQL6I.js +310 -0
  70. package/dist/chunk-NKGMQL6I.js.map +1 -0
  71. package/dist/chunk-NMFQRGLL.js +127 -0
  72. package/dist/chunk-NMFQRGLL.js.map +1 -0
  73. package/dist/chunk-OUBWD6CX.js +433 -0
  74. package/dist/chunk-OUBWD6CX.js.map +1 -0
  75. package/dist/chunk-PFNXVBLU.js +96 -0
  76. package/dist/chunk-PFNXVBLU.js.map +1 -0
  77. package/dist/chunk-PUPZ4HME.js +165 -0
  78. package/dist/chunk-PUPZ4HME.js.map +1 -0
  79. package/dist/chunk-QFS52OK5.js +690 -0
  80. package/dist/chunk-QFS52OK5.js.map +1 -0
  81. package/dist/chunk-QNC6O3PG.js +45 -0
  82. package/dist/chunk-QNC6O3PG.js.map +1 -0
  83. package/dist/chunk-QUHOXWBK.js +82 -0
  84. package/dist/chunk-QUHOXWBK.js.map +1 -0
  85. package/dist/chunk-UIQGSTBJ.js +106 -0
  86. package/dist/chunk-UIQGSTBJ.js.map +1 -0
  87. package/dist/chunk-UJQKVP6V.js +193 -0
  88. package/dist/chunk-UJQKVP6V.js.map +1 -0
  89. package/dist/chunk-VVPGEAC6.js +11 -0
  90. package/dist/chunk-VVPGEAC6.js.map +1 -0
  91. package/dist/chunk-XA3T5KWA.js +58 -0
  92. package/dist/chunk-XA3T5KWA.js.map +1 -0
  93. package/dist/chunk-YSHJHSJM.js +19 -0
  94. package/dist/chunk-YSHJHSJM.js.map +1 -0
  95. package/dist/chunk-YVHOAVSM.js +182 -0
  96. package/dist/chunk-YVHOAVSM.js.map +1 -0
  97. package/dist/collapse.d.ts +16 -0
  98. package/dist/collapse.js +3 -0
  99. package/dist/collapse.js.map +1 -0
  100. package/dist/count-badge.d.ts +11 -0
  101. package/dist/count-badge.js +4 -0
  102. package/dist/count-badge.js.map +1 -0
  103. package/dist/date-field.d.ts +39 -0
  104. package/dist/date-field.js +8 -0
  105. package/dist/date-field.js.map +1 -0
  106. package/dist/date-range-field.d.ts +30 -0
  107. package/dist/date-range-field.js +8 -0
  108. package/dist/date-range-field.js.map +1 -0
  109. package/dist/datetime-field.d.ts +28 -0
  110. package/dist/datetime-field.js +10 -0
  111. package/dist/datetime-field.js.map +1 -0
  112. package/dist/dialog.d.ts +26 -0
  113. package/dist/dialog.js +7 -0
  114. package/dist/dialog.js.map +1 -0
  115. package/dist/index.d.ts +35 -0
  116. package/dist/index.js +40 -0
  117. package/dist/index.js.map +1 -0
  118. package/dist/motion-tokens.d.ts +29 -0
  119. package/dist/motion-tokens.js +3 -0
  120. package/dist/motion-tokens.js.map +1 -0
  121. package/dist/multi-select.d.ts +25 -0
  122. package/dist/multi-select.js +7 -0
  123. package/dist/multi-select.js.map +1 -0
  124. package/dist/number-field.d.ts +24 -0
  125. package/dist/number-field.js +4 -0
  126. package/dist/number-field.js.map +1 -0
  127. package/dist/otp-field.d.ts +20 -0
  128. package/dist/otp-field.js +3 -0
  129. package/dist/otp-field.js.map +1 -0
  130. package/dist/overlay.d.ts +31 -0
  131. package/dist/overlay.js +4 -0
  132. package/dist/overlay.js.map +1 -0
  133. package/dist/pagination.d.ts +24 -0
  134. package/dist/pagination.js +5 -0
  135. package/dist/pagination.js.map +1 -0
  136. package/dist/radio-group.d.ts +46 -0
  137. package/dist/radio-group.js +6 -0
  138. package/dist/radio-group.js.map +1 -0
  139. package/dist/select-core-SAyS-8w0.d.ts +16 -0
  140. package/dist/select.d.ts +27 -0
  141. package/dist/select.js +7 -0
  142. package/dist/select.js.map +1 -0
  143. package/dist/status-badge.d.ts +17 -0
  144. package/dist/status-badge.js +5 -0
  145. package/dist/status-badge.js.map +1 -0
  146. package/dist/table.d.ts +65 -0
  147. package/dist/table.js +5 -0
  148. package/dist/table.js.map +1 -0
  149. package/dist/tabs.d.ts +44 -0
  150. package/dist/tabs.js +5 -0
  151. package/dist/tabs.js.map +1 -0
  152. package/dist/tag.d.ts +28 -0
  153. package/dist/tag.js +5 -0
  154. package/dist/tag.js.map +1 -0
  155. package/dist/text-field.d.ts +30 -0
  156. package/dist/text-field.js +6 -0
  157. package/dist/text-field.js.map +1 -0
  158. package/dist/textarea.d.ts +33 -0
  159. package/dist/textarea.js +5 -0
  160. package/dist/textarea.js.map +1 -0
  161. package/dist/time-field.d.ts +27 -0
  162. package/dist/time-field.js +6 -0
  163. package/dist/time-field.js.map +1 -0
  164. package/dist/toast-store.d.ts +75 -0
  165. package/dist/toast-store.js +3 -0
  166. package/dist/toast-store.js.map +1 -0
  167. package/dist/toast.d.ts +3 -0
  168. package/dist/toast.js +6 -0
  169. package/dist/toast.js.map +1 -0
  170. package/dist/toggle-tag.d.ts +24 -0
  171. package/dist/toggle-tag.js +4 -0
  172. package/dist/toggle-tag.js.map +1 -0
  173. package/dist/toggle.d.ts +21 -0
  174. package/dist/toggle.js +3 -0
  175. package/dist/toggle.js.map +1 -0
  176. package/dist/tooltip.d.ts +27 -0
  177. package/dist/tooltip.js +4 -0
  178. package/dist/tooltip.js.map +1 -0
  179. package/llms.txt +165 -0
  180. package/package.json +205 -0
  181. package/src/components/alert/Alert.tsx +118 -0
  182. package/src/components/alert/alert.css +136 -0
  183. package/src/components/avatar/Avatar.tsx +128 -0
  184. package/src/components/avatar/AvatarGroup.tsx +50 -0
  185. package/src/components/avatar/avatar.css +200 -0
  186. package/src/components/badge/Badge.tsx +66 -0
  187. package/src/components/badge/CountBadge.tsx +46 -0
  188. package/src/components/badge/StatusBadge.tsx +132 -0
  189. package/src/components/badge/badge.css +243 -0
  190. package/src/components/button/Button.tsx +68 -0
  191. package/src/components/button/button.css +222 -0
  192. package/src/components/checkbox/Checkbox.tsx +90 -0
  193. package/src/components/checkbox/checkbox.css +179 -0
  194. package/src/components/date-picker/DateField.tsx +362 -0
  195. package/src/components/date-picker/DateRangeField.tsx +533 -0
  196. package/src/components/date-picker/DateTimeField.tsx +177 -0
  197. package/src/components/date-picker/TimeField.tsx +100 -0
  198. package/src/components/date-picker/date-picker.css +591 -0
  199. package/src/components/date-picker/date-utils.ts +55 -0
  200. package/src/components/date-picker/field-shell.tsx +78 -0
  201. package/src/components/date-picker/glide-pill.tsx +81 -0
  202. package/src/components/date-picker/time-core.tsx +305 -0
  203. package/src/components/dialog/Dialog.tsx +181 -0
  204. package/src/components/dialog/dialog.css +170 -0
  205. package/src/components/glass/glass.css +100 -0
  206. package/src/components/icon/Icon.tsx +76 -0
  207. package/src/components/icon/IconSlot.tsx +11 -0
  208. package/src/components/icon/icon.css +33 -0
  209. package/src/components/input/NumberField.tsx +117 -0
  210. package/src/components/input/OtpField.tsx +118 -0
  211. package/src/components/input/TextField.tsx +123 -0
  212. package/src/components/input/input.css +335 -0
  213. package/src/components/motion/Collapse.tsx +33 -0
  214. package/src/components/motion/collapse.css +41 -0
  215. package/src/components/overlay/Overlay.tsx +239 -0
  216. package/src/components/overlay/overlay-core.tsx +565 -0
  217. package/src/components/overlay/overlay.css +119 -0
  218. package/src/components/overlay/sheet-drag.tsx +146 -0
  219. package/src/components/pagination/Pagination.tsx +140 -0
  220. package/src/components/pagination/pagination.css +48 -0
  221. package/src/components/radio-group/RadioGroup.tsx +182 -0
  222. package/src/components/radio-group/radio-group.css +277 -0
  223. package/src/components/select/MultiSelect.tsx +251 -0
  224. package/src/components/select/Select.tsx +235 -0
  225. package/src/components/select/select-core.tsx +417 -0
  226. package/src/components/select/select.css +386 -0
  227. package/src/components/table/Table.tsx +433 -0
  228. package/src/components/table/table.css +348 -0
  229. package/src/components/tabs/Tabs.tsx +371 -0
  230. package/src/components/tabs/tabs.css +228 -0
  231. package/src/components/tag/Tag.tsx +145 -0
  232. package/src/components/tag/ToggleTag.tsx +125 -0
  233. package/src/components/tag/tag.css +248 -0
  234. package/src/components/textarea/Textarea.tsx +197 -0
  235. package/src/components/textarea/textarea.css +219 -0
  236. package/src/components/toast/Toast.tsx +349 -0
  237. package/src/components/toast/toast-store.ts +266 -0
  238. package/src/components/toast/toast.css +233 -0
  239. package/src/components/toggle/Toggle.tsx +94 -0
  240. package/src/components/toggle/toggle.css +152 -0
  241. package/src/components/tooltip/Tooltip.tsx +365 -0
  242. package/src/components/tooltip/tooltip.css +86 -0
  243. package/src/index.ts +42 -0
  244. package/src/styles.css +39 -0
  245. package/src/tokens/avatar.css +20 -0
  246. package/src/tokens/color.css +56 -0
  247. package/src/tokens/elevation.css +20 -0
  248. package/src/tokens/fonts.css +3 -0
  249. package/src/tokens/glass.css +21 -0
  250. package/src/tokens/icons.css +7 -0
  251. package/src/tokens/layers.css +6 -0
  252. package/src/tokens/motion-tokens.ts +72 -0
  253. package/src/tokens/motion.css +49 -0
  254. package/src/tokens/radius.css +11 -0
  255. package/src/tokens/semantic.css +75 -0
  256. package/src/tokens/spacing.css +26 -0
  257. package/src/tokens/typography.css +54 -0
@@ -0,0 +1,235 @@
1
+ 'use client';
2
+
3
+ /* Select - single-select custom listbox; committing closes the menu and returns focus. */
4
+ import * as React from 'react';
5
+ import {
6
+ useControllable,
7
+ normalize,
8
+ matches,
9
+ useSelectMenu,
10
+ SelectTrigger,
11
+ SelectMenu,
12
+ SearchField,
13
+ FilterRow,
14
+ EmptyRow,
15
+ LoadingRows,
16
+ type SelectOption,
17
+ type SelectGroup,
18
+ } from './select-core';
19
+ import { Icon } from '../icon/Icon';
20
+ import { IconSlot } from '../icon/IconSlot';
21
+
22
+ export interface SelectProps {
23
+ options: SelectOption[] | SelectGroup[];
24
+ /** Controlled value. */
25
+ value?: string | null;
26
+ defaultValue?: string | null;
27
+ onChange?: (value: string, option: SelectOption) => void;
28
+ placeholder?: string;
29
+ size?: 'sm' | 'default' | 'lg';
30
+ disabled?: boolean;
31
+ /** Danger ring + border. */
32
+ invalid?: boolean;
33
+ /** Skeleton rows in the menu; trigger reads "Loading...". */
34
+ loading?: boolean;
35
+ /** Type-to-filter field pinned above the list. */
36
+ searchable?: boolean;
37
+ searchPlaceholder?: string;
38
+ /** Your own icon node pinned before the trigger label; else the selected option's icon. */
39
+ leadingIcon?: React.ReactNode;
40
+ id?: string;
41
+ ariaLabel?: string;
42
+ }
43
+
44
+ export function Select({
45
+ options = [],
46
+ value: controlledValue,
47
+ defaultValue = null,
48
+ onChange,
49
+ placeholder = 'Select an option',
50
+ size = 'default',
51
+ disabled = false,
52
+ invalid = false,
53
+ loading = false,
54
+ searchable = false,
55
+ searchPlaceholder = 'Filter options',
56
+ leadingIcon = null,
57
+ id,
58
+ ariaLabel,
59
+ }: SelectProps) {
60
+ const { useState, useRef, useEffect, useId } = React;
61
+ const [value, setValue] = useControllable<string | null>(
62
+ controlledValue,
63
+ defaultValue,
64
+ onChange as ((next: string | null, opt?: SelectOption) => void) | undefined,
65
+ );
66
+ const [open, setOpen] = useState(false);
67
+ const [query, setQuery] = useState('');
68
+
69
+ const triggerRef = useRef<HTMLButtonElement>(null),
70
+ listRef = useRef<HTMLDivElement>(null),
71
+ searchRef = useRef<HTMLInputElement>(null);
72
+ const baseId = id || 'select-' + useId();
73
+ const menuId = baseId + '-menu';
74
+ const listId = baseId + '-list';
75
+
76
+ const { groups, flat } = normalize(options);
77
+ const selected = flat.find((o) => o.value === value) || null;
78
+ const isSelected = (v: string) => v === value && value != null;
79
+ const navItems: SelectOption[] = [];
80
+ groups.forEach((g) =>
81
+ g.options.forEach((o) => {
82
+ if (matches(o, query)) navItems.push(o);
83
+ }),
84
+ );
85
+
86
+ const show = () => {
87
+ if (!disabled && !loading) setOpen(true);
88
+ };
89
+ const hide = () => setOpen(false);
90
+ const returnFocus = () => triggerRef.current && triggerRef.current.focus();
91
+
92
+ function commit(opt: SelectOption) {
93
+ if (!opt || opt.disabled) return;
94
+ setValue(opt.value, opt);
95
+ hide();
96
+ returnFocus();
97
+ }
98
+
99
+ useEffect(() => {
100
+ if (!open) setQuery('');
101
+ }, [open]);
102
+
103
+ const { activeIdx, setActiveIdx, onMenuKeyDown } = useSelectMenu({
104
+ open,
105
+ close: hide,
106
+ returnFocus,
107
+ triggerRef,
108
+ listRef,
109
+ searchRef,
110
+ menuId,
111
+ navItems,
112
+ isSelected,
113
+ commit,
114
+ searchable,
115
+ });
116
+
117
+ const isPlaceholder = !loading && !selected;
118
+ const adId = open && activeIdx >= 0 ? baseId + '-opt-' + activeIdx : undefined;
119
+ let vIdx = -1;
120
+
121
+ return (
122
+ <div
123
+ className="select"
124
+ data-size={size === 'default' ? undefined : size}
125
+ data-open={open ? 'true' : undefined}
126
+ data-disabled={disabled ? 'true' : undefined}
127
+ data-invalid={invalid ? 'true' : undefined}
128
+ data-loading={loading ? 'true' : undefined}
129
+ >
130
+ <SelectTrigger
131
+ triggerRef={triggerRef}
132
+ baseId={baseId}
133
+ open={open}
134
+ disabled={disabled}
135
+ invalid={invalid}
136
+ ariaLabel={ariaLabel}
137
+ adId={adId}
138
+ show={show}
139
+ hide={hide}
140
+ leading={leadingIcon || (selected && selected.icon) || null}
141
+ text={loading ? 'Loading...' : isPlaceholder ? placeholder : selected.label}
142
+ isPlaceholder={isPlaceholder}
143
+ />
144
+
145
+ <SelectMenu open={open} menuId={menuId}>
146
+ {searchable && !loading && (
147
+ <SearchField
148
+ searchRef={searchRef}
149
+ query={query}
150
+ onQuery={setQuery}
151
+ onKeyDown={onMenuKeyDown}
152
+ placeholder={searchPlaceholder}
153
+ listId={listId}
154
+ adId={adId}
155
+ />
156
+ )}
157
+
158
+ <div
159
+ className="select__list"
160
+ ref={listRef}
161
+ id={listId}
162
+ role="listbox"
163
+ tabIndex={-1}
164
+ aria-label={ariaLabel}
165
+ onKeyDown={searchable ? undefined : onMenuKeyDown}
166
+ >
167
+ {loading ? (
168
+ <LoadingRows />
169
+ ) : (
170
+ <React.Fragment>
171
+ {groups.map((g, gi) => (
172
+ <div
173
+ className="select__group"
174
+ role="group"
175
+ aria-label={g.label || undefined}
176
+ key={gi}
177
+ >
178
+ {g.label && g.options.some((o) => matches(o, query)) && (
179
+ <div className="select__group-label">{g.label}</div>
180
+ )}
181
+ {g.options.map((opt) => {
182
+ const visible = matches(opt, query);
183
+ const i = visible ? ((vIdx += 1), vIdx) : -1;
184
+ const isSel = isSelected(opt.value);
185
+ const row = (
186
+ <div
187
+ key={opt.value}
188
+ id={visible ? baseId + '-opt-' + i : undefined}
189
+ data-idx={visible ? i : undefined}
190
+ className="select__option"
191
+ role="option"
192
+ aria-selected={isSel}
193
+ aria-hidden={!visible || undefined}
194
+ aria-disabled={opt.disabled || undefined}
195
+ data-selected={isSel ? 'true' : undefined}
196
+ data-active={visible && i === activeIdx ? 'true' : undefined}
197
+ data-disabled={opt.disabled ? 'true' : undefined}
198
+ onMouseEnter={() => visible && !opt.disabled && setActiveIdx(i)}
199
+ onMouseDown={(e) => e.preventDefault() /* keep focus on list */}
200
+ onClick={() => visible && commit(opt)}
201
+ >
202
+ {opt.icon && (
203
+ <span className="select__option-icon">
204
+ <IconSlot size="sm">{opt.icon}</IconSlot>
205
+ </span>
206
+ )}
207
+ <span className="select__option-text">
208
+ <span className="select__option-label">{opt.label}</span>
209
+ {opt.description && (
210
+ <span className="select__option-desc">{opt.description}</span>
211
+ )}
212
+ </span>
213
+ <span className="select__option-check">
214
+ {isSel && <Icon key="on" name="check" size="sm" weight="bold" />}
215
+ </span>
216
+ </div>
217
+ );
218
+ return searchable ? (
219
+ <FilterRow key={opt.value} visible={visible}>
220
+ {row}
221
+ </FilterRow>
222
+ ) : (
223
+ row
224
+ );
225
+ })}
226
+ </div>
227
+ ))}
228
+ {navItems.length === 0 && <EmptyRow query={query} />}
229
+ </React.Fragment>
230
+ )}
231
+ </div>
232
+ </SelectMenu>
233
+ </div>
234
+ );
235
+ }
@@ -0,0 +1,417 @@
1
+ 'use client';
2
+
3
+ /* select-core - shared listbox mechanics for Select + MultiSelect; holds no selection state. */
4
+ import * as React from 'react';
5
+ import { motion as coreMotion, AnimatePresence as CoreAnimatePresence } from 'motion/react';
6
+ import { UIMotion } from '../../tokens/motion-tokens';
7
+ import { Icon } from '../icon/Icon';
8
+ import { IconSlot } from '../icon/IconSlot';
9
+
10
+ const coreSM = UIMotion;
11
+ const {
12
+ useState: coreUseState,
13
+ useEffect: coreUseEffect,
14
+ useLayoutEffect: coreUseLayoutEffect,
15
+ useRef: coreUseRef,
16
+ } = React;
17
+
18
+ export interface SelectOption {
19
+ value: string;
20
+ label: string;
21
+ description?: string;
22
+ /** Your own icon node shown before the label. */
23
+ icon?: React.ReactNode;
24
+ disabled?: boolean;
25
+ }
26
+ export interface SelectGroup {
27
+ label?: string;
28
+ options: SelectOption[];
29
+ }
30
+
31
+ interface NormalizedGroup {
32
+ label: string | null | undefined;
33
+ options: SelectOption[];
34
+ }
35
+
36
+ export function useControllable<T, O = SelectOption>(
37
+ controlled: T | undefined,
38
+ initial: T,
39
+ onChange?: (next: T, opt?: O) => void,
40
+ ): [T, (next: T, opt?: O) => void] {
41
+ const [internal, setInternal] = coreUseState<T>(initial);
42
+ const isControlled = controlled !== undefined;
43
+ const value = isControlled ? (controlled as T) : internal;
44
+ const setValue = (next: T, opt?: O) => {
45
+ if (!isControlled) setInternal(next);
46
+ onChange && onChange(next, opt);
47
+ };
48
+ return [value, setValue];
49
+ }
50
+
51
+ export function normalize(options: SelectOption[] | SelectGroup[]): {
52
+ groups: NormalizedGroup[];
53
+ flat: SelectOption[];
54
+ } {
55
+ const grouped =
56
+ options.length > 0 && options[0] && Array.isArray((options[0] as SelectGroup).options);
57
+ const groups: NormalizedGroup[] = grouped
58
+ ? (options as SelectGroup[]).map((g) => ({ label: g.label, options: g.options || [] }))
59
+ : [{ label: null, options: options as SelectOption[] }];
60
+ return { groups, flat: groups.flatMap((g) => g.options) };
61
+ }
62
+
63
+ export const matches = (o: SelectOption, q: string) =>
64
+ !q || (o.label + ' ' + (o.description || '')).toLowerCase().includes(q.toLowerCase());
65
+
66
+ /* Enter decelerates from the trigger, exit accelerates away; opacity rides a faster clock than the slide. */
67
+ const selectMenuVariants = {
68
+ closed: {
69
+ opacity: 0,
70
+ y: -6,
71
+ scale: 0.96,
72
+ transition: {
73
+ duration: coreSM.dur.base,
74
+ ease: coreSM.ease.exit,
75
+ opacity: { duration: coreSM.dur.fast, ease: coreSM.ease.exit },
76
+ },
77
+ },
78
+ open: {
79
+ opacity: 1,
80
+ y: 0,
81
+ scale: 1,
82
+ transition: {
83
+ duration: coreSM.dur.base,
84
+ ease: coreSM.ease.entrance,
85
+ opacity: { duration: coreSM.dur.fast, ease: coreSM.ease.entrance },
86
+ },
87
+ },
88
+ };
89
+
90
+ export interface UseSelectMenuArgs {
91
+ open: boolean;
92
+ close: () => void;
93
+ returnFocus: () => void;
94
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
95
+ listRef: React.RefObject<HTMLDivElement | null>;
96
+ searchRef: React.RefObject<HTMLInputElement | null>;
97
+ menuId: string;
98
+ navItems: SelectOption[];
99
+ isSelected: (value: string) => boolean;
100
+ commit: (opt: SelectOption) => void;
101
+ searchable: boolean;
102
+ }
103
+
104
+ /* The open-menu interaction machine - variant-blind; selection stays with the caller (isSelected/commit). */
105
+ export function useSelectMenu({
106
+ open,
107
+ close,
108
+ returnFocus,
109
+ triggerRef,
110
+ listRef,
111
+ searchRef,
112
+ menuId,
113
+ navItems,
114
+ isSelected,
115
+ commit,
116
+ searchable,
117
+ }: UseSelectMenuArgs) {
118
+ const [activeIdx, setActiveIdx] = coreUseState(-1);
119
+ const typeahead = coreUseRef<{ buf: string; t: ReturnType<typeof setTimeout> | number }>({
120
+ buf: '',
121
+ t: 0,
122
+ });
123
+
124
+ // outside-click dismiss (the menu isn't in the top layer, so we own this)
125
+ coreUseEffect(() => {
126
+ if (!open) return undefined;
127
+ const onDocPointer = (e: PointerEvent) => {
128
+ const t = triggerRef.current,
129
+ menu = document.getElementById(menuId);
130
+ const target = e.target as Node;
131
+ if ((t && t.contains(target)) || (menu && menu.contains(target))) return;
132
+ close();
133
+ };
134
+ document.addEventListener('pointerdown', onDocPointer, true);
135
+ return () => document.removeEventListener('pointerdown', onDocPointer, true);
136
+ }, [open, menuId]); // eslint-disable-line react-hooks/exhaustive-deps
137
+
138
+ // placement under the trigger, flips above when cramped; layout effect lands coords before paint so the entrance plays in place
139
+ coreUseLayoutEffect(() => {
140
+ if (!open) return undefined;
141
+ const place = () => {
142
+ const t = triggerRef.current,
143
+ menu = document.getElementById(menuId);
144
+ if (!t || !menu) return;
145
+ const r = t.getBoundingClientRect(),
146
+ gap = 4;
147
+ menu.style.minWidth = r.width + 'px';
148
+ menu.style.left = Math.round(r.left) + 'px';
149
+ const mh = menu.offsetHeight,
150
+ room = window.innerHeight - r.bottom;
151
+ const above = room < mh + gap && r.top > room;
152
+ menu.style.top = Math.round(above ? r.top - mh - gap : r.bottom + gap) + 'px';
153
+ menu.setAttribute('data-placement', above ? 'top' : 'bottom');
154
+ };
155
+ place();
156
+ const onScroll = () => place();
157
+ window.addEventListener('scroll', onScroll, true);
158
+ window.addEventListener('resize', onScroll);
159
+ return () => {
160
+ window.removeEventListener('scroll', onScroll, true);
161
+ window.removeEventListener('resize', onScroll);
162
+ };
163
+ }, [open, menuId, navItems.length]); // eslint-disable-line react-hooks/exhaustive-deps
164
+
165
+ coreUseEffect(() => {
166
+ if (!open) return;
167
+ const sel = navItems.findIndex((o) => isSelected(o.value) && !o.disabled);
168
+ const first = navItems.findIndex((o) => !o.disabled);
169
+ setActiveIdx(sel >= 0 ? sel : first);
170
+ const ref = searchable ? searchRef : listRef;
171
+ setTimeout(() => {
172
+ if (ref.current) ref.current.focus();
173
+ }, 0); // after the menu paints
174
+ }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
175
+
176
+ // keep the active row in view (manual - never scrollIntoView)
177
+ coreUseEffect(() => {
178
+ const list = listRef.current;
179
+ if (!open || activeIdx < 0 || !list) return;
180
+ const el = list.querySelector('[data-idx="' + activeIdx + '"]') as HTMLElement | null;
181
+ if (!el) return;
182
+ if (el.offsetTop < list.scrollTop) list.scrollTop = el.offsetTop;
183
+ else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight)
184
+ list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight;
185
+ }, [activeIdx, open]); // eslint-disable-line react-hooks/exhaustive-deps
186
+
187
+ function moveActive(dir: number) {
188
+ if (!navItems.length) return;
189
+ let i = activeIdx;
190
+ for (let s = 0; s < navItems.length; s++) {
191
+ i = (i + dir + navItems.length) % navItems.length;
192
+ if (!navItems[i].disabled) return setActiveIdx(i);
193
+ }
194
+ }
195
+ function edgeActive(toEnd: boolean) {
196
+ const order = [...navItems.keys()];
197
+ if (toEnd) order.reverse();
198
+ for (const i of order) if (!navItems[i].disabled) return setActiveIdx(i);
199
+ }
200
+ function typeAhead(ch: string) {
201
+ const ta = typeahead.current;
202
+ clearTimeout(ta.t);
203
+ ta.buf += ch.toLowerCase();
204
+ ta.t = setTimeout(() => (ta.buf = ''), 600);
205
+ const i = navItems.findIndex((o) => !o.disabled && o.label.toLowerCase().startsWith(ta.buf));
206
+ if (i >= 0) setActiveIdx(i);
207
+ }
208
+
209
+ function onMenuKeyDown(e: React.KeyboardEvent) {
210
+ switch (e.key) {
211
+ case 'Escape':
212
+ e.preventDefault();
213
+ close();
214
+ returnFocus();
215
+ break;
216
+ case 'ArrowDown':
217
+ e.preventDefault();
218
+ moveActive(1);
219
+ break;
220
+ case 'ArrowUp':
221
+ e.preventDefault();
222
+ moveActive(-1);
223
+ break;
224
+ case 'Home':
225
+ e.preventDefault();
226
+ edgeActive(false);
227
+ break;
228
+ case 'End':
229
+ e.preventDefault();
230
+ edgeActive(true);
231
+ break;
232
+ case 'Enter':
233
+ e.preventDefault();
234
+ if (activeIdx >= 0) commit(navItems[activeIdx]);
235
+ break;
236
+ case 'Tab':
237
+ close();
238
+ break;
239
+ default:
240
+ if (!searchable && e.key.length === 1 && !e.metaKey && !e.ctrlKey) typeAhead(e.key);
241
+ }
242
+ }
243
+
244
+ return { activeIdx, setActiveIdx, onMenuKeyDown };
245
+ }
246
+
247
+ export interface SelectTriggerProps {
248
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
249
+ baseId: string;
250
+ open: boolean;
251
+ disabled?: boolean;
252
+ invalid?: boolean;
253
+ ariaLabel?: string;
254
+ adId?: string;
255
+ show: () => void;
256
+ hide: () => void;
257
+ leading?: React.ReactNode;
258
+ text?: string;
259
+ isPlaceholder?: boolean;
260
+ count?: number;
261
+ }
262
+
263
+ export function SelectTrigger({
264
+ triggerRef,
265
+ baseId,
266
+ open,
267
+ disabled,
268
+ invalid,
269
+ ariaLabel,
270
+ adId,
271
+ show,
272
+ hide,
273
+ leading,
274
+ text,
275
+ isPlaceholder,
276
+ count,
277
+ }: SelectTriggerProps) {
278
+ return (
279
+ <button
280
+ type="button"
281
+ ref={triggerRef}
282
+ id={baseId + '-trigger'}
283
+ className="select__trigger"
284
+ role="combobox"
285
+ aria-haspopup="listbox"
286
+ aria-expanded={open}
287
+ aria-controls={baseId + '-list'}
288
+ aria-activedescendant={adId}
289
+ aria-label={ariaLabel}
290
+ aria-invalid={invalid || undefined}
291
+ disabled={disabled}
292
+ onClick={() => (open ? hide() : show())}
293
+ onKeyDown={(e) => {
294
+ if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
295
+ e.preventDefault();
296
+ show();
297
+ }
298
+ }}
299
+ >
300
+ {leading && (
301
+ <span className="select__leading">
302
+ <IconSlot size="sm">{leading}</IconSlot>
303
+ </span>
304
+ )}
305
+ <span className="select__value" data-placeholder={isPlaceholder ? 'true' : undefined}>
306
+ {text}
307
+ </span>
308
+ {count != null && count > 0 && <span className="select__count">+{count}</span>}
309
+ <span className="select__caret">
310
+ <Icon name="caret-down" size="sm" />
311
+ </span>
312
+ </button>
313
+ );
314
+ }
315
+
316
+ export interface SelectMenuProps {
317
+ open: boolean;
318
+ menuId: string;
319
+ children?: React.ReactNode;
320
+ }
321
+
322
+ export function SelectMenu({ open, menuId, children }: SelectMenuProps) {
323
+ return (
324
+ <CoreAnimatePresence>
325
+ {open && (
326
+ <coreMotion.div
327
+ className="select__menu"
328
+ id={menuId}
329
+ role="presentation"
330
+ variants={selectMenuVariants}
331
+ initial="closed"
332
+ animate="open"
333
+ exit="closed"
334
+ >
335
+ {children}
336
+ </coreMotion.div>
337
+ )}
338
+ </CoreAnimatePresence>
339
+ );
340
+ }
341
+
342
+ export interface SearchFieldProps {
343
+ searchRef: React.RefObject<HTMLInputElement | null>;
344
+ query: string;
345
+ onQuery: (value: string) => void;
346
+ onKeyDown: (e: React.KeyboardEvent) => void;
347
+ placeholder?: string;
348
+ listId: string;
349
+ adId?: string;
350
+ }
351
+
352
+ export function SearchField({
353
+ searchRef,
354
+ query,
355
+ onQuery,
356
+ onKeyDown,
357
+ placeholder,
358
+ listId,
359
+ adId,
360
+ }: SearchFieldProps) {
361
+ return (
362
+ <div className="select__search">
363
+ <Icon name="magnifying-glass" size="sm" />
364
+ <input
365
+ ref={searchRef}
366
+ className="select__search-input"
367
+ type="text"
368
+ value={query}
369
+ placeholder={placeholder}
370
+ aria-label={placeholder}
371
+ aria-controls={listId}
372
+ aria-activedescendant={adId}
373
+ onChange={(e) => onQuery(e.target.value)}
374
+ onKeyDown={onKeyDown}
375
+ />
376
+ </div>
377
+ );
378
+ }
379
+
380
+ export interface FilterRowProps {
381
+ visible: boolean;
382
+ children?: React.ReactNode;
383
+ }
384
+
385
+ /* Collapse wrapper for filterable rows - rows ease out instead of popping. */
386
+ export function FilterRow({ visible, children }: FilterRowProps) {
387
+ return (
388
+ <div
389
+ className="collapse collapse--fade"
390
+ data-open={visible ? 'true' : 'false'}
391
+ data-axis="height"
392
+ >
393
+ <div className="collapse__inner">{children}</div>
394
+ </div>
395
+ );
396
+ }
397
+
398
+ export function EmptyRow({ query }: { query: string }) {
399
+ return (
400
+ <div className="select__empty">
401
+ {query ? 'No matches for "' + query + '"' : 'No options available'}
402
+ </div>
403
+ );
404
+ }
405
+
406
+ export function LoadingRows() {
407
+ return (
408
+ <div className="select__loading" aria-busy="true" aria-live="polite">
409
+ {[0, 1, 2].map((i) => (
410
+ <div className="select__skeleton" key={i}>
411
+ <span className="select__skeleton-dot" data-pulse></span>
412
+ <span className="select__skeleton-bar" data-pulse></span>
413
+ </div>
414
+ ))}
415
+ </div>
416
+ );
417
+ }