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,349 @@
1
+ 'use client';
2
+
3
+ /* Toast - the React render layer (queue + clocks live in toast-store.ts). */
4
+ import * as React from 'react';
5
+ import { createPortal } from 'react-dom';
6
+ import { createRoot } from 'react-dom/client';
7
+ import { motion, AnimatePresence, animate, useMotionValue, type PanInfo } from 'motion/react';
8
+ import { UIMotion } from '../../tokens/motion-tokens';
9
+ import { Icon, type IconName } from '../icon/Icon';
10
+ import { UIToast, type ToastRecord, type ToastTone } from './toast-store';
11
+
12
+ const SM = UIMotion;
13
+ const store = UIToast;
14
+ const { useRef, useEffect, useLayoutEffect, useState, useSyncExternalStore } = React;
15
+
16
+ const VISIBLE = 3;
17
+ const RENDERED = 6;
18
+ const COLLAPSE_GRACE = 140;
19
+ const SWIPE_X = 64;
20
+ const SWIPE_V = 480;
21
+
22
+ // expanded gap AND collapsed peek - read lazily (post-stylesheet), once
23
+ let GAP = 0;
24
+ function stackGap() {
25
+ if (GAP) return GAP;
26
+ const cs = getComputedStyle(document.documentElement);
27
+ const raw = cs.getPropertyValue('--space-3').trim();
28
+ const n = parseFloat(raw) || 0;
29
+ GAP = (raw.endsWith('rem') ? n * (parseFloat(cs.fontSize) || 16) : n) || 12;
30
+ return GAP;
31
+ }
32
+
33
+ const TONE_ICON: Record<string, IconName> = {
34
+ success: 'check-circle',
35
+ error: 'warning-circle',
36
+ warning: 'warning',
37
+ info: 'info',
38
+ };
39
+
40
+ /* useToneGesture - success glint, error headshake; the glint class is toggled with a reflow (remove - offsetWidth - add) to restart the one-shot. */
41
+ function useToneGesture(
42
+ tone: ToastTone,
43
+ ref: React.RefObject<HTMLElement>,
44
+ x: ReturnType<typeof useMotionValue<number>>,
45
+ ) {
46
+ const prevTone = useRef<ToastTone | null>(null);
47
+ useEffect(() => {
48
+ const prev = prevTone.current;
49
+ prevTone.current = tone;
50
+ if (tone === prev) return;
51
+ if (tone === 'success') {
52
+ const el = ref.current;
53
+ el.classList.remove('glass-glint');
54
+ void el.offsetWidth;
55
+ el.classList.add('glass-glint');
56
+ const id = setTimeout(() => el.classList.remove('glass-glint'), SM.dur.slow * 1000 + 80);
57
+ return () => clearTimeout(id);
58
+ }
59
+ if (tone === 'error') {
60
+ const fall = animate(x, [0, -7, 5, -2, 0], {
61
+ duration: SM.dur.slow,
62
+ ease: SM.ease.standard,
63
+ delay: SM.dur.base,
64
+ });
65
+ return () => fall.stop();
66
+ }
67
+ }, [tone]);
68
+ }
69
+
70
+ function ToastItem({
71
+ t,
72
+ depth,
73
+ offset,
74
+ hidden,
75
+ behind,
76
+ frameHeight,
77
+ onHeight,
78
+ }: {
79
+ t: ToastRecord;
80
+ depth: number;
81
+ offset: number;
82
+ hidden: boolean;
83
+ behind: boolean;
84
+ frameHeight: number | null;
85
+ onHeight: (id: string, h: number) => void;
86
+ }) {
87
+ const ref = useRef<HTMLLIElement>(null);
88
+ const x = useMotionValue(0); // swipe travel - ours, so dismissal can finish it
89
+ useToneGesture(t.tone, ref, x);
90
+
91
+ // report the CONTENT height (firstChild - the li's own height is animated, so it'd read mid-tween)
92
+ useLayoutEffect(() => {
93
+ if (ref.current && ref.current.firstElementChild)
94
+ onHeight(t.id, (ref.current.firstElementChild as HTMLElement).offsetHeight);
95
+ });
96
+
97
+ const swipe = (_e: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => {
98
+ if (info.offset.x > SWIPE_X || info.velocity.x > SWIPE_V) {
99
+ // fling out the right edge, THEN remove - the exit fade plays where it landed, no snap-back
100
+ animate(x, 420, { duration: SM.dur.fast, ease: SM.ease.exit }).then(() =>
101
+ store.dismiss(t.id),
102
+ );
103
+ }
104
+ };
105
+
106
+ return (
107
+ <motion.li
108
+ ref={ref}
109
+ className="toast glass"
110
+ data-tone={t.tone}
111
+ data-behind={behind ? '' : undefined}
112
+ role={t.tone === 'error' ? 'alert' : 'status'}
113
+ aria-atomic="true"
114
+ style={{ x }}
115
+ initial={{ opacity: 0, y: 24, scale: 0.97 }}
116
+ animate={{
117
+ opacity: hidden ? 0 : 1,
118
+ y: -offset,
119
+ scale: 1 - depth * 0.05,
120
+ /* behind-cards adopt the front card's frame height (content faded) so a short card never drowns behind a tall one */
121
+ height: frameHeight || 'auto',
122
+ pointerEvents: hidden ? 'none' : 'auto',
123
+ }}
124
+ exit={{ opacity: 0, scale: 0.96, transition: { duration: SM.dur.fast, ease: SM.ease.exit } }}
125
+ transition={{ y: SM.t.settle, scale: SM.t.settle, height: SM.t.settle, opacity: SM.t.enter }}
126
+ drag={t.dismissible ? 'x' : false}
127
+ dragConstraints={{ left: 0, right: 0 }}
128
+ dragElastic={{ left: 0.04, right: 0.9 }}
129
+ dragMomentum={false}
130
+ onDragEnd={swipe}
131
+ onKeyDown={(e) => {
132
+ if (e.key === 'Escape' && t.dismissible) store.dismiss(t.id);
133
+ }}
134
+ >
135
+ {t.node ? (
136
+ <div className="toast__custom">{t.node as React.ReactNode}</div>
137
+ ) : (
138
+ <ToastBody t={t} />
139
+ )}
140
+ </motion.li>
141
+ );
142
+ }
143
+
144
+ function ToastBody({ t }: { t: ToastRecord }) {
145
+ return (
146
+ <div className="toast__inner">
147
+ {(t.tone !== 'default' || isFinite(t.duration)) && (
148
+ <span className="toast__icon">
149
+ {t.tone !== 'default' && (
150
+ /* keyed remount (not nested AnimatePresence): old glyph cuts, new springs in */
151
+ <motion.span
152
+ key={t.tone}
153
+ className="toast__icon-glyph"
154
+ initial={{ scale: 0.5, opacity: 0 }}
155
+ animate={{ scale: 1, opacity: 1 }}
156
+ transition={{ scale: SM.t.settle, opacity: SM.t.enter }}
157
+ >
158
+ {t.tone === 'loading' ? (
159
+ <span className="toast__spinner" aria-hidden="true"></span>
160
+ ) : (
161
+ <Icon name={TONE_ICON[t.tone]} weight="fill" />
162
+ )}
163
+ </motion.span>
164
+ )}
165
+ {isFinite(t.duration) && (
166
+ /* the ring - keyed to the timer so a restart re-fills it; pathLength=1 makes dashoffset a fraction */
167
+ <svg
168
+ key={'ring-' + t.timerKey}
169
+ className="toast__ring"
170
+ viewBox="0 0 36 36"
171
+ aria-hidden="true"
172
+ >
173
+ <circle className="toast__ring-track" cx="18" cy="18" r="16.5"></circle>
174
+ <circle
175
+ className="toast__ring-arc"
176
+ cx="18"
177
+ cy="18"
178
+ r="16.5"
179
+ pathLength={1}
180
+ style={{ animationDuration: t.duration + 'ms' }}
181
+ ></circle>
182
+ </svg>
183
+ )}
184
+ </span>
185
+ )}
186
+ <motion.div
187
+ key={String(t.message) + '-' + String(t.description)}
188
+ className="toast__text"
189
+ initial={{ opacity: 0 }}
190
+ animate={{ opacity: 1 }}
191
+ transition={SM.t.enter}
192
+ >
193
+ <p className="toast__message">{t.message}</p>
194
+ {t.description != null && <p className="toast__desc">{t.description}</p>}
195
+ </motion.div>
196
+ {t.count > 1 && (
197
+ <motion.span
198
+ key={t.count}
199
+ className="toast__count"
200
+ initial={{ scale: 0.6 }}
201
+ animate={{ scale: 1 }}
202
+ transition={SM.t.settle}
203
+ >
204
+ ×{t.count}
205
+ </motion.span>
206
+ )}
207
+ {t.action && (
208
+ <button
209
+ type="button"
210
+ className="btn btn--secondary btn--sm toast__action"
211
+ onClick={() => {
212
+ if (t.action.onClick) t.action.onClick();
213
+ store.dismiss(t.id);
214
+ }}
215
+ >
216
+ {t.action.label}
217
+ </button>
218
+ )}
219
+ {t.dismissible && (
220
+ <button
221
+ type="button"
222
+ className="toast__close"
223
+ aria-label="Dismiss"
224
+ onClick={() => store.dismiss(t.id)}
225
+ >
226
+ <Icon name="close" size="sm" />
227
+ </button>
228
+ )}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ function ToastHost() {
234
+ const { toasts, paused, expanded } = useSyncExternalStore(store.subscribe, store.get);
235
+ const [heights, setHeights] = useState<Record<string, number>>({});
236
+ const collapseTimer = useRef<ReturnType<typeof setTimeout> | 0>(0);
237
+ const hovering = useRef(false);
238
+
239
+ const onHeight = (id: string, h: number) =>
240
+ setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
241
+
242
+ // hold = fan open + freeze clocks; release = graced fold + resume
243
+ const onHold = () => {
244
+ hovering.current = true;
245
+ clearTimeout(collapseTimer.current);
246
+ store.setExpanded(true);
247
+ store.pause();
248
+ };
249
+ const onRelease = (graced: boolean) => {
250
+ hovering.current = false;
251
+ clearTimeout(collapseTimer.current);
252
+ collapseTimer.current = setTimeout(
253
+ () => {
254
+ store.setExpanded(false);
255
+ store.resume();
256
+ },
257
+ graced ? COLLAPSE_GRACE : 0,
258
+ );
259
+ };
260
+
261
+ // tab hidden - freeze clocks, visible - resume (visibility, not window focus: an embedded preview blurs on outside clicks).
262
+ useEffect(() => {
263
+ const onVis = () => {
264
+ if (document.hidden) store.pause();
265
+ else if (!hovering.current) store.resume();
266
+ };
267
+ document.addEventListener('visibilitychange', onVis);
268
+ return () => document.removeEventListener('visibilitychange', onVis);
269
+ }, []);
270
+
271
+ // safety net: last card removed under the cursor fires no pointerout, so release here
272
+ useEffect(() => {
273
+ if (toasts.length === 0) {
274
+ hovering.current = false;
275
+ clearTimeout(collapseTimer.current);
276
+ store.setExpanded(false);
277
+ store.resume();
278
+ }
279
+ }, [toasts.length]);
280
+
281
+ const slice = toasts.slice(-RENDERED);
282
+ const n = slice.length;
283
+ const frontH = n ? heights[slice[n - 1].id] || 0 : 0;
284
+ const gap = stackGap();
285
+
286
+ return createPortal(
287
+ <ol
288
+ className={'toast-viewport' + (paused ? ' is-paused' : '')}
289
+ aria-label="Notifications"
290
+ onPointerOver={(e) => {
291
+ if (e.pointerType !== 'touch') onHold();
292
+ }}
293
+ onPointerOut={(e) => {
294
+ if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget as Node)) onRelease(true);
295
+ }}
296
+ onFocus={onHold}
297
+ onBlur={(e) => {
298
+ if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget as Node))
299
+ onRelease(false);
300
+ }}
301
+ >
302
+ {/* initial stays true - the host mounts with the first toast, so initial={false} would skip its entrance */}
303
+ <AnimatePresence>
304
+ {slice.map((t, i) => {
305
+ const depth = n - 1 - i; // newest = 0, at the front
306
+ let offset = depth * gap; // collapsed: peek per depth
307
+ if (expanded) {
308
+ // expanded: real heights
309
+ offset = 0;
310
+ for (let j = i + 1; j < n; j++) offset += (heights[slice[j].id] || 0) + gap;
311
+ }
312
+ return (
313
+ <ToastItem
314
+ key={t.id}
315
+ t={t}
316
+ depth={expanded ? 0 : depth}
317
+ offset={offset}
318
+ hidden={!expanded && depth >= VISIBLE}
319
+ behind={!expanded && depth > 0}
320
+ frameHeight={!expanded && depth > 0 && frontH ? frontH : null}
321
+ onHeight={onHeight}
322
+ />
323
+ );
324
+ })}
325
+ </AnimatePresence>
326
+ </ol>,
327
+ document.body,
328
+ );
329
+ }
330
+
331
+ let hostMounted = false;
332
+ function ensureToastHost() {
333
+ if (hostMounted) return;
334
+ hostMounted = true;
335
+ const el = document.createElement('div');
336
+ el.setAttribute('data-toast-host', '');
337
+ document.body.appendChild(el);
338
+ createRoot(el).render(<ToastHost />);
339
+ }
340
+ store.host = ensureToastHost;
341
+ if (store.toasts.length) ensureToastHost(); // toasts fired before we loaded
342
+
343
+ /* Toaster - mount once near the app root so this module loads client-side and the lazy host is registered (toast() also auto-mounts). */
344
+ export function Toaster(): null {
345
+ useEffect(() => {
346
+ ensureToastHost();
347
+ }, []);
348
+ return null;
349
+ }
@@ -0,0 +1,266 @@
1
+ // toast-store - the Toast machinery: queue, clocks, coalescing, imperative API (zero React).
2
+
3
+ export type ToastTone = 'default' | 'success' | 'error' | 'warning' | 'info' | 'loading' | 'custom';
4
+
5
+ export interface ToastAction {
6
+ label: string;
7
+ onClick: () => void;
8
+ }
9
+
10
+ export interface ToastOptions {
11
+ id?: string | number;
12
+ duration?: number;
13
+ description?: string | null;
14
+ action?: ToastAction | null;
15
+ }
16
+
17
+ export interface ToastRecord {
18
+ id: string;
19
+ tone: ToastTone;
20
+ message: string | null;
21
+ description: string | null;
22
+ action: ToastAction | null;
23
+ duration: number;
24
+ remaining: number;
25
+ count: number;
26
+ timerKey: number;
27
+ node: unknown; // a React node, kept opaque so this module stays React-free
28
+ dismissible: boolean;
29
+ expiresAt?: number;
30
+ }
31
+
32
+ export interface ToastSnapshot {
33
+ toasts: ToastRecord[];
34
+ paused: boolean;
35
+ expanded: boolean;
36
+ }
37
+
38
+ const DURATION: Record<string, number> = {
39
+ default: 5000,
40
+ success: 5000,
41
+ info: 5000,
42
+ warning: 8000,
43
+ error: 8000,
44
+ loading: Infinity, // sticky until updated/dismissed
45
+ };
46
+
47
+ let uid = 0;
48
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
49
+
50
+ interface ToastStore {
51
+ toasts: ToastRecord[];
52
+ paused: boolean;
53
+ expanded: boolean;
54
+ host: null | (() => void);
55
+ snap: ToastSnapshot;
56
+ listeners: Set<() => void>;
57
+ subscribe(l: () => void): () => void;
58
+ get(): ToastSnapshot;
59
+ emit(): void;
60
+ add(t: ToastRecord): string;
61
+ update(id: string, patch: Partial<ToastRecord>): string;
62
+ dismiss(id?: string | null): void;
63
+ schedule(t: ToastRecord): void;
64
+ pause(): void;
65
+ resume(): void;
66
+ setExpanded(v: boolean): void;
67
+ }
68
+
69
+ /* Store - the queue; the API writes, the host renders. */
70
+ const store: ToastStore = {
71
+ toasts: [],
72
+ paused: false,
73
+ expanded: false,
74
+ host: null,
75
+ snap: { toasts: [], paused: false, expanded: false },
76
+ listeners: new Set(),
77
+ subscribe(l) {
78
+ store.listeners.add(l);
79
+ return () => store.listeners.delete(l);
80
+ },
81
+ get: () => store.snap,
82
+ emit() {
83
+ store.snap = { toasts: store.toasts.slice(), paused: store.paused, expanded: store.expanded };
84
+ store.listeners.forEach((l) => l());
85
+ },
86
+
87
+ add(t) {
88
+ // coalesce: an identical visible toast becomes a xN counter instead of a duplicate
89
+ const twin = store.toasts.find(
90
+ (x) =>
91
+ !x.node &&
92
+ !t.node &&
93
+ x.tone === t.tone &&
94
+ x.message === t.message &&
95
+ x.description === t.description,
96
+ );
97
+ if (twin) return store.update(twin.id, { count: twin.count + 1 });
98
+ store.toasts.push(t);
99
+ store.schedule(t);
100
+ store.emit();
101
+ return t.id;
102
+ },
103
+
104
+ update(id, patch) {
105
+ const t = store.toasts.find((x) => x.id === id);
106
+ if (!t) return id;
107
+ Object.assign(t, patch);
108
+ if ('tone' in patch || 'duration' in patch || 'count' in patch) {
109
+ t.remaining = t.duration;
110
+ t.timerKey++; // re-key the ring so it re-fills
111
+ store.schedule(t);
112
+ }
113
+ store.emit();
114
+ return id;
115
+ },
116
+
117
+ dismiss(id) {
118
+ const gone = id == null ? store.toasts : store.toasts.filter((t) => t.id === id);
119
+ gone.forEach((t) => {
120
+ clearTimeout(timers.get(t.id));
121
+ timers.delete(t.id);
122
+ });
123
+ store.toasts = id == null ? [] : store.toasts.filter((t) => t.id !== id);
124
+ store.emit();
125
+ },
126
+
127
+ schedule(t) {
128
+ clearTimeout(timers.get(t.id));
129
+ timers.delete(t.id);
130
+ if (!isFinite(t.duration)) return;
131
+ if (store.paused) return; // resume() picks it up with t.remaining
132
+ t.expiresAt = performance.now() + t.remaining;
133
+ timers.set(
134
+ t.id,
135
+ setTimeout(() => store.dismiss(t.id), t.remaining),
136
+ );
137
+ },
138
+
139
+ pause() {
140
+ // freeze every clock, remember what's left
141
+ if (store.paused) return;
142
+ store.paused = true;
143
+ store.toasts.forEach((t) => {
144
+ if (timers.has(t.id)) {
145
+ clearTimeout(timers.get(t.id));
146
+ timers.delete(t.id);
147
+ t.remaining = Math.max(0, (t.expiresAt ?? performance.now()) - performance.now());
148
+ }
149
+ });
150
+ store.emit();
151
+ },
152
+
153
+ resume() {
154
+ if (!store.paused) return;
155
+ store.paused = false;
156
+ store.toasts.forEach((t) => store.schedule(t));
157
+ store.emit();
158
+ },
159
+
160
+ setExpanded(v) {
161
+ if (v === store.expanded) return;
162
+ store.expanded = v;
163
+ store.emit();
164
+ },
165
+ };
166
+
167
+ function make(tone: ToastTone, message: string | null, opts: ToastOptions = {}): string {
168
+ if (store.host) store.host();
169
+ const id = opts.id != null ? String(opts.id) : 'toast-' + ++uid;
170
+ const duration = opts.duration != null ? opts.duration : DURATION[tone];
171
+ if (store.toasts.some((t) => t.id === id)) {
172
+ return store.update(id, {
173
+ tone,
174
+ message,
175
+ duration,
176
+ description: opts.description != null ? opts.description : null,
177
+ action: opts.action != null ? opts.action : null,
178
+ });
179
+ }
180
+ return store.add({
181
+ id,
182
+ tone,
183
+ message,
184
+ description: opts.description != null ? opts.description : null,
185
+ action: opts.action != null ? opts.action : null,
186
+ duration,
187
+ remaining: duration,
188
+ count: 1,
189
+ timerKey: 0,
190
+ node: null,
191
+ dismissible: true,
192
+ });
193
+ }
194
+
195
+ type PromiseMsg<V> = string | ((v: V) => string);
196
+ export interface ToastPromiseMsgs<T> {
197
+ loading?: string;
198
+ success?: PromiseMsg<T>;
199
+ error?: PromiseMsg<unknown>;
200
+ }
201
+
202
+ export interface ToastApi {
203
+ (message: string, opts?: ToastOptions): string;
204
+ success(m: string, o?: ToastOptions): string;
205
+ error(m: string, o?: ToastOptions): string;
206
+ warning(m: string, o?: ToastOptions): string;
207
+ info(m: string, o?: ToastOptions): string;
208
+ loading(m: string, o?: ToastOptions): string;
209
+ dismiss(id?: string | null): void;
210
+ update(id: string, patch?: Partial<ToastRecord>): string;
211
+ promise<T>(promise: Promise<T>, msgs?: ToastPromiseMsgs<T>): Promise<T>;
212
+ custom(
213
+ node: unknown,
214
+ opts?: { id?: string | number; duration?: number; dismissible?: boolean },
215
+ ): string;
216
+ }
217
+
218
+ const toast = ((message: string, opts?: ToastOptions) =>
219
+ make('default', message, opts)) as ToastApi;
220
+ toast.success = (m, o) => make('success', m, o);
221
+ toast.error = (m, o) => make('error', m, o);
222
+ toast.warning = (m, o) => make('warning', m, o);
223
+ toast.info = (m, o) => make('info', m, o);
224
+ toast.loading = (m, o) => make('loading', m, o);
225
+ toast.dismiss = (id) => store.dismiss(id);
226
+
227
+ toast.update = (id, patch = {}) => {
228
+ // a tone change without an explicit duration adopts that tone's clock
229
+ if (patch.tone && patch.duration == null) patch = { ...patch, duration: DURATION[patch.tone] };
230
+ return store.update(id, patch);
231
+ };
232
+
233
+ toast.promise = function <T>(promise: Promise<T>, msgs: ToastPromiseMsgs<T> = {}): Promise<T> {
234
+ const id = make('loading', msgs.loading != null ? msgs.loading : 'Working...');
235
+ const text = <V>(m: PromiseMsg<V> | undefined, fallback: string, v: V): string =>
236
+ typeof m === 'function' ? (m as (x: V) => string)(v) : m != null ? m : fallback;
237
+ promise.then(
238
+ (v) => toast.update(id, { tone: 'success', message: text(msgs.success, 'Done', v) }),
239
+ (e) =>
240
+ toast.update(id, { tone: 'error', message: text(msgs.error, 'Something went wrong', e) }),
241
+ );
242
+ return promise;
243
+ };
244
+
245
+ toast.custom = (node, opts = {}) => {
246
+ if (store.host) store.host();
247
+ const id = opts.id != null ? String(opts.id) : 'toast-' + ++uid;
248
+ const duration = opts.duration != null ? opts.duration : Infinity;
249
+ if (store.toasts.some((t) => t.id === id)) return store.update(id, { node });
250
+ return store.add({
251
+ id,
252
+ tone: 'custom',
253
+ node,
254
+ message: null,
255
+ description: null,
256
+ action: null,
257
+ duration,
258
+ remaining: duration,
259
+ count: 1,
260
+ timerKey: 0,
261
+ dismissible: opts.dismissible === true,
262
+ });
263
+ };
264
+
265
+ export { toast };
266
+ export const UIToast = store;