@zyncat/ui 0.8.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 (334) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/alert.css +132 -0
  4. package/dist/alert.d.ts +46 -0
  5. package/dist/alert.js +80 -0
  6. package/dist/alert.js.map +1 -0
  7. package/dist/avatar-group.d.ts +24 -0
  8. package/dist/avatar-group.js +23 -0
  9. package/dist/avatar-group.js.map +1 -0
  10. package/dist/avatar.css +200 -0
  11. package/dist/avatar.d.ts +35 -0
  12. package/dist/avatar.js +67 -0
  13. package/dist/avatar.js.map +1 -0
  14. package/dist/badge.css +182 -0
  15. package/dist/badge.d.ts +34 -0
  16. package/dist/badge.js +4 -0
  17. package/dist/badge.js.map +1 -0
  18. package/dist/button.css +269 -0
  19. package/dist/button.d.ts +44 -0
  20. package/dist/button.js +4 -0
  21. package/dist/button.js.map +1 -0
  22. package/dist/checkbox.css +177 -0
  23. package/dist/checkbox.d.ts +35 -0
  24. package/dist/checkbox.js +5 -0
  25. package/dist/checkbox.js.map +1 -0
  26. package/dist/chunk-2ERBBFZS.js +12 -0
  27. package/dist/chunk-2ERBBFZS.js.map +1 -0
  28. package/dist/chunk-2HORSLXJ.js +191 -0
  29. package/dist/chunk-2HORSLXJ.js.map +1 -0
  30. package/dist/chunk-36WVAQKL.js +40 -0
  31. package/dist/chunk-36WVAQKL.js.map +1 -0
  32. package/dist/chunk-3SBCBYB7.js +575 -0
  33. package/dist/chunk-3SBCBYB7.js.map +1 -0
  34. package/dist/chunk-4R2GQO43.js +125 -0
  35. package/dist/chunk-4R2GQO43.js.map +1 -0
  36. package/dist/chunk-64UU4LGU.js +258 -0
  37. package/dist/chunk-64UU4LGU.js.map +1 -0
  38. package/dist/chunk-7SZCJ7WI.js +35 -0
  39. package/dist/chunk-7SZCJ7WI.js.map +1 -0
  40. package/dist/chunk-BIDVNLJO.js +38 -0
  41. package/dist/chunk-BIDVNLJO.js.map +1 -0
  42. package/dist/chunk-CONSWNIV.js +17 -0
  43. package/dist/chunk-CONSWNIV.js.map +1 -0
  44. package/dist/chunk-EQ32F2QJ.js +75 -0
  45. package/dist/chunk-EQ32F2QJ.js.map +1 -0
  46. package/dist/chunk-GY7JOPUQ.js +111 -0
  47. package/dist/chunk-GY7JOPUQ.js.map +1 -0
  48. package/dist/chunk-I4XQ4PV2.js +30 -0
  49. package/dist/chunk-I4XQ4PV2.js.map +1 -0
  50. package/dist/chunk-JSKXZMLH.js +67 -0
  51. package/dist/chunk-JSKXZMLH.js.map +1 -0
  52. package/dist/chunk-L3Q3VDIW.js +295 -0
  53. package/dist/chunk-L3Q3VDIW.js.map +1 -0
  54. package/dist/chunk-LDC3RIYA.js +62 -0
  55. package/dist/chunk-LDC3RIYA.js.map +1 -0
  56. package/dist/chunk-MKIQFK7S.js +43 -0
  57. package/dist/chunk-MKIQFK7S.js.map +1 -0
  58. package/dist/chunk-MMRIQNEN.js +20 -0
  59. package/dist/chunk-MMRIQNEN.js.map +1 -0
  60. package/dist/chunk-MXOPCFWR.js +59 -0
  61. package/dist/chunk-MXOPCFWR.js.map +1 -0
  62. package/dist/chunk-NEF5XAHG.js +17 -0
  63. package/dist/chunk-NEF5XAHG.js.map +1 -0
  64. package/dist/chunk-NNXJCLTV.js +22 -0
  65. package/dist/chunk-NNXJCLTV.js.map +1 -0
  66. package/dist/chunk-O4N2OBTR.js +94 -0
  67. package/dist/chunk-O4N2OBTR.js.map +1 -0
  68. package/dist/chunk-PMECHR3A.js +38 -0
  69. package/dist/chunk-PMECHR3A.js.map +1 -0
  70. package/dist/chunk-QDK66S4G.js +44 -0
  71. package/dist/chunk-QDK66S4G.js.map +1 -0
  72. package/dist/chunk-QGNC7LH5.js +8 -0
  73. package/dist/chunk-QGNC7LH5.js.map +1 -0
  74. package/dist/chunk-QMSJZCK3.js +398 -0
  75. package/dist/chunk-QMSJZCK3.js.map +1 -0
  76. package/dist/chunk-S5ZTZANS.js +57 -0
  77. package/dist/chunk-S5ZTZANS.js.map +1 -0
  78. package/dist/chunk-S7B34VGN.js +14 -0
  79. package/dist/chunk-S7B34VGN.js.map +1 -0
  80. package/dist/chunk-TCUCYFT7.js +25 -0
  81. package/dist/chunk-TCUCYFT7.js.map +1 -0
  82. package/dist/chunk-TYHR4VNM.js +193 -0
  83. package/dist/chunk-TYHR4VNM.js.map +1 -0
  84. package/dist/chunk-U5QQZC3U.js +56 -0
  85. package/dist/chunk-U5QQZC3U.js.map +1 -0
  86. package/dist/chunk-UE63OOVG.js +13 -0
  87. package/dist/chunk-UE63OOVG.js.map +1 -0
  88. package/dist/chunk-VTJTGVZZ.js +22 -0
  89. package/dist/chunk-VTJTGVZZ.js.map +1 -0
  90. package/dist/chunk-W3SHPKSB.js +49 -0
  91. package/dist/chunk-W3SHPKSB.js.map +1 -0
  92. package/dist/chunk-XZAEIQDR.js +188 -0
  93. package/dist/chunk-XZAEIQDR.js.map +1 -0
  94. package/dist/chunk-YGGCQNNU.js +92 -0
  95. package/dist/chunk-YGGCQNNU.js.map +1 -0
  96. package/dist/collapse.css +88 -0
  97. package/dist/collapse.d.ts +46 -0
  98. package/dist/collapse.js +5 -0
  99. package/dist/collapse.js.map +1 -0
  100. package/dist/count-badge.d.ts +13 -0
  101. package/dist/count-badge.js +13 -0
  102. package/dist/count-badge.js.map +1 -0
  103. package/dist/date-field.d.ts +25 -0
  104. package/dist/date-field.js +87 -0
  105. package/dist/date-field.js.map +1 -0
  106. package/dist/date-picker.css +595 -0
  107. package/dist/date-range-field.d.ts +33 -0
  108. package/dist/date-range-field.js +404 -0
  109. package/dist/date-range-field.js.map +1 -0
  110. package/dist/datetime-field.d.ts +29 -0
  111. package/dist/datetime-field.js +155 -0
  112. package/dist/datetime-field.js.map +1 -0
  113. package/dist/dialog.css +180 -0
  114. package/dist/dialog.d.ts +42 -0
  115. package/dist/dialog.js +128 -0
  116. package/dist/dialog.js.map +1 -0
  117. package/dist/dom-props-C0jrllBw.d.ts +5 -0
  118. package/dist/field-chrome-CMPOQSLs.d.ts +28 -0
  119. package/dist/field-shell-Cuda-feL.d.ts +24 -0
  120. package/dist/glide.d.ts +15 -0
  121. package/dist/glide.js +5 -0
  122. package/dist/glide.js.map +1 -0
  123. package/dist/icon.css +33 -0
  124. package/dist/input.css +333 -0
  125. package/dist/mcp.js +359 -0
  126. package/dist/motion-devtools.css +265 -0
  127. package/dist/motion-devtools.d.ts +51 -0
  128. package/dist/motion-devtools.js +354 -0
  129. package/dist/motion-devtools.js.map +1 -0
  130. package/dist/motion-scale-Canq4stZ.d.ts +18 -0
  131. package/dist/motion-tokens.d.ts +28 -0
  132. package/dist/motion-tokens.js +3 -0
  133. package/dist/motion-tokens.js.map +1 -0
  134. package/dist/multi-select.d.ts +44 -0
  135. package/dist/multi-select.js +109 -0
  136. package/dist/multi-select.js.map +1 -0
  137. package/dist/number-field.d.ts +36 -0
  138. package/dist/number-field.js +112 -0
  139. package/dist/number-field.js.map +1 -0
  140. package/dist/odometer.css +62 -0
  141. package/dist/otp-field.d.ts +31 -0
  142. package/dist/otp-field.js +98 -0
  143. package/dist/otp-field.js.map +1 -0
  144. package/dist/overlay.css +23 -0
  145. package/dist/pagination.css +43 -0
  146. package/dist/pagination.d.ts +29 -0
  147. package/dist/pagination.js +107 -0
  148. package/dist/pagination.js.map +1 -0
  149. package/dist/popover.css +69 -0
  150. package/dist/popover.d.ts +35 -0
  151. package/dist/popover.js +15 -0
  152. package/dist/popover.js.map +1 -0
  153. package/dist/radio-group.css +300 -0
  154. package/dist/radio-group.d.ts +52 -0
  155. package/dist/radio-group.js +115 -0
  156. package/dist/radio-group.js.map +1 -0
  157. package/dist/select.css +416 -0
  158. package/dist/select.d.ts +49 -0
  159. package/dist/select.js +99 -0
  160. package/dist/select.js.map +1 -0
  161. package/dist/sheet.css +26 -0
  162. package/dist/sheet.d.ts +31 -0
  163. package/dist/sheet.js +14 -0
  164. package/dist/sheet.js.map +1 -0
  165. package/dist/status-badge.d.ts +19 -0
  166. package/dist/status-badge.js +72 -0
  167. package/dist/status-badge.js.map +1 -0
  168. package/dist/table.css +348 -0
  169. package/dist/table.d.ts +75 -0
  170. package/dist/table.js +262 -0
  171. package/dist/table.js.map +1 -0
  172. package/dist/tabs.css +208 -0
  173. package/dist/tabs.d.ts +71 -0
  174. package/dist/tabs.js +181 -0
  175. package/dist/tabs.js.map +1 -0
  176. package/dist/tag.css +248 -0
  177. package/dist/tag.d.ts +52 -0
  178. package/dist/tag.js +72 -0
  179. package/dist/tag.js.map +1 -0
  180. package/dist/text-field.d.ts +28 -0
  181. package/dist/text-field.js +69 -0
  182. package/dist/text-field.js.map +1 -0
  183. package/dist/textarea.css +219 -0
  184. package/dist/textarea.d.ts +38 -0
  185. package/dist/textarea.js +144 -0
  186. package/dist/textarea.js.map +1 -0
  187. package/dist/time-field.d.ts +28 -0
  188. package/dist/time-field.js +66 -0
  189. package/dist/time-field.js.map +1 -0
  190. package/dist/timing-7JA_zQHP.d.ts +27 -0
  191. package/dist/toast-store.d.ts +86 -0
  192. package/dist/toast-store.js +3 -0
  193. package/dist/toast-store.js.map +1 -0
  194. package/dist/toast.css +257 -0
  195. package/dist/toast.d.ts +12 -0
  196. package/dist/toast.js +310 -0
  197. package/dist/toast.js.map +1 -0
  198. package/dist/toggle-tag.d.ts +34 -0
  199. package/dist/toggle-tag.js +57 -0
  200. package/dist/toggle-tag.js.map +1 -0
  201. package/dist/toggle.css +152 -0
  202. package/dist/toggle.d.ts +31 -0
  203. package/dist/toggle.js +75 -0
  204. package/dist/toggle.js.map +1 -0
  205. package/dist/tooltip.css +86 -0
  206. package/dist/tooltip.d.ts +30 -0
  207. package/dist/tooltip.js +268 -0
  208. package/dist/tooltip.js.map +1 -0
  209. package/dist/types-SGMShK7R.d.ts +22 -0
  210. package/llms.txt +196 -0
  211. package/next.d.mts +7 -0
  212. package/next.mjs +59 -0
  213. package/package.json +257 -0
  214. package/src/components/composites/alert/Alert.tsx +141 -0
  215. package/src/components/composites/alert/alert.css +132 -0
  216. package/src/components/composites/date-picker/DateField.tsx +80 -0
  217. package/src/components/composites/date-picker/DateRangeField.tsx +110 -0
  218. package/src/components/composites/date-picker/DateTimeField.tsx +162 -0
  219. package/src/components/composites/date-picker/TimeField.tsx +78 -0
  220. package/src/components/composites/date-picker/calendar-panel.tsx +216 -0
  221. package/src/components/composites/date-picker/date-picker.css +595 -0
  222. package/src/components/composites/date-picker/date-utils.ts +56 -0
  223. package/src/components/composites/date-picker/field-shell.tsx +85 -0
  224. package/src/components/composites/date-picker/range-panel.tsx +363 -0
  225. package/src/components/composites/date-picker/time-core.tsx +290 -0
  226. package/src/components/composites/date-picker/use-day-focus.ts +35 -0
  227. package/src/components/composites/dialog/Dialog.tsx +198 -0
  228. package/src/components/composites/dialog/dialog.css +180 -0
  229. package/src/components/composites/pagination/Pagination.tsx +131 -0
  230. package/src/components/composites/pagination/pagination.css +43 -0
  231. package/src/components/composites/popover/Popover.tsx +141 -0
  232. package/src/components/composites/popover/popover.css +69 -0
  233. package/src/components/composites/select/MultiSelect.tsx +133 -0
  234. package/src/components/composites/select/Select.tsx +122 -0
  235. package/src/components/composites/select/core/index.ts +4 -0
  236. package/src/components/composites/select/core/menu.tsx +100 -0
  237. package/src/components/composites/select/core/panel.tsx +154 -0
  238. package/src/components/composites/select/core/trigger.tsx +74 -0
  239. package/src/components/composites/select/core/types.ts +39 -0
  240. package/src/components/composites/select/core/use-listbox.ts +182 -0
  241. package/src/components/composites/select/select.css +416 -0
  242. package/src/components/composites/sheet/Sheet.tsx +135 -0
  243. package/src/components/composites/sheet/sheet.css +26 -0
  244. package/src/components/composites/sheet/use-sheet-drag.ts +126 -0
  245. package/src/components/composites/table/Table.tsx +377 -0
  246. package/src/components/composites/table/table.css +348 -0
  247. package/src/components/composites/tabs/Tabs.tsx +278 -0
  248. package/src/components/composites/tabs/tabs.css +208 -0
  249. package/src/components/composites/toast/Toast.tsx +380 -0
  250. package/src/components/composites/toast/toast-store.ts +295 -0
  251. package/src/components/composites/toast/toast.css +257 -0
  252. package/src/components/composites/tooltip/Tooltip.tsx +110 -0
  253. package/src/components/composites/tooltip/tooltip-host.tsx +184 -0
  254. package/src/components/composites/tooltip/tooltip-store.ts +81 -0
  255. package/src/components/composites/tooltip/tooltip.css +86 -0
  256. package/src/components/dev/MotionDevtools.tsx +200 -0
  257. package/src/components/dev/motion-devtools.css +265 -0
  258. package/src/components/dev/slowmo-engine.ts +230 -0
  259. package/src/components/internal/glass/glass.css +100 -0
  260. package/src/components/internal/glass/glint.ts +9 -0
  261. package/src/components/internal/hooks/use-controllable.ts +21 -0
  262. package/src/components/internal/hooks/use-scroll-edges.ts +46 -0
  263. package/src/components/internal/icon/Icon.tsx +66 -0
  264. package/src/components/internal/icon/IconSlot.tsx +10 -0
  265. package/src/components/internal/icon/icon.css +33 -0
  266. package/src/components/internal/overlay/focus.ts +68 -0
  267. package/src/components/internal/overlay/layer.tsx +149 -0
  268. package/src/components/internal/overlay/modal.tsx +141 -0
  269. package/src/components/internal/overlay/overlay.css +23 -0
  270. package/src/components/internal/overlay/position.ts +103 -0
  271. package/src/components/internal/utils/cx.ts +3 -0
  272. package/src/components/internal/utils/token-px.ts +8 -0
  273. package/src/components/primitives/avatar/Avatar.tsx +120 -0
  274. package/src/components/primitives/avatar/AvatarGroup.tsx +56 -0
  275. package/src/components/primitives/avatar/avatar.css +200 -0
  276. package/src/components/primitives/badge/Badge.tsx +75 -0
  277. package/src/components/primitives/badge/CountBadge.tsx +20 -0
  278. package/src/components/primitives/badge/StatusBadge.tsx +111 -0
  279. package/src/components/primitives/badge/badge.css +182 -0
  280. package/src/components/primitives/badge/odometer.css +62 -0
  281. package/src/components/primitives/badge/odometer.tsx +36 -0
  282. package/src/components/primitives/button/Button.tsx +102 -0
  283. package/src/components/primitives/button/button.css +269 -0
  284. package/src/components/primitives/checkbox/Checkbox.tsx +76 -0
  285. package/src/components/primitives/checkbox/check-glyph.tsx +28 -0
  286. package/src/components/primitives/checkbox/checkbox.css +177 -0
  287. package/src/components/primitives/collapse/Collapse.tsx +86 -0
  288. package/src/components/primitives/collapse/collapse.css +88 -0
  289. package/src/components/primitives/collapse/collapse.typecheck.tsx +29 -0
  290. package/src/components/primitives/input/NumberField.tsx +139 -0
  291. package/src/components/primitives/input/OtpField.tsx +134 -0
  292. package/src/components/primitives/input/TextField.tsx +110 -0
  293. package/src/components/primitives/input/field-chrome.tsx +83 -0
  294. package/src/components/primitives/input/input.css +333 -0
  295. package/src/components/primitives/radio-group/RadioGroup.tsx +183 -0
  296. package/src/components/primitives/radio-group/radio-group.css +300 -0
  297. package/src/components/primitives/tag/Tag.tsx +143 -0
  298. package/src/components/primitives/tag/ToggleTag.tsx +97 -0
  299. package/src/components/primitives/tag/tag.css +248 -0
  300. package/src/components/primitives/textarea/Textarea.tsx +190 -0
  301. package/src/components/primitives/textarea/textarea.css +219 -0
  302. package/src/components/primitives/toggle/Toggle.tsx +105 -0
  303. package/src/components/primitives/toggle/toggle.css +152 -0
  304. package/src/css.d.ts +1 -0
  305. package/src/dom-props.ts +1 -0
  306. package/src/engine/animate.ts +141 -0
  307. package/src/engine/drag.ts +53 -0
  308. package/src/engine/ease.ts +155 -0
  309. package/src/engine/flip.ts +59 -0
  310. package/src/engine/index.ts +13 -0
  311. package/src/mcp/server.ts +420 -0
  312. package/src/motion/element.tsx +53 -0
  313. package/src/motion/flip.tsx +87 -0
  314. package/src/motion/glide.tsx +47 -0
  315. package/src/motion/motion-timing.ts +33 -0
  316. package/src/motion/presence-context.ts +18 -0
  317. package/src/motion/presence.tsx +91 -0
  318. package/src/motion/timing.ts +49 -0
  319. package/src/motion/use-motion.ts +64 -0
  320. package/src/styles.css +26 -0
  321. package/src/tokens/avatar.css +20 -0
  322. package/src/tokens/color.css +58 -0
  323. package/src/tokens/elevation.css +22 -0
  324. package/src/tokens/fonts.css +3 -0
  325. package/src/tokens/glass.css +20 -0
  326. package/src/tokens/icons.css +7 -0
  327. package/src/tokens/layers.css +6 -0
  328. package/src/tokens/motion-scale.ts +17 -0
  329. package/src/tokens/motion-tokens.ts +92 -0
  330. package/src/tokens/motion.css +49 -0
  331. package/src/tokens/radius.css +11 -0
  332. package/src/tokens/semantic.css +65 -0
  333. package/src/tokens/spacing.css +26 -0
  334. package/src/tokens/typography.css +52 -0
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import { useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react';
4
+ import type { Layer } from '../../../../engine';
5
+ import { Presence } from '../../../../motion/presence';
6
+ import { useMotion, type MotionSpecs } from '../../../../motion/use-motion';
7
+ import { UIMotion, type MotionTransition } from '../../../../tokens/motion-tokens';
8
+ import { resolveMotionTiming } from '../../../../motion/motion-timing';
9
+ import type { DisableableAnimation } from '../../../../motion/timing';
10
+ import { OverlayPortal, useOverlayEntry, useOutsidePress } from '../../../internal/overlay/layer';
11
+ import { useAnchorPosition } from '../../../internal/overlay/position';
12
+
13
+ const SELECT_MENU_TIMING = {
14
+ open: { duration: 'base', ease: 'entrance' },
15
+ close: { duration: 'base', ease: 'exit' },
16
+ } as const;
17
+
18
+ function selectMenuLayers(animation: DisableableAnimation | undefined, dir: 'open' | 'close'): Layer[] {
19
+ const t = resolveMotionTiming(animation, SELECT_MENU_TIMING)[dir];
20
+ const fade = (d: MotionTransition) => ({ duration: d.duration === 0 ? 0 : UIMotion.dur.fast, ease: d.ease });
21
+ return dir === 'open'
22
+ ? [
23
+ { y: [-6, 0], scale: [0.96, 1], timing: t },
24
+ { opacity: [0, 1], timing: fade(t) },
25
+ ]
26
+ : [
27
+ { y: [-6], scale: [0.96], timing: t },
28
+ { opacity: [0], timing: fade(t) },
29
+ ];
30
+ }
31
+
32
+ export interface SelectMenuProps {
33
+ open: boolean;
34
+ menuId: string;
35
+ requestClose: () => void;
36
+ triggerRef: RefObject<HTMLButtonElement | null>;
37
+ multiple?: boolean;
38
+ /** Open/close timing - motion tokens only, or `null` to disable. */
39
+ animation?: DisableableAnimation;
40
+ children?: ReactNode;
41
+ }
42
+
43
+ function MenuSurface({
44
+ menuId,
45
+ requestClose,
46
+ triggerRef,
47
+ multiple,
48
+ animate,
49
+ exit,
50
+ children,
51
+ }: Omit<SelectMenuProps, 'open' | 'animation'> & MotionSpecs) {
52
+ const menuRef = useRef<HTMLDivElement>(null);
53
+ const entry = useOverlayEntry({ nodeRef: menuRef, dismissible: true, requestClose });
54
+ useMotion(menuRef, { animate, exit });
55
+ useLayoutEffect(() => {
56
+ const apply = () => {
57
+ const t = triggerRef.current;
58
+ if (t && menuRef.current) menuRef.current.style.minWidth = t.getBoundingClientRect().width + 'px';
59
+ };
60
+ apply();
61
+ window.addEventListener('resize', apply);
62
+ return () => window.removeEventListener('resize', apply);
63
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
64
+ useAnchorPosition({ side: 'bottom', align: 'start', arrow: false, triggerRef, panelRef: menuRef });
65
+ useOutsidePress({ entry, refs: [menuRef, triggerRef], enabled: true, onPress: requestClose });
66
+
67
+ return (
68
+ <div
69
+ ref={menuRef}
70
+ className="select__menu"
71
+ id={menuId}
72
+ role="presentation"
73
+ data-multiple={multiple ? 'true' : undefined}
74
+ >
75
+ {children}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ export function SelectMenu({ open, menuId, requestClose, triggerRef, multiple, animation, children }: SelectMenuProps) {
81
+ return (
82
+ <OverlayPortal>
83
+ <Presence>
84
+ {open && (
85
+ <MenuSurface
86
+ key="menu"
87
+ animate={selectMenuLayers(animation, 'open')}
88
+ exit={selectMenuLayers(animation, 'close')}
89
+ menuId={menuId}
90
+ requestClose={requestClose}
91
+ triggerRef={triggerRef}
92
+ multiple={multiple}
93
+ >
94
+ {children}
95
+ </MenuSurface>
96
+ )}
97
+ </Presence>
98
+ </OverlayPortal>
99
+ );
100
+ }
@@ -0,0 +1,154 @@
1
+ 'use client';
2
+
3
+ import { Fragment, useMemo, type ReactNode } from 'react';
4
+ import { Icon } from '../../../internal/icon/Icon';
5
+ import { IconSlot } from '../../../internal/icon/IconSlot';
6
+ import { Collapse } from '../../../primitives/collapse/Collapse';
7
+ import { GlidePill } from '../../../../motion/glide';
8
+ import { SelectMenu } from './menu';
9
+ import type { ListboxState } from './use-listbox';
10
+ import type { DisableableAnimation } from '../../../../motion/timing';
11
+ import type { ButtonProps } from '../../../primitives/button/Button';
12
+
13
+ export interface ListboxPanelProps {
14
+ lb: ListboxState;
15
+ loading: boolean;
16
+ searchable: boolean;
17
+ searchPlaceholder?: string;
18
+ ariaLabel?: string;
19
+ multiple?: boolean;
20
+ /** Menu open/close timing - motion tokens only, or `null` to disable. */
21
+ animation?: DisableableAnimation;
22
+ check?: (selected: boolean) => ReactNode;
23
+ size?: ButtonProps['size'];
24
+ }
25
+
26
+ const defaultCheck = (selected: boolean) => (selected ? <Icon key="on" name="check" size="sm" weight="bold" /> : null);
27
+
28
+ function LoadingRows() {
29
+ return (
30
+ <div className="select__loading" aria-busy="true" aria-live="polite">
31
+ {[0, 1, 2].map((i) => (
32
+ <div className="select__skeleton" key={i}>
33
+ <span className="select__skeleton-dot" data-pulse></span>
34
+ <span className="select__skeleton-bar" data-pulse></span>
35
+ </div>
36
+ ))}
37
+ </div>
38
+ );
39
+ }
40
+
41
+ function EmptyRow({ query }: { query: string }) {
42
+ return <div className="select__empty">{query ? 'No matches for "' + query + '"' : 'No options available'}</div>;
43
+ }
44
+
45
+ export function ListboxPanel({
46
+ lb,
47
+ loading,
48
+ searchable,
49
+ searchPlaceholder,
50
+ ariaLabel,
51
+ multiple,
52
+ animation,
53
+ size = 'md',
54
+ check = defaultCheck,
55
+ }: ListboxPanelProps) {
56
+ const navIndex = useMemo(() => new Map(lb.navItems.map((o, i) => [o.value, i] as const)), [lb.navItems]);
57
+ return (
58
+ <SelectMenu
59
+ open={lb.open}
60
+ menuId={lb.menuId}
61
+ requestClose={lb.requestClose}
62
+ triggerRef={lb.triggerRef}
63
+ multiple={multiple}
64
+ animation={animation}
65
+ >
66
+ {searchable && !loading && (
67
+ <div className="select__search">
68
+ <Icon name="magnifying-glass" size="sm" />
69
+ <input
70
+ ref={lb.searchRef}
71
+ className="select__search-input"
72
+ type="text"
73
+ value={lb.query}
74
+ placeholder={searchPlaceholder}
75
+ aria-label={searchPlaceholder}
76
+ aria-controls={lb.listId}
77
+ aria-activedescendant={lb.adId}
78
+ onChange={(e) => lb.setQuery(e.target.value)}
79
+ onKeyDown={lb.onMenuKeyDown}
80
+ />
81
+ </div>
82
+ )}
83
+
84
+ <div
85
+ className="select__list"
86
+ ref={lb.listRef}
87
+ id={lb.listId}
88
+ role="listbox"
89
+ aria-multiselectable={multiple || undefined}
90
+ tabIndex={-1}
91
+ aria-label={ariaLabel}
92
+ onKeyDown={searchable ? undefined : lb.onMenuKeyDown}
93
+ >
94
+ <GlidePill className="select__glide" glide={lb.glide} />
95
+ {loading ? (
96
+ <LoadingRows />
97
+ ) : (
98
+ <Fragment>
99
+ {lb.groups.map((g, gi) => (
100
+ <div className="select__group" role="group" aria-label={g.label || undefined} key={gi}>
101
+ {g.label && g.options.some((o) => navIndex.has(o.value)) && (
102
+ <div className="select__group-label">{g.label}</div>
103
+ )}
104
+ {g.options.map((opt) => {
105
+ const i = navIndex.get(opt.value) ?? -1;
106
+ const visible = i !== -1;
107
+ const isSel = lb.isSelected(opt.value);
108
+ const row = (
109
+ <div
110
+ key={opt.value}
111
+ id={visible ? lb.optId(opt.value) : undefined}
112
+ data-idx={visible ? i : undefined}
113
+ className="select__option"
114
+ role="option"
115
+ aria-selected={isSel}
116
+ aria-hidden={!visible || undefined}
117
+ aria-disabled={opt.disabled || undefined}
118
+ data-selected={isSel ? 'true' : undefined}
119
+ data-active={visible && i === lb.activeIdx ? 'true' : undefined}
120
+ data-disabled={opt.disabled ? 'true' : undefined}
121
+ onMouseEnter={() => visible && !opt.disabled && lb.setActiveIdx(i)}
122
+ onMouseDown={(e) => e.preventDefault()}
123
+ onClick={() => visible && lb.commit(opt)}
124
+ data-size={size}
125
+ >
126
+ {opt.icon && (
127
+ <span className="select__option-icon">
128
+ <IconSlot size="sm">{opt.icon}</IconSlot>
129
+ </span>
130
+ )}
131
+ <span className="select__option-text">
132
+ <span className="select__option-label">{opt.label}</span>
133
+ {opt.description && <span className="select__option-desc">{opt.description}</span>}
134
+ </span>
135
+ {check(isSel) ? <span className="select__option-check">{check(isSel)}</span> : null}
136
+ </div>
137
+ );
138
+ return searchable ? (
139
+ <Collapse key={opt.value} open={visible} fade>
140
+ {row}
141
+ </Collapse>
142
+ ) : (
143
+ row
144
+ );
145
+ })}
146
+ </div>
147
+ ))}
148
+ {lb.navItems.length === 0 && <EmptyRow query={lb.query} />}
149
+ </Fragment>
150
+ )}
151
+ </div>
152
+ </SelectMenu>
153
+ );
154
+ }
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { Icon } from '../../../internal/icon/Icon';
5
+ import { IconSlot } from '../../../internal/icon/IconSlot';
6
+ import type { ListboxState } from './use-listbox';
7
+ import { Button, ButtonProps } from '../../../primitives/button/Button';
8
+
9
+ export interface SelectTriggerProps extends ButtonProps {
10
+ lb: ListboxState;
11
+ invalid?: boolean;
12
+ ariaLabel?: string;
13
+ leading?: ReactNode;
14
+ text?: ReactNode;
15
+ isPlaceholder?: boolean;
16
+ count?: number;
17
+ }
18
+
19
+ export function SelectTrigger({
20
+ lb,
21
+ invalid,
22
+ ariaLabel,
23
+ leading,
24
+ text,
25
+ isPlaceholder,
26
+ count,
27
+ htmlProps,
28
+ ...rest
29
+ }: SelectTriggerProps) {
30
+ const { triggerRef, baseId, open, adId, show, requestClose } = lb;
31
+ return (
32
+ <Button
33
+ type="button"
34
+ ref={triggerRef}
35
+ variant="ghost"
36
+ htmlProps={{
37
+ id: baseId + '-trigger',
38
+ role: 'combobox',
39
+ 'aria-haspopup': 'listbox',
40
+ 'aria-expanded': open,
41
+ 'aria-controls': baseId + '-list',
42
+ 'aria-activedescendant': adId,
43
+ 'aria-label': ariaLabel,
44
+ 'aria-invalid': invalid || undefined,
45
+ onKeyDown: (e) => {
46
+ if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
47
+ e.preventDefault();
48
+ show();
49
+ }
50
+ },
51
+ ...htmlProps,
52
+ }}
53
+ onClick={() => (open ? requestClose() : show())}
54
+ iconRight={
55
+ <span className="select__caret">
56
+ <Icon name="caret-down" size="sm" />
57
+ </span>
58
+ }
59
+ iconLeft={
60
+ leading && (
61
+ <span className="select__leading">
62
+ <IconSlot size="sm">{leading}</IconSlot>
63
+ </span>
64
+ )
65
+ }
66
+ {...rest}
67
+ >
68
+ <span className="select__value" data-placeholder={isPlaceholder ? 'true' : undefined}>
69
+ {text}
70
+ </span>
71
+ {count != null && count > 0 && <span className="select__count">+{count}</span>}
72
+ </Button>
73
+ );
74
+ }
@@ -0,0 +1,39 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface SelectOption {
4
+ /** The stored value - what `onChange` returns and `value` matches; must be unique. */
5
+ value: string;
6
+ /** Primary row text, and the trigger label once selected; also matched by `searchable`. */
7
+ label: ReactNode;
8
+ /** Optional secondary line under the label; also matched by `searchable`. */
9
+ description?: string;
10
+ /** Your own icon node shown before the label. */
11
+ icon?: ReactNode;
12
+ /** Not selectable - skipped by keyboard nav and typeahead, and marked `aria-disabled`. @default false */
13
+ disabled?: boolean;
14
+ }
15
+ export interface SelectGroup {
16
+ /** Section heading rendered above the options; omit for an unlabeled group. */
17
+ label?: string;
18
+ /** The options in this section. */
19
+ options: SelectOption[];
20
+ }
21
+
22
+ export interface NormalizedGroup {
23
+ label: string | null | undefined;
24
+ options: SelectOption[];
25
+ }
26
+
27
+ export function normalize(options: SelectOption[] | SelectGroup[]): {
28
+ groups: NormalizedGroup[];
29
+ flat: SelectOption[];
30
+ } {
31
+ const grouped = options.length > 0 && options[0] && Array.isArray((options[0] as SelectGroup).options);
32
+ const groups: NormalizedGroup[] = grouped
33
+ ? (options as SelectGroup[]).map((g) => ({ label: g.label, options: g.options || [] }))
34
+ : [{ label: null, options: options as SelectOption[] }];
35
+ return { groups, flat: groups.flatMap((g) => g.options) };
36
+ }
37
+
38
+ export const matches = (o: SelectOption, q: string) =>
39
+ !q || (o.label + ' ' + (o.description || '')).toLowerCase().includes(q.toLowerCase());
@@ -0,0 +1,182 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
4
+ import { useGlide } from '../../../../motion/glide';
5
+ import { normalize, matches, type SelectOption, type SelectGroup } from './types';
6
+
7
+ export interface UseListboxArgs {
8
+ options: SelectOption[] | SelectGroup[];
9
+ disabled: boolean;
10
+ loading: boolean;
11
+ searchable: boolean;
12
+ id?: string;
13
+ idPrefix: string;
14
+ isSelected: (value: string) => boolean;
15
+ onCommit: (opt: SelectOption) => void;
16
+ closeOnCommit: boolean;
17
+ }
18
+
19
+ export type ListboxState = ReturnType<typeof useListbox>;
20
+
21
+ export function useListbox({
22
+ options,
23
+ disabled,
24
+ loading,
25
+ searchable,
26
+ id,
27
+ idPrefix,
28
+ isSelected,
29
+ onCommit,
30
+ closeOnCommit,
31
+ }: UseListboxArgs) {
32
+ const [open, setOpen] = useState(false);
33
+ const [query, setQuery] = useState('');
34
+ const [activeIdx, setActiveIdx] = useState(-1);
35
+ const typeahead = useRef<{ buf: string; t: ReturnType<typeof setTimeout> | number }>({ buf: '', t: 0 });
36
+
37
+ const triggerRef = useRef<HTMLButtonElement>(null),
38
+ listRef = useRef<HTMLDivElement>(null),
39
+ searchRef = useRef<HTMLInputElement>(null);
40
+ const autoId = useId();
41
+ const baseId = id || idPrefix + autoId;
42
+ const menuId = baseId + '-menu';
43
+ const listId = baseId + '-list';
44
+ const glide = useGlide(listRef);
45
+
46
+ const { groups, flat } = useMemo(() => normalize(options), [options]);
47
+ const navItems = useMemo(() => flat.filter((o) => matches(o, query)), [flat, query]);
48
+
49
+ const show = () => {
50
+ if (!disabled && !loading) setOpen(true);
51
+ };
52
+ const requestClose = () => setOpen(false);
53
+ const returnFocus = () => triggerRef.current && triggerRef.current.focus();
54
+
55
+ function commit(opt: SelectOption) {
56
+ if (!opt || opt.disabled) return;
57
+ onCommit(opt);
58
+ if (closeOnCommit) {
59
+ requestClose();
60
+ returnFocus();
61
+ }
62
+ }
63
+
64
+ useEffect(() => {
65
+ if (!open) setQuery('');
66
+ }, [open]);
67
+
68
+ useEffect(() => {
69
+ if (!open) return;
70
+ const sel = navItems.findIndex((o) => isSelected(o.value) && !o.disabled);
71
+ const first = navItems.findIndex((o) => !o.disabled);
72
+ setActiveIdx(sel >= 0 ? sel : first);
73
+ const ref = searchable ? searchRef : listRef;
74
+ setTimeout(() => {
75
+ if (ref.current) ref.current.focus();
76
+ }, 0);
77
+ }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
78
+
79
+ useEffect(() => {
80
+ const list = listRef.current;
81
+ if (!open || activeIdx < 0 || !list) return;
82
+ const el = list.querySelector('[data-idx="' + activeIdx + '"]') as HTMLElement | null;
83
+ if (!el) return;
84
+ if (el.offsetTop < list.scrollTop) list.scrollTop = el.offsetTop;
85
+ else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight)
86
+ list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight;
87
+ }, [activeIdx, open]); // eslint-disable-line react-hooks/exhaustive-deps
88
+
89
+ useLayoutEffect(() => {
90
+ const list = listRef.current;
91
+ const el = open && activeIdx >= 0 && list && list.querySelector<HTMLElement>('[data-idx="' + activeIdx + '"]');
92
+ if (el) glide.enter(el);
93
+ else glide.leave();
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
95
+ }, [open, activeIdx, query]);
96
+
97
+ function moveActive(dir: number) {
98
+ if (!navItems.length) return;
99
+ let i = activeIdx;
100
+ for (let s = 0; s < navItems.length; s++) {
101
+ i = (i + dir + navItems.length) % navItems.length;
102
+ if (!navItems[i].disabled) return setActiveIdx(i);
103
+ }
104
+ }
105
+ function edgeActive(toEnd: boolean) {
106
+ const order = [...navItems.keys()];
107
+ if (toEnd) order.reverse();
108
+ for (const i of order) if (!navItems[i].disabled) return setActiveIdx(i);
109
+ }
110
+ function typeAhead(ch: string) {
111
+ const ta = typeahead.current;
112
+ clearTimeout(ta.t);
113
+ ta.buf += ch.toLowerCase();
114
+ ta.t = setTimeout(() => (ta.buf = ''), 600);
115
+ const i = navItems.findIndex((o) => !o.disabled && o.value.toLowerCase().startsWith(ta.buf));
116
+ if (i >= 0) setActiveIdx(i);
117
+ }
118
+
119
+ function onMenuKeyDown(e: KeyboardEvent) {
120
+ switch (e.key) {
121
+ case 'Escape':
122
+ e.preventDefault();
123
+ requestClose();
124
+ returnFocus();
125
+ break;
126
+ case 'ArrowDown':
127
+ e.preventDefault();
128
+ moveActive(1);
129
+ break;
130
+ case 'ArrowUp':
131
+ e.preventDefault();
132
+ moveActive(-1);
133
+ break;
134
+ case 'Home':
135
+ e.preventDefault();
136
+ edgeActive(false);
137
+ break;
138
+ case 'End':
139
+ e.preventDefault();
140
+ edgeActive(true);
141
+ break;
142
+ case 'Enter':
143
+ e.preventDefault();
144
+ if (activeIdx >= 0) commit(navItems[activeIdx]);
145
+ break;
146
+ case 'Tab':
147
+ requestClose();
148
+ break;
149
+ default:
150
+ if (!searchable && e.key.length === 1 && !e.metaKey && !e.ctrlKey) typeAhead(e.key);
151
+ }
152
+ }
153
+
154
+ const optId = (value: string) => baseId + '-opt-' + encodeURIComponent(value);
155
+ const active = activeIdx >= 0 ? navItems[activeIdx] : undefined;
156
+ const adId = open && active ? optId(active.value) : undefined;
157
+
158
+ return {
159
+ open,
160
+ query,
161
+ setQuery,
162
+ triggerRef,
163
+ listRef,
164
+ searchRef,
165
+ baseId,
166
+ menuId,
167
+ listId,
168
+ optId,
169
+ adId,
170
+ groups,
171
+ flat,
172
+ navItems,
173
+ glide,
174
+ isSelected,
175
+ show,
176
+ requestClose,
177
+ commit,
178
+ activeIdx,
179
+ setActiveIdx,
180
+ onMenuKeyDown,
181
+ };
182
+ }