@enigmax/primitives 0.21.0 → 0.23.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 (83) hide show
  1. package/dist/chunk-3BQVOOAM.js +388 -0
  2. package/dist/{chunk-QQFNAKMY.js → chunk-45UHLZYT.js} +1 -1
  3. package/dist/chunk-4VUHQFAT.js +130 -0
  4. package/dist/{chunk-D5A2ZMAG.js → chunk-4ZPMP47J.js} +6 -1
  5. package/dist/chunk-DSBYVA7V.js +713 -0
  6. package/dist/{chunk-FWVWX67R.js → chunk-FGBIZDV2.js} +3 -3
  7. package/dist/{chunk-R4ZAEE7V.js → chunk-KEVZ5XQV.js} +1 -1
  8. package/dist/chunk-N6PDHMAX.js +213 -0
  9. package/dist/chunk-NONGREXC.js +248 -0
  10. package/dist/chunk-VKL3DEIQ.js +804 -0
  11. package/dist/chunk-WPTBURIC.js +1627 -0
  12. package/dist/chunk-WSQC3PCC.js +466 -0
  13. package/dist/context-menu-D3FtTn7v.d.ts +174 -0
  14. package/dist/{index-dTdAbOWl.d.ts → index-DQNnohoo.d.ts} +1 -1
  15. package/dist/index.d.ts +5 -1
  16. package/dist/index.js +8 -4
  17. package/dist/keys-D2zJs1uB.d.ts +100 -0
  18. package/dist/next/index.d.ts +12 -5
  19. package/dist/next/index.js +21 -14
  20. package/dist/react/button.d.ts +2 -2
  21. package/dist/react/context-menu.d.ts +202 -0
  22. package/dist/react/context-menu.js +6 -0
  23. package/dist/react/index.d.ts +11 -4
  24. package/dist/react/index.js +20 -13
  25. package/dist/react/input.d.ts +2 -2
  26. package/dist/react/input.js +1 -1
  27. package/dist/react/palette.d.ts +1 -1
  28. package/dist/react/palette.js +3 -3
  29. package/dist/react/search.d.ts +1 -1
  30. package/dist/react/search.js +2 -2
  31. package/dist/react/select.d.ts +217 -0
  32. package/dist/react/select.js +7 -0
  33. package/dist/react/selection.d.ts +104 -0
  34. package/dist/react/selection.js +4 -0
  35. package/dist/react/slot.d.ts +2 -2
  36. package/dist/react/toast.d.ts +165 -32
  37. package/dist/react/toast.js +1 -1
  38. package/dist/react-router/index.d.ts +12 -5
  39. package/dist/react-router/index.js +21 -14
  40. package/dist/search/index.d.ts +2 -2
  41. package/dist/search/index.js +1 -1
  42. package/dist/{search-DXxY8SEH.d.ts → search-DYgqRp37.d.ts} +20 -3
  43. package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
  44. package/dist/select-ClSy-J1f.d.ts +100 -0
  45. package/dist/selection-B_pmzHpy.d.ts +150 -0
  46. package/package.json +21 -2
  47. package/recipes/context-menu/styles.css +177 -0
  48. package/recipes/input/styles.css +9 -0
  49. package/recipes/palette/styles.css +3 -0
  50. package/recipes/search/tailwind.tsx +3 -2
  51. package/recipes/select/styles.css +230 -0
  52. package/recipes/toast/styles.css +688 -192
  53. package/registry.json +434 -29
  54. package/src/core/context-menu.ts +694 -0
  55. package/src/core/keys.ts +264 -0
  56. package/src/core/search.ts +25 -1
  57. package/src/core/select.ts +404 -0
  58. package/src/core/selection.ts +648 -0
  59. package/src/index.ts +60 -1
  60. package/src/react/context-menu/context.ts +57 -0
  61. package/src/react/context-menu/index.tsx +94 -0
  62. package/src/react/context-menu/root.tsx +846 -0
  63. package/src/react/context-menu/styles.ts +186 -0
  64. package/src/react/index.ts +70 -2
  65. package/src/react/palette/root.tsx +2 -2
  66. package/src/react/select/context.ts +56 -0
  67. package/src/react/select/index.tsx +96 -0
  68. package/src/react/select/root.tsx +839 -0
  69. package/src/react/select/styles.ts +238 -0
  70. package/src/react/selection/index.tsx +115 -0
  71. package/src/react/selection/use-selection.ts +260 -0
  72. package/src/react/toast/NOTICE +20 -0
  73. package/src/react/toast/assets.tsx +85 -0
  74. package/src/react/toast/cn.ts +10 -0
  75. package/src/react/toast/hooks.ts +13 -0
  76. package/src/react/toast/index.tsx +736 -0
  77. package/src/react/toast/state.ts +207 -0
  78. package/src/react/toast/styles.ts +738 -0
  79. package/src/react/toast/types.ts +193 -0
  80. package/src/react/toaster.tsx +76 -341
  81. package/dist/chunk-JCCL7XKC.js +0 -465
  82. package/src/react/toast-styles.ts +0 -247
  83. /package/dist/{chunk-U3V4EHOB.js → chunk-3HDEZ2E7.js} +0 -0
