@blurrow/ui 0.1.0 → 0.2.1

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 (272) hide show
  1. package/README.md +60 -20
  2. package/dist/Accordion.d.ts +17 -0
  3. package/dist/Accordion.js +138 -0
  4. package/dist/Accordion.js.map +1 -0
  5. package/dist/Alert.d.ts +13 -0
  6. package/dist/Alert.js +69 -0
  7. package/dist/Alert.js.map +1 -0
  8. package/dist/AuthorCard.d.ts +12 -0
  9. package/dist/AuthorCard.js +57 -0
  10. package/dist/AuthorCard.js.map +1 -0
  11. package/dist/Avatar.d.ts +21 -0
  12. package/dist/Avatar.js +96 -0
  13. package/dist/Avatar.js.map +1 -0
  14. package/dist/Badge.d.ts +12 -0
  15. package/dist/Badge.js +46 -0
  16. package/dist/Badge.js.map +1 -0
  17. package/dist/Banner.d.ts +15 -0
  18. package/dist/Banner.js +68 -0
  19. package/dist/Banner.js.map +1 -0
  20. package/dist/Breadcrumb.d.ts +13 -0
  21. package/dist/Breadcrumb.js +45 -0
  22. package/dist/Breadcrumb.js.map +1 -0
  23. package/dist/Button.d.ts +19 -0
  24. package/dist/Button.js +282 -0
  25. package/dist/Button.js.map +1 -0
  26. package/dist/ButtonGroup.d.ts +19 -0
  27. package/dist/ButtonGroup.js +62 -0
  28. package/dist/ButtonGroup.js.map +1 -0
  29. package/dist/Card.d.ts +40 -0
  30. package/dist/Card.js +175 -0
  31. package/dist/Card.js.map +1 -0
  32. package/dist/CardShell.d.ts +18 -0
  33. package/dist/CardShell.js +58 -0
  34. package/dist/CardShell.js.map +1 -0
  35. package/dist/Carousel.d.ts +22 -0
  36. package/dist/Carousel.js +247 -0
  37. package/dist/Carousel.js.map +1 -0
  38. package/dist/Checkbox.d.ts +15 -0
  39. package/dist/Checkbox.js +84 -0
  40. package/dist/Checkbox.js.map +1 -0
  41. package/dist/CloseButton.d.ts +12 -0
  42. package/dist/CloseButton.js +63 -0
  43. package/dist/CloseButton.js.map +1 -0
  44. package/dist/CodeBlock.d.ts +12 -0
  45. package/dist/CodeBlock.js +221 -0
  46. package/dist/CodeBlock.js.map +1 -0
  47. package/dist/ColorPicker.d.ts +24 -0
  48. package/dist/ColorPicker.js +366 -0
  49. package/dist/ColorPicker.js.map +1 -0
  50. package/dist/Combobox.d.ts +34 -0
  51. package/dist/Combobox.js +255 -0
  52. package/dist/Combobox.js.map +1 -0
  53. package/dist/CommandPalette.d.ts +25 -0
  54. package/dist/CommandPalette.js +304 -0
  55. package/dist/CommandPalette.js.map +1 -0
  56. package/dist/ContextMenu.d.ts +16 -0
  57. package/dist/ContextMenu.js +209 -0
  58. package/dist/ContextMenu.js.map +1 -0
  59. package/dist/DataList.d.ts +15 -0
  60. package/dist/DataList.js +42 -0
  61. package/dist/DataList.js.map +1 -0
  62. package/dist/DatePicker.d.ts +65 -0
  63. package/dist/DatePicker.js +391 -0
  64. package/dist/DatePicker.js.map +1 -0
  65. package/dist/DateRangePicker.d.ts +34 -0
  66. package/dist/DateRangePicker.js +180 -0
  67. package/dist/DateRangePicker.js.map +1 -0
  68. package/dist/Drawer.d.ts +17 -0
  69. package/dist/Drawer.js +132 -0
  70. package/dist/Drawer.js.map +1 -0
  71. package/dist/DropdownMenu.d.ts +23 -0
  72. package/dist/DropdownMenu.js +185 -0
  73. package/dist/DropdownMenu.js.map +1 -0
  74. package/dist/EmptyState.d.ts +13 -0
  75. package/dist/EmptyState.js +55 -0
  76. package/dist/EmptyState.js.map +1 -0
  77. package/dist/FieldMessage.d.ts +26 -0
  78. package/dist/FieldMessage.js +95 -0
  79. package/dist/FieldMessage.js.map +1 -0
  80. package/dist/FileUpload.d.ts +32 -0
  81. package/dist/FileUpload.js +253 -0
  82. package/dist/FileUpload.js.map +1 -0
  83. package/dist/Floating.d.ts +32 -0
  84. package/dist/Floating.js +95 -0
  85. package/dist/Floating.js.map +1 -0
  86. package/dist/HoverCard.d.ts +18 -0
  87. package/dist/HoverCard.js +90 -0
  88. package/dist/HoverCard.js.map +1 -0
  89. package/dist/Input.d.ts +17 -0
  90. package/dist/Input.js +96 -0
  91. package/dist/Input.js.map +1 -0
  92. package/dist/Kbd.d.ts +9 -0
  93. package/dist/Kbd.js +24 -0
  94. package/dist/Kbd.js.map +1 -0
  95. package/dist/Modal.d.ts +15 -0
  96. package/dist/Modal.js +109 -0
  97. package/dist/Modal.js.map +1 -0
  98. package/dist/NumberInput.d.ts +23 -0
  99. package/dist/NumberInput.js +162 -0
  100. package/dist/NumberInput.js.map +1 -0
  101. package/dist/OtpInput.d.ts +26 -0
  102. package/dist/OtpInput.js +173 -0
  103. package/dist/OtpInput.js.map +1 -0
  104. package/dist/Pagination.d.ts +11 -0
  105. package/dist/Pagination.js +148 -0
  106. package/dist/Pagination.js.map +1 -0
  107. package/dist/Popover.d.ts +13 -0
  108. package/dist/Popover.js +98 -0
  109. package/dist/Popover.js.map +1 -0
  110. package/dist/Progress.d.ts +12 -0
  111. package/dist/Progress.js +61 -0
  112. package/dist/Progress.js.map +1 -0
  113. package/dist/QuoteCard.d.ts +13 -0
  114. package/dist/QuoteCard.js +92 -0
  115. package/dist/QuoteCard.js.map +1 -0
  116. package/dist/Radio.d.ts +19 -0
  117. package/dist/Radio.js +161 -0
  118. package/dist/Radio.js.map +1 -0
  119. package/dist/RadioCards.d.ts +27 -0
  120. package/dist/RadioCards.js +205 -0
  121. package/dist/RadioCards.js.map +1 -0
  122. package/dist/ReactionBar.d.ts +15 -0
  123. package/dist/ReactionBar.js +92 -0
  124. package/dist/ReactionBar.js.map +1 -0
  125. package/dist/ReadingProgress.d.ts +8 -0
  126. package/dist/ReadingProgress.js +51 -0
  127. package/dist/ReadingProgress.js.map +1 -0
  128. package/dist/ReadingTime.d.ts +9 -0
  129. package/dist/ReadingTime.js +39 -0
  130. package/dist/ReadingTime.js.map +1 -0
  131. package/dist/Select.d.ts +20 -0
  132. package/dist/Select.js +244 -0
  133. package/dist/Select.js.map +1 -0
  134. package/dist/Separator.d.ts +14 -0
  135. package/dist/Separator.js +33 -0
  136. package/dist/Separator.js.map +1 -0
  137. package/dist/ShareButtons.d.ts +10 -0
  138. package/dist/ShareButtons.js +107 -0
  139. package/dist/ShareButtons.js.map +1 -0
  140. package/dist/Sidebar.d.ts +30 -0
  141. package/dist/Sidebar.js +140 -0
  142. package/dist/Sidebar.js.map +1 -0
  143. package/dist/Skeleton.d.ts +15 -0
  144. package/dist/Skeleton.js +67 -0
  145. package/dist/Skeleton.js.map +1 -0
  146. package/dist/Slider.d.ts +27 -0
  147. package/dist/Slider.js +182 -0
  148. package/dist/Slider.js.map +1 -0
  149. package/dist/Spinner.d.ts +12 -0
  150. package/dist/Spinner.js +24 -0
  151. package/dist/Spinner.js.map +1 -0
  152. package/dist/SpotlightCard.d.ts +17 -0
  153. package/dist/SpotlightCard.js +87 -0
  154. package/dist/SpotlightCard.js.map +1 -0
  155. package/dist/Stat.d.ts +19 -0
  156. package/dist/Stat.js +71 -0
  157. package/dist/Stat.js.map +1 -0
  158. package/dist/StatCard.d.ts +24 -0
  159. package/dist/StatCard.js +212 -0
  160. package/dist/StatCard.js.map +1 -0
  161. package/dist/Steps.d.ts +14 -0
  162. package/dist/Steps.js +94 -0
  163. package/dist/Steps.js.map +1 -0
  164. package/dist/Table.d.ts +21 -0
  165. package/dist/Table.js +93 -0
  166. package/dist/Table.js.map +1 -0
  167. package/dist/TableOfContents.d.ts +17 -0
  168. package/dist/TableOfContents.js +92 -0
  169. package/dist/TableOfContents.js.map +1 -0
  170. package/dist/Tabs.d.ts +17 -0
  171. package/dist/Tabs.js +123 -0
  172. package/dist/Tabs.js.map +1 -0
  173. package/dist/TagInput.d.ts +16 -0
  174. package/dist/TagInput.js +169 -0
  175. package/dist/TagInput.js.map +1 -0
  176. package/dist/Textarea.d.ts +13 -0
  177. package/dist/Textarea.js +74 -0
  178. package/dist/Textarea.js.map +1 -0
  179. package/dist/TimePicker.d.ts +28 -0
  180. package/dist/TimePicker.js +264 -0
  181. package/dist/TimePicker.js.map +1 -0
  182. package/dist/Timeline.d.ts +19 -0
  183. package/dist/Timeline.js +76 -0
  184. package/dist/Timeline.js.map +1 -0
  185. package/dist/Toast.d.ts +18 -0
  186. package/dist/Toast.js +318 -0
  187. package/dist/Toast.js.map +1 -0
  188. package/dist/Toggle.d.ts +13 -0
  189. package/dist/Toggle.js +84 -0
  190. package/dist/Toggle.js.map +1 -0
  191. package/dist/Tooltip.d.ts +13 -0
  192. package/dist/Tooltip.js +138 -0
  193. package/dist/Tooltip.js.map +1 -0
  194. package/dist/cli.js +1644 -0
  195. package/dist/index.d.ts +68 -631
  196. package/dist/index.js +67 -5558
  197. package/dist/index.js.map +1 -1
  198. package/dist/registry/Accordion.tsx +140 -0
  199. package/dist/registry/Alert.tsx +95 -0
  200. package/dist/registry/AuthorCard.tsx +67 -0
  201. package/dist/registry/Avatar.tsx +116 -0
  202. package/dist/registry/Badge.tsx +55 -0
  203. package/dist/registry/Banner.tsx +86 -0
  204. package/dist/registry/Breadcrumb.tsx +60 -0
  205. package/dist/registry/Button.tsx +361 -0
  206. package/dist/registry/ButtonGroup.tsx +82 -0
  207. package/dist/registry/Card.tsx +241 -0
  208. package/dist/registry/CardShell.tsx +78 -0
  209. package/dist/registry/Carousel.tsx +296 -0
  210. package/dist/registry/Checkbox.tsx +104 -0
  211. package/dist/registry/CloseButton.tsx +68 -0
  212. package/dist/registry/CodeBlock.tsx +202 -0
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +356 -0
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +49 -0
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +166 -0
  221. package/dist/registry/DropdownMenu.tsx +219 -0
  222. package/dist/registry/EmptyState.tsx +55 -0
  223. package/dist/registry/FieldMessage.tsx +123 -0
  224. package/dist/registry/FileUpload.tsx +318 -0
  225. package/dist/registry/Floating.tsx +134 -0
  226. package/dist/registry/HoverCard.tsx +113 -0
  227. package/dist/registry/Input.tsx +108 -0
  228. package/dist/registry/Kbd.tsx +23 -0
  229. package/dist/registry/Modal.tsx +130 -0
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +160 -0
  233. package/dist/registry/Popover.tsx +116 -0
  234. package/dist/registry/Progress.tsx +66 -0
  235. package/dist/registry/QuoteCard.tsx +93 -0
  236. package/dist/registry/Radio.tsx +205 -0
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +101 -0
  239. package/dist/registry/ReadingProgress.tsx +51 -0
  240. package/dist/registry/ReadingTime.tsx +35 -0
  241. package/dist/registry/Select.tsx +265 -0
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +134 -0
  244. package/dist/registry/Sidebar.tsx +182 -0
  245. package/dist/registry/Skeleton.tsx +83 -0
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +106 -0
  249. package/dist/registry/Stat.tsx +82 -0
  250. package/dist/registry/StatCard.tsx +256 -0
  251. package/dist/registry/Steps.tsx +109 -0
  252. package/dist/registry/Table.tsx +123 -0
  253. package/dist/registry/TableOfContents.tsx +116 -0
  254. package/dist/registry/Tabs.tsx +136 -0
  255. package/dist/registry/TagInput.tsx +191 -0
  256. package/dist/registry/Textarea.tsx +84 -0
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +102 -0
  259. package/dist/registry/Toast.tsx +388 -0
  260. package/dist/registry/Toggle.tsx +96 -0
  261. package/dist/registry/Tooltip.tsx +160 -0
  262. package/dist/registry/styles.css +1207 -0
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/registry/useModalBehavior.ts +77 -0
  265. package/dist/styles.css +868 -62
  266. package/dist/useFloatingPanel.d.ts +24 -0
  267. package/dist/useFloatingPanel.js +89 -0
  268. package/dist/useFloatingPanel.js.map +1 -0
  269. package/dist/useModalBehavior.d.ts +15 -0
  270. package/dist/useModalBehavior.js +57 -0
  271. package/dist/useModalBehavior.js.map +1 -0
  272. package/package.json +23 -6
