@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,208 @@
1
+ /* tabs.css - line tabs for VIEW SWITCHING (overview / activity / settings), sitting on a
2
+ hairline baseline. NOT a form control: pick Segmented control for 2-3 filter options.
3
+
4
+ Anatomy: .tabs > .tabs__list[role=tablist] > .tab__hover? + .tab* + .tabs__ink
5
+ .tab > .tab__pad ( Icon? + .tab__label + .tab__count? )
6
+ States: :hover - :active - :focus-visible - :disabled - .is-selected
7
+
8
+ Two motions, both driven from Tabs.tsx - nothing here transitions them:
9
+ - the ink ("reaches, then releases"): one persistent node; the edge facing the
10
+ destination travels first, then the trailing edge settles. animate() keyframes on
11
+ x + measured width (destination width is unknowable to transforms; scaleX would
12
+ distort the radius).
13
+ - the gliding hover: one persistent pill (../motion/glide) that fades in under the
14
+ first tab touched, springs between pads, and fades out where it rests on leave.
15
+ Label color, icon weight and press dip remain trivial-tier CSS. */
16
+
17
+ /* -- Shell - owns the full-width baseline ----------------------------------
18
+ The hairline is a ::after on the shell (not a border on the scroller) so it
19
+ spans edge-to-edge while the scroller above it clips, masks and scrolls
20
+ freely. The scroller gets z-index:1 so the ink paints OVER the hairline. */
21
+ .tabs {
22
+ position: relative;
23
+ }
24
+ .tabs::after {
25
+ content: '';
26
+ position: absolute;
27
+ inset-inline: 0;
28
+ bottom: 0;
29
+ height: var(--border-hairline);
30
+ background: var(--border-default);
31
+ }
32
+
33
+ /* -- Scroller - the tablist ------------------------------------------------
34
+ Overflow is honest: the row scrolls (scrollbar hidden) and the clipped edge
35
+ fades out via a mask. The fade lengths are two custom props flipped by
36
+ data-fade (set from scroll geometry in Tabs.jsx) - at 0px the mask is fully
37
+ opaque, i.e. a no-op. Negative margin + matching padding align the first
38
+ label flush with the container edge while the hover pill still has room. */
39
+ .tabs__list {
40
+ position: relative;
41
+ z-index: 1;
42
+ display: flex;
43
+ gap: var(--space-1);
44
+ overflow-x: auto;
45
+ scrollbar-width: none;
46
+ margin-inline: calc(var(--space-2) * -1);
47
+ padding-inline: var(--space-2);
48
+
49
+ --tabs-fade-start: 0px;
50
+ --tabs-fade-end: 0px;
51
+ mask-image: linear-gradient(
52
+ to right,
53
+ transparent,
54
+ black var(--tabs-fade-start),
55
+ black calc(100% - var(--tabs-fade-end)),
56
+ transparent
57
+ );
58
+ }
59
+ .tabs__list::-webkit-scrollbar {
60
+ display: none;
61
+ }
62
+ .tabs__list[data-fade~='start'] {
63
+ --tabs-fade-start: var(--space-8);
64
+ }
65
+ .tabs__list[data-fade~='end'] {
66
+ --tabs-fade-end: var(--space-8);
67
+ }
68
+
69
+ /* -- Tab - the hit target --------------------------------------------------
70
+ The button is pill + a bottom gap down to the baseline (total height 36px).
71
+ All visual feedback lives on the inner pill so hover/press/focus read as a
72
+ soft inset shape, while the ink below stays anchored to the baseline. */
73
+ .tab {
74
+ appearance: none;
75
+ border: 0;
76
+ background: none;
77
+ margin: 0;
78
+ padding: 0 0 var(--space-2);
79
+ flex: none;
80
+ display: block;
81
+ font-family: var(--font-sans);
82
+ cursor: pointer;
83
+ -webkit-tap-highlight-color: transparent;
84
+ }
85
+ .tab:focus-visible {
86
+ outline: none;
87
+ }
88
+
89
+ .tab__pad {
90
+ position: relative; /* positioned so every pad paints above the shared glide
91
+ pill (z-index 0, first child of the list) */
92
+ display: flex;
93
+ align-items: center;
94
+ gap: var(--space-2);
95
+ height: var(--control-height-sm); /* 28 */
96
+ padding-inline: var(--space-2);
97
+ border-radius: var(--radius-md);
98
+ font: var(--type-micro);
99
+ line-height: 1;
100
+ white-space: nowrap;
101
+ color: var(--text-subtle);
102
+ transition:
103
+ var(--transition-control),
104
+ transform var(--duration-fast) var(--ease-standard);
105
+ }
106
+ .tab__pad .icon {
107
+ display: inline-flex;
108
+ }
109
+
110
+ /* States - hover strengthens the label (the shared glide pill supplies the
111
+ background), press dips 1px on a darker fill (tactile, like Button), focus
112
+ is the composed accent ring, selection is text-strong + the ink. */
113
+ .tab:hover:not(:disabled) .tab__pad {
114
+ color: var(--text-body);
115
+ }
116
+ .tab:active:not(:disabled) .tab__pad {
117
+ background: var(--neutral-wash-press);
118
+ transform: translateY(var(--space-px));
119
+ }
120
+ .tab.is-selected .tab__pad,
121
+ .tab.is-selected:hover:not(:disabled) .tab__pad {
122
+ color: var(--text-strong);
123
+ }
124
+ .tab:focus-visible .tab__pad {
125
+ box-shadow: var(--ring-accent);
126
+ }
127
+ .tab:disabled {
128
+ cursor: not-allowed;
129
+ }
130
+ .tab:disabled .tab__pad {
131
+ color: var(--text-disabled);
132
+ }
133
+
134
+ /* -- Count - always mono, always tabular -----------------------------------*/
135
+ .tab__count {
136
+ font-family: var(--font-mono);
137
+ font-size: var(--size-micro);
138
+ line-height: 1;
139
+ font-variant-numeric: tabular-nums;
140
+ color: var(--text-subtle);
141
+ background: var(--bg-muted);
142
+ padding: var(--space-1);
143
+ border-radius: var(--radius-sm);
144
+ transition: var(--transition-colors);
145
+ }
146
+ .tab.is-selected .tab__count {
147
+ color: var(--text-accent);
148
+ background: var(--accent-wash);
149
+ }
150
+ .tab:disabled .tab__count {
151
+ color: var(--text-disabled);
152
+ background: var(--bg-subtle);
153
+ }
154
+
155
+ /* -- The gliding hover ------------------------------------------------------
156
+ One persistent pill, first child of the scroller (../motion/glide writes
157
+ x/y/width/height/opacity inline; coordinates include scrollLeft, so it
158
+ scrolls with its tab). z-index 0 + DOM order keep it under every tab's
159
+ positioned pad in both travel directions. Nothing transitions here. */
160
+ .tab__hover {
161
+ opacity: 0;
162
+ position: absolute;
163
+ top: 0;
164
+ left: 0;
165
+ z-index: 0;
166
+ border-radius: var(--radius-md);
167
+ background: var(--neutral-wash);
168
+ pointer-events: none;
169
+ will-change: transform, opacity;
170
+ }
171
+
172
+ /* -- The ink ---------------------------------------------------------------
173
+ Positioned in the scroller's layout coordinates (offsetLeft space), so it
174
+ scrolls WITH its tab. Motion writes transform + width at runtime; resting
175
+ geometry is set the same way, so there is nothing to transition here.
176
+ EXCEPTION (intentional, mirrors RadioGroup's dot dims): 2px ink height is a
177
+ bespoke control dim - elevation.css reserves 2px for selected emphasis, but
178
+ ships no width token for it (--border-emphasis is 1.5px, which renders as
179
+ sub-pixel fuzz on 1x screens). Do not "fix" it to a token. */
180
+ .tabs__ink {
181
+ --tabs-ink-h: 2px;
182
+ position: absolute;
183
+ left: 0;
184
+ bottom: 0;
185
+ height: var(--tabs-ink-h);
186
+ border-radius: var(--radius-full);
187
+ background: var(--accent);
188
+ pointer-events: none;
189
+ will-change: transform, width;
190
+ }
191
+
192
+ /* -- Panel -----------------------------------------------------------------
193
+ The panel ROOT is static chrome - borders, padding, background put on it
194
+ by the consumer never move. Only .tab-panel__inner (the content) enters
195
+ from the direction of travel (Motion, TabPanel). The root is focusable per
196
+ the APG (tabindex=0) so keyboard users land on content right after the
197
+ tablist; the ring shows only for keyboard focus. */
198
+ .tab-panel {
199
+ outline: none;
200
+ overflow: hidden;
201
+ }
202
+ .tab-panel:focus-visible {
203
+ box-shadow: var(--ring-accent);
204
+ border-radius: var(--radius-md);
205
+ }
206
+ .tab-panel__inner {
207
+ will-change: transform, opacity;
208
+ }
@@ -0,0 +1,380 @@
1
+ 'use client';
2
+
3
+ import './toast.css';
4
+ import {
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ useSyncExternalStore,
10
+ type CSSProperties,
11
+ type FocusEvent as ReactFocusEvent,
12
+ type KeyboardEvent as ReactKeyboardEvent,
13
+ type PointerEvent as ReactPointerEvent,
14
+ type HTMLAttributes,
15
+ type ReactElement,
16
+ type ReactNode,
17
+ type RefObject,
18
+ } from 'react';
19
+ import { createPortal } from 'react-dom';
20
+ import { animate, startDrag, type Layer, type Playback } from '../../../engine';
21
+ import { Presence } from '../../../motion/presence';
22
+ import { Motion } from '../../../motion/element';
23
+ import { UIMotion } from '../../../tokens/motion-tokens';
24
+ import { fireGlint } from '../../internal/glass/glint';
25
+ import { tokenPx } from '../../internal/utils/token-px';
26
+ import { Icon, type IconName } from '../../internal/icon/Icon';
27
+ import { Button } from '../../primitives/button/Button';
28
+ import { UIToast, DEFAULT_TOASTER_CONFIG, type ToastRecord, type ToastTone, type ToasterConfig } from './toast-store';
29
+ import type { DataAttributes } from '../../../dom-props';
30
+ import { cx } from '../../internal/utils/cx';
31
+
32
+ const SM = UIMotion;
33
+ const store = UIToast;
34
+
35
+ const COLLAPSE_GRACE = 140;
36
+ const SWIPE_X = 64;
37
+ const SWIPE_V = 480;
38
+
39
+ let GAP = 0;
40
+ function stackGap() {
41
+ if (!GAP) GAP = tokenPx('--space-3') || 12;
42
+ return GAP;
43
+ }
44
+
45
+ const TONE_ICON: Partial<Record<ToastTone, IconName>> = {
46
+ success: 'check-circle',
47
+ danger: 'warning-circle',
48
+ warning: 'warning',
49
+ info: 'info',
50
+ };
51
+
52
+ function useToneGesture(tone: ToastTone, ref: RefObject<HTMLElement>) {
53
+ const prevTone = useRef<ToastTone | null>(null);
54
+ useEffect(() => {
55
+ const prev = prevTone.current;
56
+ prevTone.current = tone;
57
+ if (tone === prev) return;
58
+ if (tone === 'success') return fireGlint(ref.current);
59
+ if (tone === 'danger' && ref.current) {
60
+ const shake = ref.current.firstElementChild as HTMLElement | null;
61
+ if (!shake) return;
62
+ const fall = animate(shake, {
63
+ x: [0, -7, 5, -2, 0],
64
+ timing: { duration: SM.dur.slow, ease: SM.ease.standard, delay: SM.dur.base },
65
+ });
66
+ fall.finished.then(() => fall.stop());
67
+ return () => fall.stop();
68
+ }
69
+ }, [tone]);
70
+ }
71
+
72
+ function useReplay<T extends HTMLElement>(token: unknown, build: () => Layer[]) {
73
+ const ref = useRef<T>(null);
74
+ const latest = useRef(build);
75
+ latest.current = build;
76
+ useLayoutEffect(() => {
77
+ if (ref.current) animate(ref.current, ...latest.current());
78
+ }, [token]);
79
+ return ref;
80
+ }
81
+
82
+ function ToastItem({
83
+ t,
84
+ depth,
85
+ offset,
86
+ hidden,
87
+ behind,
88
+ frameHeight,
89
+ isTop,
90
+ onHeight,
91
+ }: {
92
+ t: ToastRecord;
93
+ depth: number;
94
+ offset: number;
95
+ hidden: boolean;
96
+ behind: boolean;
97
+ frameHeight: number | null;
98
+ isTop: boolean;
99
+ onHeight: (id: string, h: number) => void;
100
+ }) {
101
+ const ref = useRef<HTMLLIElement>(null);
102
+ const settled = useRef(false);
103
+ const swipeBack = useRef<Playback | null>(null);
104
+ useToneGesture(t.tone, ref);
105
+
106
+ useLayoutEffect(() => {
107
+ if (ref.current && ref.current.firstElementChild)
108
+ onHeight(t.id, (ref.current.firstElementChild as HTMLElement).offsetHeight);
109
+ });
110
+
111
+ useLayoutEffect(() => {
112
+ const el = ref.current;
113
+ if (!el) return;
114
+ el.style.pointerEvents = hidden ? 'none' : 'auto';
115
+ const y = isTop ? offset : -offset;
116
+ const scale = 1 - depth * 0.05;
117
+ if (settled.current) {
118
+ animate(
119
+ el,
120
+ { y: [y], scale: [scale], height: [frameHeight ?? 'auto'], timing: SM.t.settle },
121
+ { opacity: [hidden ? 0 : 1], timing: SM.t.enter },
122
+ );
123
+ return;
124
+ }
125
+ settled.current = true;
126
+ animate(
127
+ el,
128
+ { y: [isTop ? -24 : 24, y], scale: [0.97, scale], timing: SM.t.settle },
129
+ { opacity: [0, hidden ? 0 : 1], timing: SM.t.enter },
130
+ );
131
+ }, [offset, depth, hidden, frameHeight, isTop]);
132
+
133
+ const onPointerDown = (e: ReactPointerEvent) => {
134
+ const inner = ref.current?.firstElementChild as HTMLElement | null;
135
+ if (!t.dismissible || e.button !== 0 || !inner) return;
136
+ swipeBack.current?.stop();
137
+ startDrag(e, {
138
+ onMove: (info) => {
139
+ const dx = info.offset.x;
140
+ inner.style.translate = `${dx > 0 ? dx * 0.9 : dx * 0.04}px 0px`;
141
+ },
142
+ onEnd: (info) => {
143
+ if (info.offset.x > SWIPE_X || info.velocity.x > SWIPE_V) {
144
+ animate(inner, { x: [420], timing: SM.t.exit }).finished.then(() => store.dismiss(t.id));
145
+ return;
146
+ }
147
+ const back = animate(inner, { x: [0], timing: SM.t.settle });
148
+ swipeBack.current = back;
149
+ back.finished.then(() => back.stop());
150
+ },
151
+ });
152
+ };
153
+
154
+ return (
155
+ <Motion
156
+ as="li"
157
+ ref={ref}
158
+ exit={{ opacity: [0], scale: [0.96], timing: SM.t.exit }}
159
+ className="toast glass"
160
+ data-tone={t.tone}
161
+ data-behind={behind ? '' : undefined}
162
+ role={t.tone === 'danger' ? 'alert' : 'status'}
163
+ aria-atomic="true"
164
+ onPointerDown={onPointerDown}
165
+ onKeyDown={(e: ReactKeyboardEvent) => {
166
+ if (e.key === 'Escape' && t.dismissible) store.dismiss(t.id);
167
+ }}
168
+ >
169
+ {t.node ? <div className="toast__custom">{t.node as ReactNode}</div> : <ToastBody t={t} />}
170
+ </Motion>
171
+ );
172
+ }
173
+
174
+ function ToastBody({ t }: { t: ToastRecord }) {
175
+ const glyphRef = useReplay<HTMLSpanElement>(t.tone, () => [
176
+ { scale: [0.5, 1], timing: SM.t.settle },
177
+ { opacity: [0, 1], timing: SM.t.enter },
178
+ ]);
179
+ const textRef = useReplay<HTMLDivElement>(String(t.message) + '-' + String(t.description), () => [
180
+ { opacity: [0, 1], timing: SM.t.enter },
181
+ ]);
182
+ const countRef = useReplay<HTMLSpanElement>(t.count, () => [{ scale: [0.6, 1], timing: SM.t.settle }]);
183
+
184
+ return (
185
+ <div className="toast__inner">
186
+ {(t.tone !== 'default' || isFinite(t.duration)) && (
187
+ <span className="toast__icon">
188
+ {t.tone !== 'default' && (
189
+ <span ref={glyphRef} className="toast__icon-glyph">
190
+ {t.tone === 'loading' ? (
191
+ <span className="toast__spinner" aria-hidden="true"></span>
192
+ ) : (
193
+ <Icon name={TONE_ICON[t.tone] || 'info'} weight="fill" />
194
+ )}
195
+ </span>
196
+ )}
197
+ {isFinite(t.duration) && (
198
+ <svg key={'ring-' + t.timerKey} className="toast__ring" viewBox="0 0 36 36" aria-hidden="true">
199
+ <circle className="toast__ring-track" cx="18" cy="18" r="16.5"></circle>
200
+ <circle
201
+ className="toast__ring-arc"
202
+ cx="18"
203
+ cy="18"
204
+ r="16.5"
205
+ pathLength={1}
206
+ style={{ animationDuration: t.duration + 'ms' }}
207
+ ></circle>
208
+ </svg>
209
+ )}
210
+ </span>
211
+ )}
212
+ <div ref={textRef} className="toast__text">
213
+ <p className="toast__message">{t.message}</p>
214
+ {t.description != null && <p className="toast__desc">{t.description}</p>}
215
+ </div>
216
+ {t.count > 1 && (
217
+ <span ref={countRef} className="toast__count">
218
+ ×{t.count}
219
+ </span>
220
+ )}
221
+ {t.action && (
222
+ <Button
223
+ variant="secondary"
224
+ size="sm"
225
+ className="toast__action"
226
+ onClick={() => {
227
+ if (t.action.onClick) t.action.onClick();
228
+ store.dismiss(t.id);
229
+ }}
230
+ >
231
+ {t.action.label}
232
+ </Button>
233
+ )}
234
+ {t.dismissible && (
235
+ <button type="button" className="toast__close" aria-label="Dismiss" onClick={() => store.dismiss(t.id)}>
236
+ <Icon name="close" size="sm" />
237
+ </button>
238
+ )}
239
+ </div>
240
+ );
241
+ }
242
+
243
+ function ToastHost({ config, htmlProps }: { config: ToasterConfig; htmlProps?: HTMLAttributes<HTMLOListElement> }) {
244
+ const { toasts, paused, expanded: hoverExpanded } = useSyncExternalStore(store.subscribe, store.get, store.get);
245
+ const [heights, setHeights] = useState<Record<string, number>>({});
246
+ const [ready, setReady] = useState(false);
247
+ const collapseTimer = useRef<ReturnType<typeof setTimeout> | 0>(0);
248
+ const hovering = useRef(false);
249
+
250
+ useEffect(() => setReady(true), []);
251
+
252
+ const onHeight = (id: string, h: number) => setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
253
+
254
+ const onHold = () => {
255
+ hovering.current = true;
256
+ clearTimeout(collapseTimer.current);
257
+ store.setExpanded(true);
258
+ store.pause();
259
+ };
260
+ const onRelease = (graced: boolean) => {
261
+ hovering.current = false;
262
+ clearTimeout(collapseTimer.current);
263
+ collapseTimer.current = setTimeout(
264
+ () => {
265
+ store.setExpanded(false);
266
+ store.resume();
267
+ },
268
+ graced ? COLLAPSE_GRACE : 0,
269
+ );
270
+ };
271
+
272
+ useEffect(() => {
273
+ const onVis = () => {
274
+ if (document.hidden) store.pause();
275
+ else if (!hovering.current) store.resume();
276
+ };
277
+ document.addEventListener('visibilitychange', onVis);
278
+ return () => document.removeEventListener('visibilitychange', onVis);
279
+ }, []);
280
+
281
+ useEffect(() => {
282
+ if (toasts.length === 0) {
283
+ hovering.current = false;
284
+ clearTimeout(collapseTimer.current);
285
+ store.setExpanded(false);
286
+ store.resume();
287
+ }
288
+ }, [toasts.length]);
289
+
290
+ if (!ready) return null;
291
+
292
+ const isTop = config.position.startsWith('top');
293
+ const visible = config.visibleToasts;
294
+ const rendered = visible * 2;
295
+ const gap = config.gap || stackGap();
296
+ const expanded = config.expand || hoverExpanded;
297
+
298
+ const slice = toasts.slice(-rendered);
299
+ const n = slice.length;
300
+ const frontH = n ? heights[slice[n - 1].id] || 0 : 0;
301
+
302
+ return createPortal(
303
+ <ol
304
+ {...htmlProps}
305
+ className={cx('toast-viewport', paused && 'is-paused', htmlProps?.className)}
306
+ data-position={config.position}
307
+ style={
308
+ config.offset
309
+ ? ({ ...htmlProps?.style, '--toast-offset': `${config.offset}px` } as CSSProperties)
310
+ : htmlProps?.style
311
+ }
312
+ aria-label="Notifications"
313
+ onPointerOver={(e: ReactPointerEvent<HTMLOListElement>) => {
314
+ if (e.pointerType !== 'touch') onHold();
315
+ }}
316
+ onPointerOut={(e: ReactPointerEvent<HTMLOListElement>) => {
317
+ if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget as Node)) onRelease(true);
318
+ }}
319
+ onFocus={onHold}
320
+ onBlur={(e: ReactFocusEvent<HTMLOListElement>) => {
321
+ if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget as Node)) onRelease(false);
322
+ }}
323
+ >
324
+ <Presence>
325
+ {slice.map((t, i) => {
326
+ const depth = n - 1 - i;
327
+ let offset = depth * gap;
328
+ if (expanded) {
329
+ offset = 0;
330
+ for (let j = i + 1; j < n; j++) offset += (heights[slice[j].id] || 0) + gap;
331
+ }
332
+ return (
333
+ <ToastItem
334
+ key={t.id}
335
+ t={t}
336
+ depth={expanded ? 0 : depth}
337
+ offset={offset}
338
+ hidden={!expanded && depth >= visible}
339
+ behind={!expanded && depth > 0}
340
+ frameHeight={!expanded && depth > 0 && frontH ? frontH : null}
341
+ isTop={isTop}
342
+ onHeight={onHeight}
343
+ />
344
+ );
345
+ })}
346
+ </Presence>
347
+ </ol>,
348
+ document.body,
349
+ );
350
+ }
351
+
352
+ function stripUndefined<T extends object>(o: T): Partial<T> {
353
+ const out: Partial<T> = {};
354
+ (Object.keys(o) as (keyof T)[]).forEach((k) => {
355
+ if (o[k] !== undefined) out[k] = o[k];
356
+ });
357
+ return out;
358
+ }
359
+
360
+ export interface ToasterProps extends Partial<ToasterConfig> {
361
+ /** Standard <ol> attributes (className, style, data-*, ...) forwarded to the toast viewport. */
362
+ htmlProps?: HTMLAttributes<HTMLOListElement> & DataAttributes;
363
+ }
364
+
365
+ export function Toaster({ htmlProps, ...props }: ToasterProps): ReactElement {
366
+ const config: ToasterConfig = { ...DEFAULT_TOASTER_CONFIG, ...stripUndefined(props) };
367
+
368
+ useEffect(() => {
369
+ store.config = config;
370
+ store.mounted = true;
371
+ if (config.expand) store.setExpanded(true);
372
+ return () => {
373
+ store.mounted = false;
374
+ };
375
+ }, [config.position, config.duration, config.visibleToasts, config.gap, config.offset, config.expand]);
376
+
377
+ return <ToastHost config={config} htmlProps={htmlProps} />;
378
+ }
379
+
380
+ export { toast } from './toast-store';