@@ -0,0 +1,736 @@
1
+ "use client";
2
+
3
+ import { cn } from "./cn";
4
+ import ReactDOM from "react-dom";
5
+ import { toast, ToastState } from "./state";
6
+ import { useIsDocumentHidden } from "./hooks";
7
+ import { CloseIcon, getAsset, Loader } from "./assets";
8
+ import React, { forwardRef, isValidElement } from "react";
9
+ import { isAction, SwipeDirection, type ExternalToast, type HeightT, type ToasterProps, type ToastProps, type ToastT, type ToastToDismiss } from "./types";
10
+
11
+ // Visible toasts amount.
12
+ const VISIBLE_TOASTS_AMOUNT = 3;
13
+
14
+ // Viewport padding.
15
+ const VIEWPORT_OFFSET = "32px";
16
+
17
+ // Mobile viewport padding.
18
+ const MOBILE_VIEWPORT_OFFSET = "16px";
19
+
20
+ // Default lifetime of a toasts (in ms).
21
+ const TOAST_LIFETIME = 4000;
22
+
23
+ // Default toast width.
24
+ const TOAST_WIDTH = 356;
25
+
26
+ // Default gap between toasts.
27
+ const GAP = 14;
28
+
29
+ // Threshold to dismiss a toast.
30
+ const SWIPE_THRESHOLD = 20;
31
+
32
+ // Equal to exit animation duration.
33
+ const TIME_BEFORE_UNMOUNT = 200;
34
+
35
+ function getDefaultSwipeDirections(position: string): Array<SwipeDirection> {
36
+ const [y, x] = position.split("-");
37
+ const directions: Array<SwipeDirection> = [];
38
+ if (y) directions.push(y as SwipeDirection);
39
+ if (x) directions.push(x as SwipeDirection);
40
+ return directions;
41
+ };
42
+
43
+ const Toast = (props: ToastProps) => {
44
+ const {
45
+ invert: ToasterInvert,
46
+ toast,
47
+ unstyled,
48
+ interacting,
49
+ setHeights,
50
+ visibleToasts,
51
+ heights,
52
+ index,
53
+ toasts,
54
+ expanded,
55
+ removeToast,
56
+ defaultRichColors,
57
+ closeButton: closeButtonFromToaster,
58
+ style,
59
+ cancelButtonStyle,
60
+ actionButtonStyle,
61
+ className = "",
62
+ descriptionClassName = "",
63
+ duration: durationFromToaster,
64
+ position,
65
+ gap,
66
+ loadingIcon: loadingIconProp,
67
+ expandByDefault,
68
+ classNames,
69
+ icons,
70
+ closeButtonAriaLabel = "Close toast",
71
+ pauseWhenPageIsHidden
72
+ } = props;
73
+ const [swipeDirection, setSwipeDirection] = React.useState<"x" | "y" | null>(null);
74
+ const [swipeOutDirection, setSwipeOutDirection] = React.useState<"left" | "right" | "up" | "down" | null>(null);
75
+ const [mounted, setMounted] = React.useState(false);
76
+ const [removed, setRemoved] = React.useState(false);
77
+ const [swiping, setSwiping] = React.useState(false);
78
+ const [swipeOut, setSwipeOut] = React.useState(false);
79
+ const [isSwiped, setIsSwiped] = React.useState(false);
80
+ const [offsetBeforeRemove, setOffsetBeforeRemove] = React.useState(0);
81
+ const [initialHeight, setInitialHeight] = React.useState(0);
82
+ const remainingTime = React.useRef(toast.duration || durationFromToaster || TOAST_LIFETIME);
83
+ const dragStartTime = React.useRef<Date | null>(null);
84
+ const toastRef = React.useRef<HTMLLIElement>(null);
85
+ const isFront = index === 0;
86
+ const isVisible = index + 1 <= visibleToasts;
87
+ const toastType = toast.type;
88
+ const dismissible = toast.dismissible !== false;
89
+ const toastClassname = toast.className || "";
90
+ const toastDescriptionClassname = toast.descriptionClassName || "";
91
+ // Height index is used to calculate the offset as it gets updated before the toast array, which means we can calculate the new layout faster.
92
+ const heightIndex = React.useMemo(() => heights.findIndex((height) => height.toastId === toast.id) || 0, [heights, toast.id]);
93
+ const closeButton = React.useMemo(() => toast.closeButton ?? closeButtonFromToaster, [toast.closeButton, closeButtonFromToaster]);
94
+ const duration = React.useMemo(() => toast.duration || durationFromToaster || TOAST_LIFETIME, [toast.duration, durationFromToaster]);
95
+ const closeTimerStartTimeRef = React.useRef(0);
96
+ const offset = React.useRef(0);
97
+ const lastCloseTimerStartTimeRef = React.useRef(0);
98
+ const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
99
+ const [y, x] = position.split("-");
100
+ const toastsHeightBefore = React.useMemo(() => {
101
+ return heights.reduce((prev, curr, reducerIndex) => {
102
+ // Calculate offset up until current toast.
103
+ if (reducerIndex >= heightIndex) return prev;
104
+ return prev + curr.height;
105
+ }, 0);
106
+ }, [heights, heightIndex]);
107
+ const isDocumentHidden = useIsDocumentHidden();
108
+
109
+ const invert = toast.invert || ToasterInvert;
110
+ const disabled = toastType === "loading";
111
+
112
+ offset.current = React.useMemo(() => heightIndex * (gap ?? 0) + toastsHeightBefore, [heightIndex, gap, toastsHeightBefore]);
113
+
114
+ React.useEffect(() => {
115
+ remainingTime.current = duration;
116
+ }, [duration]);
117
+
118
+ React.useEffect(() => setMounted(true), []);
119
+
120
+ React.useEffect(() => {
121
+ const toastNode = toastRef.current;
122
+ if (toastNode) {
123
+ const height = toastNode.getBoundingClientRect().height;
124
+ // Add toast height to heights array after the toast is mounted.
125
+ setInitialHeight(height);
126
+ //@ts-ignore
127
+ setHeights((h) => [{ toastId: toast.id, height, position: toast.position }, ...h]);
128
+ return () => setHeights((h) => h.filter((height) => height.toastId !== toast.id));
129
+ }
130
+ }, [setHeights, toast.id]);
131
+
132
+ React.useLayoutEffect(() => {
133
+ if (!mounted) return;
134
+ const toastNode = toastRef.current;
135
+ const originalHeight = toastNode?.style.height || "";
136
+ if (toastNode) toastNode.style.height = "auto";
137
+ const newHeight = toastNode?.getBoundingClientRect().height;
138
+ if (toastNode) toastNode.style.height = originalHeight;
139
+ //@ts-ignore
140
+ setInitialHeight(newHeight);
141
+ //@ts-ignore
142
+ setHeights((heights) => {
143
+ const alreadyExists = heights.find((height) => height.toastId === toast.id);
144
+ if (!alreadyExists) return [{ toastId: toast.id, height: newHeight, position: toast.position }, ...heights];
145
+ else return heights.map((height) => (height.toastId === toast.id ? { ...height, height: newHeight } : height));
146
+ });
147
+ }, [mounted, toast.title, toast.description, setHeights, toast.id]);
148
+
149
+ const deleteToast = React.useCallback(() => {
150
+ // Save the offset for the exit swipe animation.
151
+ setRemoved(true);
152
+ setOffsetBeforeRemove(offset.current);
153
+ setHeights((h) => h.filter((height) => height.toastId !== toast.id));
154
+
155
+ setTimeout(() => removeToast(toast), TIME_BEFORE_UNMOUNT);
156
+ }, [toast, removeToast, setHeights, offset]);
157
+
158
+ React.useEffect(() => {
159
+ if ((toast.promise && toastType === "loading") || toast.duration === Infinity || toast.type === "loading") return;
160
+ let timeoutId: NodeJS.Timeout;
161
+
162
+ // Pause the timer on each hover.
163
+ const pauseTimer = () => {
164
+ if (lastCloseTimerStartTimeRef.current < closeTimerStartTimeRef.current) remainingTime.current = remainingTime.current - new Date().getTime() - closeTimerStartTimeRef.current;
165
+ lastCloseTimerStartTimeRef.current = new Date().getTime();
166
+ };
167
+
168
+ const startTimer = () => {
169
+ // setTimeout(, Infinity) behaves as if the delay is 0.
170
+ // As a result, the toast would be closed immediately, giving the appearance that it was never rendered.
171
+ // See: https://github.com/denysdovhan/wtfjs?tab=readme-ov-file#an-infinite-timeout
172
+ if (remainingTime.current === Infinity) return;
173
+
174
+ closeTimerStartTimeRef.current = new Date().getTime();
175
+
176
+ // Let the toast know it has started.
177
+ timeoutId = setTimeout(() => {
178
+ toast.onAutoClose?.(toast);
179
+ deleteToast();
180
+ }, remainingTime.current);
181
+ };
182
+
183
+ if (expanded || interacting || (pauseWhenPageIsHidden && isDocumentHidden)) pauseTimer();
184
+ else startTimer();
185
+
186
+ return () => clearTimeout(timeoutId);
187
+ }, [expanded, interacting, toast, toastType, pauseWhenPageIsHidden, isDocumentHidden, deleteToast]);
188
+
189
+ React.useEffect(() => {
190
+ if (toast.delete) deleteToast();
191
+ }, [deleteToast, toast.delete]);
192
+
193
+ function getLoadingIcon() {
194
+ if (icons?.loading) {
195
+ return (
196
+ <div
197
+ className={cn(classNames?.loader, toast?.classNames?.loader, "toastcmp-loader")}
198
+ data-visible={toastType === "loading"}
199
+ >
200
+ {icons.loading}
201
+ </div>
202
+ );
203
+ }
204
+
205
+ if (loadingIconProp) {
206
+ return (
207
+ <div
208
+ className={cn(classNames?.loader, toast?.classNames?.loader, "toastcmp-loader")}
209
+ data-visible={toastType === "loading"}
210
+ >
211
+ {loadingIconProp}
212
+ </div>
213
+ );
214
+ }
215
+ return <Loader className={cn(classNames?.loader, toast?.classNames?.loader)} visible={toastType === "loading"} />;
216
+ }
217
+
218
+ return (
219
+ <li
220
+ tabIndex={0}
221
+ ref={toastRef}
222
+ className={cn(
223
+ className,
224
+ toastClassname,
225
+ classNames?.toast,
226
+ toast?.classNames?.toast,
227
+ classNames?.default,
228
+ //@ts-ignore
229
+ classNames?.[toastType],
230
+ //@ts-ignore
231
+ toast?.classNames?.[toastType]
232
+ )}
233
+ data-toastcmp-toast
234
+ data-rich-colors={toast.richColors ?? defaultRichColors ?? true}
235
+ data-styled={!Boolean(toast.jsx || toast.unstyled || unstyled)}
236
+ data-mounted={mounted}
237
+ data-promise={Boolean(toast.promise)}
238
+ data-swiped={isSwiped}
239
+ data-removed={removed}
240
+ data-visible={isVisible}
241
+ data-y-position={y}
242
+ data-x-position={x}
243
+ data-index={index}
244
+ data-front={isFront}
245
+ data-swiping={swiping}
246
+ data-dismissible={dismissible}
247
+ data-type={toastType}
248
+ data-invert={invert}
249
+ data-swipe-out={swipeOut}
250
+ data-swipe-direction={swipeOutDirection}
251
+ data-expanded={Boolean(expanded || (expandByDefault && mounted))}
252
+ style={{
253
+ "--index": index,
254
+ "--toasts-before": index,
255
+ "--z-index": toasts.length - index,
256
+ "--offset": `${removed ? offsetBeforeRemove : offset.current}px`,
257
+ "--initial-height": expandByDefault ? "auto" : `${initialHeight}px`,
258
+ ...style,
259
+ ...toast.style
260
+ } as React.CSSProperties}
261
+ onPointerDown={(event) => {
262
+ if (disabled || !dismissible) return;
263
+ dragStartTime.current = new Date();
264
+ setOffsetBeforeRemove(offset.current);
265
+ // Ensure we maintain correct pointer capture even when going outside of the toast (e.g. when swiping).
266
+ (event.target as HTMLElement).setPointerCapture(event.pointerId);
267
+ if ((event.target as HTMLElement).tagName === "BUTTON") return;
268
+ setSwiping(true);
269
+ pointerStartRef.current = { x: event.clientX, y: event.clientY };
270
+ }}
271
+ onPointerUp={() => {
272
+ if (swipeOut || !dismissible) return;
273
+
274
+ pointerStartRef.current = null;
275
+ const swipeAmountX = Number(toastRef.current?.style.getPropertyValue("--swipe-amount-x").replace("px", "") || 0);
276
+ const swipeAmountY = Number(toastRef.current?.style.getPropertyValue("--swipe-amount-y").replace("px", "") || 0);
277
+ const timeTaken = new Date().getTime() - (dragStartTime.current?.getTime() || 0);
278
+
279
+ const swipeAmount = swipeDirection === "x" ? swipeAmountX : swipeAmountY;
280
+ const velocity = Math.abs(swipeAmount) / timeTaken;
281
+
282
+ if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
283
+ setOffsetBeforeRemove(offset.current);
284
+ toast.onDismiss?.(toast);
285
+
286
+ if (swipeDirection === "x") setSwipeOutDirection(swipeAmountX > 0 ? "right" : "left");
287
+ else setSwipeOutDirection(swipeAmountY > 0 ? "down" : "up");
288
+
289
+ deleteToast();
290
+ setSwipeOut(true);
291
+ setIsSwiped(false);
292
+ return;
293
+ }
294
+
295
+ setSwiping(false);
296
+ setSwipeDirection(null);
297
+ }}
298
+ onPointerMove={(event) => {
299
+ if (!pointerStartRef.current || !dismissible) return;
300
+
301
+ const selection = window.getSelection();
302
+ const isHighlighted = selection ? selection.toString().length > 0 : false;
303
+ if (isHighlighted) return;
304
+
305
+ const yDelta = event.clientY - pointerStartRef.current.y;
306
+ const xDelta = event.clientX - pointerStartRef.current.x;
307
+
308
+ const swipeDirections = props.swipeDirections ?? getDefaultSwipeDirections(position);
309
+
310
+ // Determine swipe direction if not already locked.
311
+ if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) setSwipeDirection(Math.abs(xDelta) > Math.abs(yDelta) ? "x" : "y");
312
+
313
+ let swipeAmount = { x: 0, y: 0 };
314
+
315
+ // Only apply swipe in the locked direction.
316
+ if (swipeDirection === "y") {
317
+ // Handle vertical swipes.
318
+ if (swipeDirections.includes("top") || swipeDirections.includes("bottom")) {
319
+ if (swipeDirections.includes("top") && yDelta < 0) swipeAmount.y = yDelta;
320
+ else if (swipeDirections.includes("bottom") && yDelta > 0) swipeAmount.y = yDelta;
321
+ }
322
+ } else if (swipeDirection === "x") {
323
+ // Handle horizontal swipes.
324
+ if (swipeDirections.includes("left") || swipeDirections.includes("right")) {
325
+ if (swipeDirections.includes("left") && xDelta < 0) swipeAmount.x = xDelta;
326
+ else if (swipeDirections.includes("right") && xDelta > 0) swipeAmount.x = xDelta;
327
+ }
328
+ }
329
+
330
+ if (Math.abs(swipeAmount.x) > 0 || Math.abs(swipeAmount.y) > 0) setIsSwiped(true);
331
+
332
+ // Apply transform using both x and y values.
333
+ toastRef.current?.style.setProperty("--swipe-amount-x", `${swipeAmount.x}px`);
334
+ toastRef.current?.style.setProperty("--swipe-amount-y", `${swipeAmount.y}px`);
335
+ }}
336
+ >
337
+ {closeButton && !toast.jsx ? (
338
+ <button
339
+ aria-label={closeButtonAriaLabel}
340
+ data-disabled={disabled}
341
+ data-close-button
342
+ onClick={
343
+ disabled || !dismissible
344
+ ? () => { }
345
+ : () => {
346
+ deleteToast();
347
+ toast.onDismiss?.(toast);
348
+ }
349
+ }
350
+ className={cn(classNames?.closeButton, toast?.classNames?.closeButton)}
351
+ >
352
+ {icons?.close ?? CloseIcon}
353
+ </button>
354
+ ) : null}
355
+ {/* TODO: This can be cleaner */}
356
+ {toast.jsx || isValidElement(toast.title) ? (toast.jsx ? (toast.jsx) : typeof toast.title === "function" ? (toast.title()) : (toast.title)
357
+ ) : (
358
+ <>
359
+ {toastType || toast.icon || toast.promise ? (
360
+ <div data-icon="" className={cn(classNames?.icon, toast?.classNames?.icon)}>
361
+ {toast.promise || (toast.type === "loading" && !toast.icon) ? toast.icon || getLoadingIcon() : null}
362
+ {toast.type !== "loading" ? toast.icon || icons?.[toastType as keyof typeof icons] || getAsset(toastType ?? "default") : null}
363
+ </div>
364
+ ) : null}
365
+
366
+ <div data-content="" className={cn(classNames?.content, toast?.classNames?.content)}>
367
+ <div data-title="" className={cn(classNames?.title, toast?.classNames?.title)}>
368
+ {typeof toast.title === "function" ? toast.title() : toast.title}
369
+ </div>
370
+ {toast.description ? (
371
+ <div
372
+ data-description=""
373
+ className={cn(
374
+ descriptionClassName,
375
+ toastDescriptionClassname,
376
+ classNames?.description,
377
+ toast?.classNames?.description,
378
+ )}
379
+ >
380
+ {typeof toast.description === "function" ? toast.description() : toast.description}
381
+ </div>
382
+ ) : null}
383
+ </div>
384
+ {isValidElement(toast.cancel) ? (
385
+ toast.cancel
386
+ ) : toast.cancel && isAction(toast.cancel) ? (
387
+ <button
388
+ data-button
389
+ data-cancel
390
+ style={toast.cancelButtonStyle || cancelButtonStyle}
391
+ onClick={(event) => {
392
+ // We need to check twice because typescript.
393
+ if (!isAction(toast.cancel)) return;
394
+ if (!dismissible) return;
395
+ toast.cancel.onClick?.(event);
396
+ deleteToast();
397
+ }}
398
+ className={cn(classNames?.cancelButton, toast?.classNames?.cancelButton)}
399
+ >
400
+ {toast.cancel.label}
401
+ </button>
402
+ ) : null}
403
+ {isValidElement(toast.action) ? (
404
+ toast.action
405
+ ) : toast.action && isAction(toast.action) ? (
406
+ <button
407
+ data-button
408
+ data-action
409
+ style={toast.actionButtonStyle || actionButtonStyle}
410
+ onClick={(event) => {
411
+ // We need to check twice because typescript.
412
+ if (!isAction(toast.action)) return;
413
+ toast.action.onClick?.(event);
414
+ if (event.defaultPrevented) return;
415
+ deleteToast();
416
+ }}
417
+ className={cn(classNames?.actionButton, toast?.classNames?.actionButton)}
418
+ >
419
+ {toast.action.label}
420
+ </button>
421
+ ) : null}
422
+ </>
423
+ )}
424
+ </li>
425
+ );
426
+ };
427
+
428
+ function getDocumentDirection(): ToasterProps["dir"] {
429
+ if (typeof window === "undefined") return "ltr";
430
+ if (typeof document === "undefined") return "ltr"; // For Fresh purpose
431
+ const dirAttribute = document.documentElement.getAttribute("dir");
432
+ if (dirAttribute === "auto" || !dirAttribute) return window.getComputedStyle(document.documentElement).direction as ToasterProps["dir"];
433
+ return dirAttribute as ToasterProps["dir"];
434
+ };
435
+
436
+ function assignOffset(defaultOffset: ToasterProps["offset"], mobileOffset: ToasterProps["mobileOffset"]) {
437
+ const styles = {} as React.CSSProperties;
438
+
439
+ [defaultOffset, mobileOffset].forEach((offset, index) => {
440
+ const isMobile = index === 1;
441
+ const prefix = isMobile ? "--mobile-offset" : "--offset";
442
+ const defaultValue = isMobile ? MOBILE_VIEWPORT_OFFSET : VIEWPORT_OFFSET;
443
+ function assignAll(offset: string | number) {
444
+ ["top", "right", "bottom", "left"].forEach((key) => {
445
+ //@ts-ignore
446
+ styles[`${prefix}-${key}`] = typeof offset === "number" ? `${offset}px` : offset;
447
+ });
448
+ };
449
+ if (typeof offset === "number" || typeof offset === "string") assignAll(offset);
450
+ else if (typeof offset === "object") {
451
+ ["top", "right", "bottom", "left"].forEach((key) => {
452
+ //@ts-ignore
453
+ if (offset[key] === undefined) styles[`${prefix}-${key}`] = defaultValue;
454
+ //@ts-ignore
455
+ else styles[`${prefix}-${key}`] = typeof offset[key] === "number" ? `${offset[key]}px` : offset[key];
456
+ });
457
+ } else assignAll(defaultValue);
458
+ });
459
+ return styles;
460
+ };
461
+
462
+ function useSonner() {
463
+ const [activeToasts, setActiveToasts] = React.useState<ToastT[]>([]);
464
+
465
+ React.useEffect(() => {
466
+ return ToastState.subscribe((toast) => {
467
+ //@ts-ignore
468
+ setActiveToasts((currentToasts) => {
469
+ if ("dismiss" in toast && toast.dismiss) return currentToasts.filter((t) => t.id !== toast.id);
470
+ const existingToastIndex = currentToasts.findIndex((t) => t.id === toast.id);
471
+ if (existingToastIndex !== -1) {
472
+ const updatedToasts = [...currentToasts];
473
+ updatedToasts[existingToastIndex] = { ...updatedToasts[existingToastIndex], ...toast };
474
+ return updatedToasts;
475
+ } else return [toast, ...currentToasts];
476
+ });
477
+ });
478
+ }, []);
479
+
480
+ return {
481
+ toasts: activeToasts
482
+ };
483
+ };
484
+
485
+ const Toaster = forwardRef<HTMLElement, ToasterProps>(function Toaster(props, ref) {
486
+ const {
487
+ invert,
488
+ position = "bottom-right",
489
+ hotkey = ["altKey", "KeyT"],
490
+ expand,
491
+ closeButton,
492
+ className,
493
+ offset,
494
+ mobileOffset,
495
+ theme = "dark",
496
+ richColors,
497
+ duration = 4000,
498
+ style,
499
+ visibleToasts = VISIBLE_TOASTS_AMOUNT,
500
+ toastOptions,
501
+ dir = getDocumentDirection(),
502
+ gap = GAP,
503
+ loadingIcon,
504
+ icons,
505
+ containerAriaLabel = "Notifications",
506
+ pauseWhenPageIsHidden
507
+ } = props;
508
+ const [toasts, setToasts] = React.useState<ToastT[]>([]);
509
+ const possiblePositions = React.useMemo(() => Array.from(new Set([position, ...toasts.filter((toast) => toast.position !== undefined).map((toast) => toast.position)])), [toasts, position]);
510
+ const [heights, setHeights] = React.useState<HeightT[]>([]);
511
+ const [expanded, setExpanded] = React.useState(false);
512
+ const [interacting, setInteracting] = React.useState(false);
513
+ const [actualTheme, setActualTheme] = React.useState(theme !== "system" ? theme : typeof window !== "undefined" ? window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" : "light");
514
+ const listRef = React.useRef<HTMLOListElement>(null);
515
+ const hotkeyLabel = hotkey.join("+").replace(/Key/g, "").replace(/Digit/g, "");
516
+ const lastFocusedElementRef = React.useRef<HTMLElement>(null);
517
+ const isFocusWithinRef = React.useRef(false);
518
+
519
+ const removeToast = React.useCallback((toastToRemove: ToastT) => {
520
+ setToasts((toasts) => {
521
+ if (!toasts.find((toast) => toast.id === toastToRemove.id)?.delete) ToastState.dismiss(toastToRemove.id);
522
+ return toasts.filter(({ id }) => id !== toastToRemove.id);
523
+ });
524
+ }, []);
525
+
526
+ React.useEffect(() => {
527
+ return ToastState.subscribe((toast) => {
528
+ if ((toast as ToastToDismiss).dismiss) return setToasts((toasts) => toasts.map((t) => (t.id === toast.id ? { ...t, delete: true } : t)));
529
+
530
+ // Prevent batching, temp solution.
531
+ setTimeout(() => {
532
+ ReactDOM.flushSync(() => {
533
+ //@ts-ignore
534
+ setToasts((toasts) => {
535
+ const indexOfExistingToast = toasts.findIndex((t) => t.id === toast.id);
536
+ // Update the toast if it already exists.
537
+ if (indexOfExistingToast !== -1) {
538
+ return [
539
+ ...toasts.slice(0, indexOfExistingToast),
540
+ { ...toasts[indexOfExistingToast], ...toast },
541
+ ...toasts.slice(indexOfExistingToast + 1)
542
+ ];
543
+ }
544
+ return [toast, ...toasts];
545
+ });
546
+ });
547
+ });
548
+ });
549
+ }, []);
550
+
551
+ React.useEffect(() => {
552
+ if (theme !== "system") return setActualTheme(theme);
553
+
554
+ if (theme === "system") {
555
+ // check if current preference is dark
556
+ if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
557
+ // it"s currently dark
558
+ setActualTheme("dark");
559
+ } else {
560
+ // it"s not dark
561
+ setActualTheme("light");
562
+ }
563
+ }
564
+
565
+ if (typeof window === "undefined") return;
566
+ const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
567
+
568
+ try {
569
+ // Chrome & Firefox.
570
+ darkMediaQuery.addEventListener("change", ({ matches }) => {
571
+ if (matches) setActualTheme("dark");
572
+ else setActualTheme("light");
573
+ });
574
+ } catch (error) {
575
+ // Safari < 14.
576
+ darkMediaQuery.addListener(({ matches }) => {
577
+ try {
578
+ if (matches) setActualTheme("dark");
579
+ else setActualTheme("light");
580
+ } catch (e) {
581
+ console.error(e);
582
+ }
583
+ });
584
+ }
585
+ }, [theme]);
586
+
587
+ React.useEffect(() => {
588
+ // Ensure expanded is always false when no toasts are present / only one left.
589
+ if (toasts.length <= 1) setExpanded(false);
590
+ }, [toasts]);
591
+
592
+ React.useEffect(() => {
593
+ const handleKeyDown = (event: KeyboardEvent) => {
594
+ const isHotkeyPressed = hotkey.every((key) => (event as any)[key] || event.code === key);
595
+
596
+ if (isHotkeyPressed) {
597
+ setExpanded(true);
598
+ listRef.current?.focus();
599
+ }
600
+
601
+ if (event.code === "Escape" && (document.activeElement === listRef.current || listRef.current?.contains(document.activeElement))) setExpanded(false);
602
+
603
+ };
604
+ document.addEventListener("keydown", handleKeyDown);
605
+
606
+ return () => document.removeEventListener("keydown", handleKeyDown);
607
+ }, [hotkey]);
608
+
609
+ React.useEffect(() => {
610
+ if (listRef.current) {
611
+ return () => {
612
+ if (lastFocusedElementRef.current) {
613
+ lastFocusedElementRef.current.focus({ preventScroll: true });
614
+ //@ts-ignore
615
+ lastFocusedElementRef.current = null;
616
+ isFocusWithinRef.current = false;
617
+ }
618
+ };
619
+ }
620
+ }, [listRef.current]);
621
+
622
+ return (
623
+ // Remove item from normal navigation flow, only available via hotkey.
624
+ <section
625
+ ref={ref}
626
+ aria-label={`${containerAriaLabel} ${hotkeyLabel}`}
627
+ tabIndex={-1}
628
+ aria-live="polite"
629
+ aria-relevant="additions text"
630
+ aria-atomic="false"
631
+ suppressHydrationWarning
632
+ >
633
+ {possiblePositions.map((position, index) => {
634
+ const [y, x] = (position ?? "").split("-");
635
+ if (!toasts.length) return null;
636
+ return (
637
+ <ol
638
+ key={position}
639
+ dir={dir === "auto" ? getDocumentDirection() : dir}
640
+ tabIndex={-1}
641
+ ref={listRef}
642
+ className={className}
643
+ data-toastcmp-toaster
644
+ data-theme={actualTheme}
645
+ data-y-position={y}
646
+ data-lifted={expanded && toasts.length > 1 && !expand}
647
+ data-x-position={x}
648
+ style={
649
+ {
650
+ "--front-toast-height": `${heights[0]?.height || 0}px`,
651
+ "--width": `${TOAST_WIDTH}px`,
652
+ "--gap": `${gap}px`,
653
+ ...style,
654
+ ...assignOffset(offset, mobileOffset)
655
+ } as React.CSSProperties
656
+ }
657
+ onBlur={(event) => {
658
+ if (isFocusWithinRef.current && !event.currentTarget.contains(event.relatedTarget)) {
659
+ isFocusWithinRef.current = false;
660
+ if (lastFocusedElementRef.current) {
661
+ lastFocusedElementRef.current.focus({ preventScroll: true });
662
+ //@ts-ignore
663
+ lastFocusedElementRef.current = null;
664
+ }
665
+ }
666
+ }}
667
+ onFocus={(event) => {
668
+ const isNotDismissible = event.target instanceof HTMLElement && event.target.dataset.dismissible === "false";
669
+ if (isNotDismissible) return;
670
+ if (!isFocusWithinRef.current) {
671
+ isFocusWithinRef.current = true;
672
+ //@ts-ignore
673
+ lastFocusedElementRef.current = event.relatedTarget as HTMLElement;
674
+ }
675
+ }}
676
+ onMouseEnter={() => setExpanded(true)}
677
+ onMouseMove={() => setExpanded(true)}
678
+ onMouseLeave={() => {
679
+ // Avoid setting expanded to false when interacting with a toast, e.g. swiping.
680
+ if (!interacting) setExpanded(false);
681
+ }}
682
+ onPointerDown={(event) => {
683
+ const isNotDismissible = event.target instanceof HTMLElement && event.target.dataset.dismissible === "false";
684
+ if (isNotDismissible) return;
685
+ setInteracting(true);
686
+ }}
687
+ onPointerUp={() => setInteracting(false)}
688
+ >
689
+ {toasts
690
+ .filter((toast) => (!toast.position && index === 0) || toast.position === position)
691
+ .map((toast, index) => (
692
+ <Toast
693
+ key={toast.id}
694
+ icons={icons}
695
+ index={index}
696
+ toast={toast}
697
+ defaultRichColors={richColors}
698
+ duration={toastOptions?.duration ?? duration}
699
+ className={toastOptions?.className}
700
+ descriptionClassName={toastOptions?.descriptionClassName}
701
+ //@ts-ignore
702
+ invert={invert}
703
+ visibleToasts={visibleToasts}
704
+ //@ts-ignore
705
+ closeButton={toastOptions?.closeButton ?? closeButton}
706
+ interacting={interacting}
707
+ //@ts-ignore
708
+ position={position}
709
+ style={toastOptions?.style}
710
+ unstyled={toastOptions?.unstyled}
711
+ classNames={toastOptions?.classNames}
712
+ cancelButtonStyle={toastOptions?.cancelButtonStyle}
713
+ actionButtonStyle={toastOptions?.actionButtonStyle}
714
+ removeToast={removeToast}
715
+ toasts={toasts.filter((t) => t.position == toast.position)}
716
+ heights={heights.filter((h) => h.position == toast.position)}
717
+ setHeights={setHeights}
718
+ //@ts-ignore
719
+ expandByDefault={expand}
720
+ gap={gap}
721
+ loadingIcon={loadingIcon}
722
+ expanded={expanded}
723
+ //@ts-ignore
724
+ pauseWhenPageIsHidden={pauseWhenPageIsHidden}
725
+ swipeDirections={props.swipeDirections}
726
+ />
727
+ ))}
728
+ </ol>
729
+ );
730
+ })}
731
+ </section>
732
+ );
733
+ });
734
+
735
+ export { type ToastClassnames, type ToastToDismiss, type Action } from "./types";
736
+ export { toast, Toaster, type ExternalToast, type ToastT, type ToasterProps, useSonner };