@enigmax/primitives 0.20.0 → 0.22.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.
- package/dist/chunk-WPTBURIC.js +1627 -0
- package/dist/{index-dTdAbOWl.d.ts → index-BHpOZncw.d.ts} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/next/index.d.ts +5 -5
- package/dist/next/index.js +1 -1
- package/dist/react/button.d.ts +2 -2
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +1 -1
- package/dist/react/input.d.ts +2 -2
- package/dist/react/palette.d.ts +1 -1
- package/dist/react/search.d.ts +1 -1
- package/dist/react/slot.d.ts +2 -2
- package/dist/react/toast.d.ts +168 -34
- package/dist/react/toast.js +2 -2
- package/dist/react-router/index.d.ts +5 -5
- package/dist/react-router/index.js +1 -1
- package/dist/search/index.d.ts +2 -2
- package/dist/{search-DXxY8SEH.d.ts → search-BD9-5O5U.d.ts} +7 -2
- package/package.json +1 -1
- package/recipes/toast/styles.css +697 -128
- package/registry.json +79 -26
- package/src/core/search.ts +6 -1
- package/src/react/index.ts +2 -1
- package/src/react/toast/NOTICE +20 -0
- package/src/react/toast/assets.tsx +85 -0
- package/src/react/toast/cn.ts +10 -0
- package/src/react/toast/hooks.ts +13 -0
- package/src/react/toast/index.tsx +736 -0
- package/src/react/toast/state.ts +207 -0
- package/src/react/toast/styles.ts +738 -0
- package/src/react/toast/types.ts +193 -0
- package/src/react/toaster.tsx +97 -209
- package/dist/chunk-ZCUFYBPB.js +0 -154
|
@@ -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 };
|