@@ -0,0 +1,388 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ useState,
5
+ useEffect,
6
+ useCallback,
7
+ createContext,
8
+ useContext,
9
+ useRef,
10
+ useLayoutEffect,
11
+ } from "react";
12
+ import { CloseButton } from "./CloseButton";
13
+
14
+ type ToastVariant = "default" | "success" | "warning" | "error";
15
+
16
+ interface ToastItem {
17
+ id: string;
18
+ title: string;
19
+ description?: string;
20
+ variant: ToastVariant;
21
+ duration: number;
22
+ }
23
+
24
+ interface ToastContextValue {
25
+ toast: (opts: {
26
+ title: string;
27
+ description?: string;
28
+ variant?: ToastVariant;
29
+ duration?: number;
30
+ }) => void;
31
+ }
32
+
33
+ const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
34
+
35
+ export function useToast() {
36
+ return useContext(ToastContext);
37
+ }
38
+
39
+ const variantConfig: Record<
40
+ ToastVariant,
41
+ { tile: string; icon: React.ReactNode }
42
+ > = {
43
+ default: {
44
+ tile: "var(--face-inverse)",
45
+ icon: (
46
+ <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
47
+ <circle cx="12" cy="12" r="10" />
48
+ <path strokeLinecap="round" d="M12 8v4m0 4h.01" />
49
+ </svg>
50
+ ),
51
+ },
52
+ success: {
53
+ tile: "var(--tile-success)",
54
+ icon: (
55
+ <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
56
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
57
+ </svg>
58
+ ),
59
+ },
60
+ warning: {
61
+ tile: "var(--tile-warning)",
62
+ icon: (
63
+ <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
64
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
65
+ </svg>
66
+ ),
67
+ },
68
+ error: {
69
+ tile: "var(--tile-error)",
70
+ icon: (
71
+ <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">
72
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
73
+ </svg>
74
+ ),
75
+ },
76
+ };
77
+
78
+ const TOAST_WIDTH = "min(360px, calc(100vw - 24px))";
79
+ const VISIBLE_STACK = 3;
80
+ const STACK_OFFSET = 10;
81
+ const STACK_SCALE_STEP = 0.05;
82
+ const GAP = 10;
83
+ const ENTER_MS = 320;
84
+ const EXIT_MS = 220;
85
+ const SHIFT_MS = 420;
86
+
87
+ interface ToastCardProps {
88
+ item: ToastItem;
89
+ index: number;
90
+ total: number;
91
+ expanded: boolean;
92
+ expandedOffset: number;
93
+ exiting: boolean;
94
+ entering: boolean;
95
+ paused: boolean;
96
+ onDismiss: (id: string) => void;
97
+ onMeasure: (id: string, height: number) => void;
98
+ }
99
+
100
+ function ToastCard({
101
+ item,
102
+ index,
103
+ total,
104
+ expanded,
105
+ expandedOffset,
106
+ exiting,
107
+ entering,
108
+ paused,
109
+ onDismiss,
110
+ onMeasure,
111
+ }: ToastCardProps) {
112
+ const ref = useRef<HTMLDivElement>(null);
113
+ const innerRef = useRef<HTMLDivElement>(null);
114
+ const remainingRef = useRef(item.duration);
115
+ const startRef = useRef<number | null>(null);
116
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
117
+
118
+ useLayoutEffect(() => {
119
+ if (!innerRef.current) return;
120
+ const ro = new ResizeObserver(() => {
121
+ if (innerRef.current) onMeasure(item.id, innerRef.current.offsetHeight);
122
+ });
123
+ ro.observe(innerRef.current);
124
+ onMeasure(item.id, innerRef.current.offsetHeight);
125
+ return () => ro.disconnect();
126
+ }, [item.id, onMeasure]);
127
+
128
+ useEffect(() => {
129
+ if (exiting) {
130
+ if (timerRef.current) clearTimeout(timerRef.current);
131
+ return;
132
+ }
133
+ if (paused) {
134
+ if (timerRef.current) {
135
+ clearTimeout(timerRef.current);
136
+ timerRef.current = null;
137
+ if (startRef.current !== null) {
138
+ remainingRef.current = Math.max(
139
+ 0,
140
+ remainingRef.current - (Date.now() - startRef.current),
141
+ );
142
+ }
143
+ }
144
+ return;
145
+ }
146
+ startRef.current = Date.now();
147
+ timerRef.current = setTimeout(() => {
148
+ onDismiss(item.id);
149
+ }, remainingRef.current);
150
+ return () => {
151
+ if (timerRef.current) clearTimeout(timerRef.current);
152
+ };
153
+ }, [paused, exiting, item.id, onDismiss]);
154
+
155
+ const { tile, icon } = variantConfig[item.variant];
156
+
157
+ const hidden = !expanded && index >= VISIBLE_STACK;
158
+
159
+ const collapsedTransform = `translateX(-50%) translateY(${
160
+ index * STACK_OFFSET
161
+ }px) scale(${1 - index * STACK_SCALE_STEP})`;
162
+
163
+ const expandedTransform = `translateX(-50%) translateY(${expandedOffset}px) scale(1)`;
164
+
165
+ let transform = expanded ? expandedTransform : collapsedTransform;
166
+ let opacity = hidden ? 0 : 1;
167
+
168
+ if (entering) {
169
+ transform = `translateX(-50%) translateY(-14px) scale(0.96)`;
170
+ opacity = 0;
171
+ }
172
+ if (exiting) {
173
+ transform = expanded
174
+ ? `translateX(-50%) translateY(${expandedOffset - 12}px) scale(0.96)`
175
+ : `translateX(-50%) translateY(${
176
+ index * STACK_OFFSET - 12
177
+ }px) scale(${1 - index * STACK_SCALE_STEP - 0.02})`;
178
+ opacity = 0;
179
+ }
180
+
181
+ return (
182
+ <div
183
+ ref={ref}
184
+ className="absolute top-0 left-1/2 pointer-events-auto"
185
+ style={{
186
+ width: TOAST_WIDTH,
187
+ transform,
188
+ opacity,
189
+ zIndex: total - index,
190
+ transformOrigin: "top center",
191
+ transition: `transform ${SHIFT_MS}ms cubic-bezier(0.32,0.72,0,1), opacity ${
192
+ exiting ? EXIT_MS : ENTER_MS
193
+ }ms ease`,
194
+ }}
195
+ >
196
+ <div
197
+ ref={innerRef}
198
+ className="rounded-[18px] p-1.5"
199
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--overlay-shadow)" }}
200
+ >
201
+ <div
202
+ className="bg-panel-face rounded-[13px] pl-3 pr-2 py-3 flex items-start gap-3"
203
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
204
+ >
205
+ <div
206
+ aria-hidden
207
+ className={[
208
+ "shrink-0 w-7 h-7 rounded-[8px] flex items-center justify-center",
209
+ // the default tile is the inverse face; status tiles stay white-on-colour
210
+ tile === "var(--face-inverse)" ? "text-ink-inverse" : "text-white",
211
+ ].join(" ")}
212
+ style={{
213
+ background: tile,
214
+ boxShadow: "var(--tile-status-shadow)",
215
+ }}
216
+ >
217
+ {icon}
218
+ </div>
219
+ <div className="flex-1 min-w-0 pt-[5px]">
220
+ <p className="text-[13px] font-semibold text-ink leading-tight tracking-[-0.01em]">
221
+ {item.title}
222
+ </p>
223
+ {item.description && (
224
+ <p className="text-[12.5px] text-ink-muted mt-1 leading-relaxed">
225
+ {item.description}
226
+ </p>
227
+ )}
228
+ </div>
229
+ <CloseButton size="sm" label="Dismiss notification" onClick={() => onDismiss(item.id)} />
230
+ </div>
231
+ </div>
232
+ </div>
233
+ );
234
+ }
235
+
236
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
237
+ const [toasts, setToasts] = useState<ToastItem[]>([]);
238
+ const [expanded, setExpanded] = useState(false);
239
+ const [heights, setHeights] = useState<Record<string, number>>({});
240
+ const [exiting, setExiting] = useState<Set<string>>(new Set());
241
+ const [entering, setEntering] = useState<Set<string>>(new Set());
242
+ const counter = useRef(0);
243
+
244
+ const reportHeight = useCallback((id: string, h: number) => {
245
+ setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
246
+ }, []);
247
+
248
+ const dismiss = useCallback((id: string) => {
249
+ setExiting((prev) => {
250
+ if (prev.has(id)) return prev;
251
+ const next = new Set(prev);
252
+ next.add(id);
253
+ return next;
254
+ });
255
+ setTimeout(() => {
256
+ setToasts((prev) => prev.filter((t) => t.id !== id));
257
+ setExiting((prev) => {
258
+ const next = new Set(prev);
259
+ next.delete(id);
260
+ return next;
261
+ });
262
+ setHeights((prev) => {
263
+ const next = { ...prev };
264
+ delete next[id];
265
+ return next;
266
+ });
267
+ }, EXIT_MS);
268
+ }, []);
269
+
270
+ const toast = useCallback(
271
+ (opts: {
272
+ title: string;
273
+ description?: string;
274
+ variant?: ToastVariant;
275
+ duration?: number;
276
+ }) => {
277
+ const id = `toast-${++counter.current}`;
278
+ const item: ToastItem = {
279
+ id,
280
+ title: opts.title,
281
+ description: opts.description,
282
+ variant: opts.variant ?? "default",
283
+ duration: opts.duration ?? 4000,
284
+ };
285
+ setToasts((prev) => [...prev, item]);
286
+ setEntering((prev) => {
287
+ const next = new Set(prev);
288
+ next.add(id);
289
+ return next;
290
+ });
291
+ requestAnimationFrame(() => {
292
+ requestAnimationFrame(() => {
293
+ setEntering((prev) => {
294
+ const next = new Set(prev);
295
+ next.delete(id);
296
+ return next;
297
+ });
298
+ });
299
+ });
300
+ },
301
+ [],
302
+ );
303
+
304
+ const list = [...toasts].reverse();
305
+
306
+ const expandedOffsets: number[] = [];
307
+ let cum = 0;
308
+ for (let i = 0; i < list.length; i++) {
309
+ expandedOffsets.push(cum);
310
+ cum += (heights[list[i].id] ?? 72) + GAP;
311
+ }
312
+ const expandedHeight = Math.max(0, cum - GAP);
313
+
314
+ const topH = heights[list[0]?.id] ?? 72;
315
+ const visibleCount = Math.min(list.length, VISIBLE_STACK);
316
+ const collapsedHeight =
317
+ list.length === 0
318
+ ? 0
319
+ : topH + Math.max(0, visibleCount - 1) * STACK_OFFSET;
320
+
321
+ const containerHeight = expanded ? expandedHeight : collapsedHeight;
322
+
323
+ const showCount = list.length - VISIBLE_STACK;
324
+
325
+ return (
326
+ <ToastContext.Provider value={{ toast }}>
327
+ {children}
328
+ {/* Hover or keyboard focus fans the stack out and pauses the timers */}
329
+ <section
330
+ aria-label="Notifications"
331
+ aria-live="polite"
332
+ className="fixed top-[72px] left-1/2 z-[95] pointer-events-none"
333
+ style={{
334
+ width: TOAST_WIDTH,
335
+ transform: "translateX(-50%)",
336
+ }}
337
+ onMouseEnter={() => setExpanded(true)}
338
+ onMouseLeave={() => setExpanded(false)}
339
+ onFocus={() => setExpanded(true)}
340
+ onBlur={(e) => {
341
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setExpanded(false);
342
+ }}
343
+ >
344
+ <div
345
+ className={list.length > 0 ? "relative pointer-events-auto" : "relative"}
346
+ style={{
347
+ width: TOAST_WIDTH,
348
+ height: containerHeight,
349
+ transition: `height ${SHIFT_MS}ms cubic-bezier(0.32,0.72,0,1)`,
350
+ }}
351
+ >
352
+ {list.map((t, i) => (
353
+ <ToastCard
354
+ key={t.id}
355
+ item={t}
356
+ index={i}
357
+ total={list.length}
358
+ expanded={expanded}
359
+ expandedOffset={expandedOffsets[i]}
360
+ exiting={exiting.has(t.id)}
361
+ entering={entering.has(t.id)}
362
+ paused={expanded}
363
+ onDismiss={dismiss}
364
+ onMeasure={reportHeight}
365
+ />
366
+ ))}
367
+
368
+ {!expanded && showCount > 0 && (
369
+ <div
370
+ className="absolute left-1/2 -translate-x-1/2 pointer-events-none"
371
+ style={{
372
+ top: collapsedHeight + 6,
373
+ transition: `opacity ${ENTER_MS}ms ease`,
374
+ }}
375
+ >
376
+ <span
377
+ className="text-[10.5px] font-medium text-ink-muted px-2 py-0.5 rounded-full"
378
+ style={{ background: "var(--face)", boxShadow: "var(--face-shadow)" }}
379
+ >
380
+ +{showCount} more
381
+ </span>
382
+ </div>
383
+ )}
384
+ </div>
385
+ </section>
386
+ </ToastContext.Provider>
387
+ );
388
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import React, { useId, useState } from "react";
4
+
5
+ interface ToggleProps {
6
+ checked?: boolean;
7
+ defaultChecked?: boolean;
8
+ onChange?: (checked: boolean) => void;
9
+ label?: string;
10
+ description?: string;
11
+ disabled?: boolean;
12
+ }
13
+
14
+ export function Toggle({
15
+ checked,
16
+ defaultChecked = false,
17
+ onChange,
18
+ label,
19
+ description,
20
+ disabled = false,
21
+ }: ToggleProps) {
22
+ const [internal, setInternal] = useState(defaultChecked);
23
+ const [pressed, setPressed] = useState(false);
24
+ const uid = useId();
25
+ const isOn = checked !== undefined ? checked : internal;
26
+
27
+ const toggle = () => {
28
+ if (disabled) return;
29
+ const next = !isOn;
30
+ setInternal(next);
31
+ onChange?.(next);
32
+ };
33
+
34
+ return (
35
+ <div
36
+ className={[
37
+ "flex items-center justify-between gap-4 select-none",
38
+ disabled ? "opacity-35 pointer-events-none" : "cursor-pointer",
39
+ ].join(" ")}
40
+ onClick={toggle}
41
+ >
42
+ {(label || description) && (
43
+ <div className="flex flex-col gap-0.5 min-w-0">
44
+ {label && (
45
+ <span id={`${uid}-label`} className="text-[13px] font-medium text-ink leading-snug">
46
+ {label}
47
+ </span>
48
+ )}
49
+ {description && (
50
+ <span id={`${uid}-desc`} className="text-[12px] text-ink-muted leading-relaxed">
51
+ {description}
52
+ </span>
53
+ )}
54
+ </div>
55
+ )}
56
+
57
+ {/* Clicks bubble to the row, so the switch itself has no onClick */}
58
+ <button
59
+ type="button"
60
+ role="switch"
61
+ aria-checked={isOn}
62
+ aria-labelledby={label ? `${uid}-label` : undefined}
63
+ aria-describedby={description ? `${uid}-desc` : undefined}
64
+ disabled={disabled}
65
+ onPointerDown={() => setPressed(true)}
66
+ onPointerUp={() => setPressed(false)}
67
+ onPointerLeave={() => setPressed(false)}
68
+ className={[
69
+ "relative shrink-0 w-[46px] h-[26px] rounded-[13px] cursor-pointer",
70
+ "transition-[background-color,box-shadow] duration-200 ease-out",
71
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
72
+ isOn ? "bg-track-on" : "bg-track",
73
+ ].join(" ")}
74
+ style={{
75
+ /* The track is a recessed channel; the knob is a raised face inside it */
76
+ boxShadow: isOn ? "var(--track-on-shadow)" : "var(--track-shadow)",
77
+ }}
78
+ >
79
+ <span
80
+ aria-hidden
81
+ className="absolute top-1/2 h-5 rounded-[10px]"
82
+ style={{
83
+ left: 3,
84
+ /* The knob stretches while held, like a finger dragging it */
85
+ width: pressed ? 24 : 20,
86
+ background: "var(--face)",
87
+ boxShadow: "var(--knob-shadow)",
88
+ transform: `translateX(${isOn ? (pressed ? 16 : 20) : 0}px) translateY(-50%)`,
89
+ transition:
90
+ "transform 260ms var(--ease-spring), width 200ms var(--ease-out-soft)",
91
+ }}
92
+ />
93
+ </button>
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useCallback, useEffect, useId } from "react";
4
+ import { ENTER_FROM, LAYER, ORIGIN, Portal, useAnchoredPosition } from "./Floating";
5
+
6
+ type TooltipPosition = "top" | "bottom" | "left" | "right";
7
+
8
+ interface TooltipProps {
9
+ content: string;
10
+ position?: TooltipPosition;
11
+ children: React.ReactNode;
12
+ className?: string;
13
+ }
14
+
15
+ export function Tooltip({
16
+ content,
17
+ position = "top",
18
+ children,
19
+ className = "",
20
+ }: TooltipProps) {
21
+ const [open, setOpen] = useState(false);
22
+ const [visible, setVisible] = useState(false);
23
+ const [mounted, setMounted] = useState(false);
24
+ const triggerRef = useRef<HTMLDivElement>(null);
25
+ const enterTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
26
+ const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
27
+ const autoHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
28
+ const touchActiveRef = useRef(false);
29
+ const uid = useId();
30
+
31
+ const hide = useCallback(() => {
32
+ if (enterTimer.current) clearTimeout(enterTimer.current);
33
+ if (autoHideTimer.current) clearTimeout(autoHideTimer.current);
34
+ setVisible(false);
35
+ exitTimer.current = setTimeout(() => {
36
+ setMounted(false);
37
+ setOpen(false);
38
+ }, 150);
39
+ }, []);
40
+
41
+ const show = useCallback((fromTouch = false) => {
42
+ if (exitTimer.current) clearTimeout(exitTimer.current);
43
+ if (autoHideTimer.current) clearTimeout(autoHideTimer.current);
44
+ enterTimer.current = setTimeout(() => setOpen(true), fromTouch ? 0 : 80);
45
+ if (fromTouch) {
46
+ autoHideTimer.current = setTimeout(() => {
47
+ touchActiveRef.current = false;
48
+ hide();
49
+ }, 2400);
50
+ }
51
+ }, [hide]);
52
+
53
+ // Mounted during render; the effect only waits for it to paint
54
+ if (open && !mounted) setMounted(true);
55
+
56
+ useEffect(() => {
57
+ if (open) {
58
+ requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
59
+ }
60
+ }, [open]);
61
+
62
+ /* Portalled to the body and placed before its first paint, flipping
63
+ to the other side when there's no room */
64
+ const { setFloating, side } = useAnchoredPosition(triggerRef, { open: mounted, side: position, align: "center", gap: 7 });
65
+
66
+ useEffect(() => {
67
+ if (!open) return;
68
+ const onDocPointer = (e: PointerEvent) => {
69
+ if (!triggerRef.current?.contains(e.target as Node)) {
70
+ touchActiveRef.current = false;
71
+ hide();
72
+ }
73
+ };
74
+ document.addEventListener("pointerdown", onDocPointer);
75
+ return () => document.removeEventListener("pointerdown", onDocPointer);
76
+ }, [open, hide]);
77
+
78
+ // Escape dismisses without moving focus (WCAG 1.4.13)
79
+ useEffect(() => {
80
+ if (!open) return;
81
+ const onKey = (e: KeyboardEvent) => {
82
+ if (e.key === "Escape") hide();
83
+ };
84
+ document.addEventListener("keydown", onKey);
85
+ return () => document.removeEventListener("keydown", onKey);
86
+ }, [open, hide]);
87
+
88
+ useEffect(() => {
89
+ return () => {
90
+ if (enterTimer.current) clearTimeout(enterTimer.current);
91
+ if (exitTimer.current) clearTimeout(exitTimer.current);
92
+ if (autoHideTimer.current) clearTimeout(autoHideTimer.current);
93
+ };
94
+ }, []);
95
+
96
+ const handlePointerEnter = (e: React.PointerEvent) => {
97
+ if (e.pointerType === "touch") return;
98
+ show(false);
99
+ };
100
+
101
+ const handlePointerLeave = (e: React.PointerEvent) => {
102
+ if (e.pointerType === "touch") return;
103
+ if (touchActiveRef.current) return;
104
+ hide();
105
+ };
106
+
107
+ const handlePointerDown = (e: React.PointerEvent) => {
108
+ if (e.pointerType !== "touch") return;
109
+ touchActiveRef.current = true;
110
+ if (open) {
111
+ hide();
112
+ } else {
113
+ show(true);
114
+ }
115
+ };
116
+
117
+ return (
118
+ <div
119
+ ref={triggerRef}
120
+ className={["relative inline-flex", className].join(" ")}
121
+ onPointerEnter={handlePointerEnter}
122
+ onPointerLeave={handlePointerLeave}
123
+ onPointerDown={handlePointerDown}
124
+ onFocus={() => show(false)}
125
+ onBlur={() => {
126
+ if (!touchActiveRef.current) hide();
127
+ }}
128
+ >
129
+ {React.isValidElement(children)
130
+ ? React.cloneElement(children as React.ReactElement<Record<string, unknown>>, {
131
+ "aria-describedby": mounted ? `${uid}-tip` : undefined,
132
+ })
133
+ : children}
134
+ {mounted && (
135
+ <Portal>
136
+ <div
137
+ ref={setFloating}
138
+ id={`${uid}-tip`}
139
+ role="tooltip"
140
+ className="px-2.5 py-1.5 rounded-[9px] text-ink-inverse text-[11.5px] font-medium whitespace-nowrap pointer-events-none transition-[opacity,transform] duration-150 ease-[var(--ease-spring)]"
141
+ style={{
142
+ position: "fixed",
143
+ top: 0,
144
+ left: 0,
145
+ zIndex: LAYER.tooltip,
146
+ transformOrigin: ORIGIN[side],
147
+ opacity: visible ? 1 : 0,
148
+ transform: visible ? "none" : `${ENTER_FROM[side]} scale(0.95)`,
149
+ background: "var(--face-inverse)",
150
+ boxShadow: "var(--tooltip-shadow)",
151
+ textShadow: "var(--emboss-inverse)",
152
+ }}
153
+ >
154
+ {content}
155
+ </div>
156
+ </Portal>
157
+ )}
158
+ </div>
159
+ );
160
+ }