@octanejs/sonner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,831 @@
1
+ // Ported from sonner@2.0.7 src/index.tsx
2
+ // (https://github.com/emilkowalski/sonner/tree/v2.0.7).
3
+ //
4
+ // Octane adaptations:
5
+ // - React hooks/isValidElement + ReactDOM.flushSync come directly from octane.
6
+ // - forwardRef becomes the normal `ref` prop in ToasterProps.
7
+ // - event handlers receive native DOM events.
8
+ import {
9
+ flushSync,
10
+ isValidElement,
11
+ useCallback,
12
+ useEffect,
13
+ useLayoutEffect,
14
+ useMemo,
15
+ useRef,
16
+ useState,
17
+ } from 'octane';
18
+ import { CloseIcon, getAsset, Loader } from './assets.tsrx';
19
+ import { useIsDocumentHidden } from './hooks.tsrx';
20
+ import { toast, ToastState } from './state';
21
+ import './styles.css';
22
+ import { isAction } from './types';
23
+ import type {
24
+ CSSProperties,
25
+ ExternalToast,
26
+ HeightT,
27
+ Position,
28
+ SwipeDirection,
29
+ ToasterProps,
30
+ ToastClassnames,
31
+ ToastIcons,
32
+ ToastProps,
33
+ ToastT,
34
+ ToastTypes,
35
+ } from './types';
36
+
37
+ const VISIBLE_TOASTS_AMOUNT = 3;
38
+ const VIEWPORT_OFFSET = '24px';
39
+ const MOBILE_VIEWPORT_OFFSET = '16px';
40
+ const TOAST_LIFETIME = 4000;
41
+ const TOAST_WIDTH = 356;
42
+ const GAP = 14;
43
+ const SWIPE_THRESHOLD = 45;
44
+ const TIME_BEFORE_UNMOUNT = 200;
45
+
46
+ function cn(...classes: (string | undefined)[]): string {
47
+ return classes.filter(Boolean).join(' ');
48
+ }
49
+
50
+ function getDefaultSwipeDirections(position: string): SwipeDirection[] {
51
+ const [y, x] = position.split('-');
52
+ const directions: SwipeDirection[] = [];
53
+ if (y) directions.push(y as SwipeDirection);
54
+ if (x) directions.push(x as SwipeDirection);
55
+ return directions;
56
+ }
57
+
58
+ function LoadingIcon(props: {
59
+ icons?: ToastIcons;
60
+ classNames?: ToastClassnames;
61
+ toastItem: ToastT;
62
+ toastType?: ToastTypes;
63
+ }) @{
64
+ @if (props.icons?.loading) {
65
+ <div
66
+ className={cn(props.classNames?.loader, props.toastItem.classNames?.loader, 'sonner-loader')}
67
+ data-visible={props.toastType === 'loading'}
68
+ >{props.icons.loading}</div>
69
+ } @else {
70
+ <Loader
71
+ className={cn(props.classNames?.loader, props.toastItem.classNames?.loader)}
72
+ visible={props.toastType === 'loading'}
73
+ />
74
+ }
75
+ }
76
+
77
+ function Toast(props: ToastProps) {
78
+ const {
79
+ invert: toasterInvert,
80
+ toast: toastItem,
81
+ unstyled,
82
+ interacting,
83
+ setHeights,
84
+ visibleToasts,
85
+ heights,
86
+ index,
87
+ toasts,
88
+ expanded,
89
+ removeToast,
90
+ defaultRichColors,
91
+ closeButton: closeButtonFromToaster,
92
+ style,
93
+ cancelButtonStyle,
94
+ actionButtonStyle,
95
+ className = '',
96
+ descriptionClassName = '',
97
+ duration: durationFromToaster,
98
+ position,
99
+ gap,
100
+ expandByDefault,
101
+ classNames,
102
+ icons,
103
+ closeButtonAriaLabel = 'Close toast',
104
+ } = props;
105
+ const [swipeDirection, setSwipeDirection] = useState<'x' | 'y' | null>(null);
106
+ const [swipeOutDirection, setSwipeOutDirection] = useState<
107
+ 'left' | 'right' | 'up' | 'down' | null
108
+ >(null);
109
+ const [mounted, setMounted] = useState(false);
110
+ const [removed, setRemoved] = useState(false);
111
+ const [swiping, setSwiping] = useState(false);
112
+ const [swipeOut, setSwipeOut] = useState(false);
113
+ const [isSwiped, setIsSwiped] = useState(false);
114
+ const [offsetBeforeRemove, setOffsetBeforeRemove] = useState(0);
115
+ const [initialHeight, setInitialHeight] = useState(0);
116
+ const remainingTime = useRef(toastItem.duration || durationFromToaster || TOAST_LIFETIME);
117
+ const dragStartTime = useRef<Date | null>(null);
118
+ const toastRef = useRef<HTMLLIElement | null>(null);
119
+ const isFront = index === 0;
120
+ const isVisible = index + 1 <= visibleToasts;
121
+ const toastType = toastItem.type;
122
+ const dismissible = toastItem.dismissible !== false;
123
+ const toastClassname = toastItem.className || '';
124
+ const toastDescriptionClassname = toastItem.descriptionClassName || '';
125
+ const heightIndex = useMemo(
126
+ () => heights.findIndex((height) => height.toastId === toastItem.id) || 0,
127
+ [heights, toastItem.id],
128
+ );
129
+ const closeButton = useMemo(() => toastItem.closeButton ?? closeButtonFromToaster, [
130
+ toastItem.closeButton,
131
+ closeButtonFromToaster,
132
+ ]);
133
+ const duration = useMemo(() => toastItem.duration || durationFromToaster || TOAST_LIFETIME, [
134
+ toastItem.duration,
135
+ durationFromToaster,
136
+ ]);
137
+ const closeTimerStartTimeRef = useRef(0);
138
+ const offset = useRef(0);
139
+ const lastCloseTimerStartTimeRef = useRef(0);
140
+ const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
141
+ const [y, x] = position.split('-');
142
+ const toastsHeightBefore = useMemo(
143
+ () => heights.reduce((previous, current, reducerIndex) => {
144
+ if (reducerIndex >= heightIndex) return previous;
145
+ return previous + current.height;
146
+ }, 0),
147
+ [heights, heightIndex],
148
+ );
149
+ const isDocumentHidden = useIsDocumentHidden();
150
+ const invert = toastItem.invert || toasterInvert;
151
+ const disabled = toastType === 'loading';
152
+
153
+ offset.current = useMemo(() => heightIndex * gap + toastsHeightBefore, [
154
+ heightIndex,
155
+ gap,
156
+ toastsHeightBefore,
157
+ ]);
158
+
159
+ useEffect(() => {
160
+ remainingTime.current = duration;
161
+ }, [duration]);
162
+
163
+ useEffect(() => {
164
+ setMounted(true);
165
+ }, []);
166
+
167
+ useEffect(() => {
168
+ const toastNode = toastRef.current;
169
+ if (!toastNode) return;
170
+ const height = toastNode.getBoundingClientRect().height;
171
+ setInitialHeight(height);
172
+ setHeights(
173
+ (current) => [
174
+ { toastId: toastItem.id, height, position: toastItem.position },
175
+ ...current,
176
+ ],
177
+ );
178
+ return () => setHeights(
179
+ (current) => current.filter((heightItem) => heightItem.toastId !== toastItem.id),
180
+ );
181
+ }, [setHeights, toastItem.id]);
182
+
183
+ useLayoutEffect(() => {
184
+ if (!mounted || !toastRef.current) return;
185
+ const toastNode = toastRef.current;
186
+ const originalHeight = toastNode.style.height;
187
+ toastNode.style.height = 'auto';
188
+ const newHeight = toastNode.getBoundingClientRect().height;
189
+ toastNode.style.height = originalHeight;
190
+ setInitialHeight(newHeight);
191
+ setHeights((current) => {
192
+ const alreadyExists = current.find((height) => height.toastId === toastItem.id);
193
+ if (!alreadyExists) {
194
+ return [
195
+ { toastId: toastItem.id, height: newHeight, position: toastItem.position },
196
+ ...current,
197
+ ];
198
+ }
199
+ return current.map(
200
+ (height) => height.toastId === toastItem.id ? { ...height, height: newHeight } : height,
201
+ );
202
+ });
203
+ }, [
204
+ mounted,
205
+ toastItem.title,
206
+ toastItem.description,
207
+ setHeights,
208
+ toastItem.id,
209
+ toastItem.jsx,
210
+ toastItem.action,
211
+ toastItem.cancel,
212
+ ]);
213
+
214
+ const deleteToast = useCallback(() => {
215
+ setRemoved(true);
216
+ setOffsetBeforeRemove(offset.current);
217
+ setHeights((current) => current.filter((height) => height.toastId !== toastItem.id));
218
+ setTimeout(() => removeToast(toastItem), TIME_BEFORE_UNMOUNT);
219
+ }, [toastItem, removeToast, setHeights, offset]);
220
+
221
+ useEffect(() => {
222
+ if (
223
+ toastItem.promise && toastType === 'loading' || toastItem.duration === Infinity ||
224
+ toastItem.type === 'loading'
225
+ ) {
226
+ return;
227
+ }
228
+ let timeoutId: ReturnType<typeof setTimeout>;
229
+
230
+ const pauseTimer = (): void => {
231
+ if (lastCloseTimerStartTimeRef.current < closeTimerStartTimeRef.current) {
232
+ const elapsedTime = Date.now() - closeTimerStartTimeRef.current;
233
+ remainingTime.current -= elapsedTime;
234
+ }
235
+ lastCloseTimerStartTimeRef.current = Date.now();
236
+ };
237
+
238
+ const startTimer = (): void => {
239
+ if (remainingTime.current === Infinity) return;
240
+ closeTimerStartTimeRef.current = Date.now();
241
+ timeoutId = setTimeout(() => {
242
+ toastItem.onAutoClose?.(toastItem);
243
+ deleteToast();
244
+ }, remainingTime.current);
245
+ };
246
+
247
+ if (expanded || interacting || isDocumentHidden) pauseTimer();
248
+ else startTimer();
249
+
250
+ return () => clearTimeout(timeoutId);
251
+ }, [expanded, interacting, toastItem, toastType, isDocumentHidden, deleteToast]);
252
+
253
+ useEffect(() => {
254
+ if (toastItem.delete) {
255
+ deleteToast();
256
+ toastItem.onDismiss?.(toastItem);
257
+ }
258
+ }, [deleteToast, toastItem.delete]);
259
+
260
+ const icon =
261
+ toastItem.icon || (toastType ? icons?.[toastType] : undefined) || getAsset(toastType);
262
+ const toasterTypeClass = toastType ? classNames?.[toastType] : undefined;
263
+ const toastTypeClass = toastType ? toastItem.classNames?.[toastType] : undefined;
264
+
265
+ return <li
266
+ tabIndex={0}
267
+ ref={toastRef}
268
+ className={cn(
269
+ className,
270
+ toastClassname,
271
+ classNames?.toast,
272
+ toastItem.classNames?.toast,
273
+ classNames?.default,
274
+ toasterTypeClass,
275
+ toastTypeClass,
276
+ )}
277
+ data-sonner-toast=""
278
+ data-rich-colors={toastItem.richColors ?? defaultRichColors}
279
+ data-styled={!Boolean(toastItem.jsx || toastItem.unstyled || unstyled)}
280
+ data-mounted={mounted}
281
+ data-promise={Boolean(toastItem.promise)}
282
+ data-swiped={isSwiped}
283
+ data-removed={removed}
284
+ data-visible={isVisible}
285
+ data-y-position={y}
286
+ data-x-position={x}
287
+ data-index={index}
288
+ data-front={isFront}
289
+ data-swiping={swiping}
290
+ data-dismissible={dismissible}
291
+ data-type={toastType}
292
+ data-invert={invert}
293
+ data-swipe-out={swipeOut}
294
+ data-swipe-direction={swipeOutDirection}
295
+ data-expanded={Boolean(expanded || expandByDefault && mounted)}
296
+ data-testid={toastItem.testId}
297
+ style={{
298
+ '--index': index,
299
+ '--toasts-before': index,
300
+ '--z-index': toasts.length - index,
301
+ '--offset': `${removed ? offsetBeforeRemove : offset.current}px`,
302
+ '--initial-height': expandByDefault ? 'auto' : `${initialHeight}px`,
303
+ ...style,
304
+ ...toastItem.style,
305
+ }}
306
+ onDragEnd={() => {
307
+ setSwiping(false);
308
+ setSwipeDirection(null);
309
+ pointerStartRef.current = null;
310
+ }}
311
+ onPointerDown={(event: PointerEvent) => {
312
+ if (event.button === 2 || disabled || !dismissible) return;
313
+ dragStartTime.current = new Date();
314
+ setOffsetBeforeRemove(offset.current);
315
+ (event.target as HTMLElement).setPointerCapture(event.pointerId);
316
+ if ((event.target as HTMLElement).tagName === 'BUTTON') return;
317
+ setSwiping(true);
318
+ pointerStartRef.current = { x: event.clientX, y: event.clientY };
319
+ }}
320
+ onPointerUp={() => {
321
+ if (swipeOut || !dismissible) return;
322
+ pointerStartRef.current = null;
323
+ const swipeAmountX = Number(
324
+ toastRef.current?.style.getPropertyValue('--swipe-amount-x').replace('px', '') || 0,
325
+ );
326
+ const swipeAmountY = Number(
327
+ toastRef.current?.style.getPropertyValue('--swipe-amount-y').replace('px', '') || 0,
328
+ );
329
+ const timeTaken = Date.now() - (dragStartTime.current?.getTime() ?? 0);
330
+ const swipeAmount =
331
+ swipeDirection === 'x' ? swipeAmountX : swipeAmountY;
332
+ const velocity = Math.abs(swipeAmount) / timeTaken;
333
+
334
+ if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
335
+ setOffsetBeforeRemove(offset.current);
336
+ toastItem.onDismiss?.(toastItem);
337
+ if (swipeDirection === 'x') {
338
+ setSwipeOutDirection(swipeAmountX > 0 ? 'right' : 'left');
339
+ } else {
340
+ setSwipeOutDirection(swipeAmountY > 0 ? 'down' : 'up');
341
+ }
342
+ deleteToast();
343
+ setSwipeOut(true);
344
+ return;
345
+ }
346
+
347
+ toastRef.current?.style.setProperty('--swipe-amount-x', '0px');
348
+ toastRef.current?.style.setProperty('--swipe-amount-y', '0px');
349
+ setIsSwiped(false);
350
+ setSwiping(false);
351
+ setSwipeDirection(null);
352
+ }}
353
+ onPointerMove={(event: PointerEvent) => {
354
+ if (!pointerStartRef.current || !dismissible) return;
355
+ if ((window.getSelection()?.toString().length ?? 0) > 0) return;
356
+ const yDelta = event.clientY - pointerStartRef.current.y;
357
+ const xDelta = event.clientX - pointerStartRef.current.x;
358
+ const swipeDirections = props.swipeDirections ?? getDefaultSwipeDirections(position);
359
+
360
+ if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) {
361
+ setSwipeDirection(Math.abs(xDelta) > Math.abs(yDelta) ? 'x' : 'y');
362
+ }
363
+
364
+ const swipeAmount = { x: 0, y: 0 };
365
+ const getDampening = (delta: number): number => {
366
+ const factor = Math.abs(delta) / 20;
367
+ return 1 / (1.5 + factor);
368
+ };
369
+
370
+ if (swipeDirection === 'y') {
371
+ if (swipeDirections.includes('top') || swipeDirections.includes('bottom')) {
372
+ if (
373
+ swipeDirections.includes('top') && yDelta < 0 ||
374
+ swipeDirections.includes('bottom') && yDelta > 0
375
+ ) {
376
+ swipeAmount.y = yDelta;
377
+ } else {
378
+ const dampenedDelta = yDelta * getDampening(yDelta);
379
+ swipeAmount.y = Math.abs(dampenedDelta) < Math.abs(yDelta) ? dampenedDelta : yDelta;
380
+ }
381
+ }
382
+ } else if (swipeDirection === 'x') {
383
+ if (swipeDirections.includes('left') || swipeDirections.includes('right')) {
384
+ if (
385
+ swipeDirections.includes('left') && xDelta < 0 ||
386
+ swipeDirections.includes('right') && xDelta > 0
387
+ ) {
388
+ swipeAmount.x = xDelta;
389
+ } else {
390
+ const dampenedDelta = xDelta * getDampening(xDelta);
391
+ swipeAmount.x = Math.abs(dampenedDelta) < Math.abs(xDelta) ? dampenedDelta : xDelta;
392
+ }
393
+ }
394
+ }
395
+
396
+ if (Math.abs(swipeAmount.x) > 0 || Math.abs(swipeAmount.y) > 0) {
397
+ setIsSwiped(true);
398
+ }
399
+ toastRef.current?.style.setProperty('--swipe-amount-x', `${swipeAmount.x}px`);
400
+ toastRef.current?.style.setProperty('--swipe-amount-y', `${swipeAmount.y}px`);
401
+ }}
402
+ >
403
+ {closeButton && !toastItem.jsx && toastType !== 'loading'
404
+ ? <button
405
+ aria-label={closeButtonAriaLabel}
406
+ data-disabled={disabled}
407
+ data-close-button="true"
408
+ onClick={disabled || !dismissible
409
+ ? () => {}
410
+ : () => {
411
+ deleteToast();
412
+ toastItem.onDismiss?.(toastItem);
413
+ }}
414
+ className={cn(classNames?.closeButton, toastItem.classNames?.closeButton)}
415
+ >
416
+ {icons?.close ?? CloseIcon}
417
+ </button>
418
+ : null}
419
+ {(toastType || toastItem.icon || toastItem.promise) && toastItem.icon !== null &&
420
+ ((toastType ? icons?.[toastType] : undefined) !== null || toastItem.icon)
421
+ ? <div data-icon="" className={cn(classNames?.icon, toastItem.classNames?.icon)}>
422
+ {toastItem.promise || toastItem.type === 'loading' && !toastItem.icon
423
+ ? toastItem.icon ||
424
+ <LoadingIcon
425
+ icons={icons}
426
+ classNames={classNames}
427
+ toastItem={toastItem}
428
+ toastType={toastType}
429
+ />
430
+ : null}
431
+ {toastItem.type !== 'loading' ? icon : null}
432
+ </div>
433
+ : null}
434
+ <div data-content="" className={cn(classNames?.content, toastItem.classNames?.content)}>
435
+ <div data-title="" className={cn(classNames?.title, toastItem.classNames?.title)}>
436
+ {toastItem.jsx
437
+ ? toastItem.jsx
438
+ : typeof toastItem.title === 'function'
439
+ ? toastItem.title()
440
+ : toastItem.title}
441
+ </div>
442
+ {toastItem.description
443
+ ? <div
444
+ data-description=""
445
+ className={cn(
446
+ descriptionClassName,
447
+ toastDescriptionClassname,
448
+ classNames?.description,
449
+ toastItem.classNames?.description,
450
+ )}
451
+ >
452
+ {typeof toastItem.description === 'function'
453
+ ? toastItem.description()
454
+ : toastItem.description}
455
+ </div>
456
+ : null}
457
+ </div>
458
+ {isValidElement(toastItem.cancel)
459
+ ? (toastItem.cancel)
460
+ : toastItem.cancel && isAction(toastItem.cancel)
461
+ ? <button
462
+ data-button="true"
463
+ data-cancel="true"
464
+ style={toastItem.cancelButtonStyle || cancelButtonStyle}
465
+ onClick={(event: MouseEvent) => {
466
+ if (!isAction(toastItem.cancel) || !dismissible) return;
467
+ toastItem.cancel.onClick(event as any);
468
+ deleteToast();
469
+ }}
470
+ className={cn(classNames?.cancelButton, toastItem.classNames?.cancelButton)}
471
+ >{toastItem.cancel.label}</button>
472
+ : null}
473
+ {isValidElement(toastItem.action)
474
+ ? (toastItem.action)
475
+ : toastItem.action && isAction(toastItem.action)
476
+ ? <button
477
+ data-button="true"
478
+ data-action="true"
479
+ style={toastItem.actionButtonStyle || actionButtonStyle}
480
+ onClick={(event: MouseEvent) => {
481
+ if (!isAction(toastItem.action)) return;
482
+ toastItem.action.onClick(event as any);
483
+ if (event.defaultPrevented) return;
484
+ deleteToast();
485
+ }}
486
+ className={cn(classNames?.actionButton, toastItem.classNames?.actionButton)}
487
+ >{toastItem.action.label}</button>
488
+ : null}
489
+ </li>;
490
+ }
491
+
492
+ function getDocumentDirection(): ToasterProps['dir'] {
493
+ if (typeof window === 'undefined' || typeof document === 'undefined') return 'ltr';
494
+ const direction = document.documentElement.getAttribute('dir');
495
+ if (direction === 'auto' || !direction) {
496
+ return window.getComputedStyle(document.documentElement).direction as ToasterProps['dir'];
497
+ }
498
+ return direction as ToasterProps['dir'];
499
+ }
500
+
501
+ function assignOffset(
502
+ defaultOffset: ToasterProps['offset'],
503
+ mobileOffset: ToasterProps['mobileOffset'],
504
+ ): CSSProperties {
505
+ const styles: CSSProperties = {};
506
+ [defaultOffset, mobileOffset].forEach((offset, index) => {
507
+ const isMobile = index === 1;
508
+ const prefix = isMobile ? '--mobile-offset' : '--offset';
509
+ const defaultValue = isMobile ? MOBILE_VIEWPORT_OFFSET : VIEWPORT_OFFSET;
510
+ const assignAll = (value: string | number): void => {
511
+ ['top', 'right', 'bottom', 'left'].forEach((key) => {
512
+ styles[`${prefix}-${key}`] = typeof value === 'number' ? `${value}px` : value;
513
+ });
514
+ };
515
+
516
+ if (typeof offset === 'number' || typeof offset === 'string') {
517
+ assignAll(offset);
518
+ } else if (typeof offset === 'object' && offset !== null) {
519
+ (['top', 'right', 'bottom', 'left'] as const).forEach((key) => {
520
+ const value = offset[key];
521
+ styles[`${prefix}-${key}`] = value === undefined
522
+ ? defaultValue
523
+ : typeof value === 'number'
524
+ ? `${value}px`
525
+ : value;
526
+ });
527
+ } else {
528
+ assignAll(defaultValue);
529
+ }
530
+ });
531
+ return styles;
532
+ }
533
+
534
+ export function useSonner(): { toasts: ToastT[] } {
535
+ const [activeToasts, setActiveToasts] = useState<ToastT[]>([]);
536
+
537
+ useEffect(
538
+ () => ToastState.subscribe((published) => {
539
+ if ('dismiss' in published) {
540
+ setTimeout(() => {
541
+ flushSync(() => {
542
+ setActiveToasts(
543
+ (current) => current.filter((toastItem) => toastItem.id !== published.id),
544
+ );
545
+ });
546
+ });
547
+ return;
548
+ }
549
+
550
+ setTimeout(() => {
551
+ flushSync(() => {
552
+ setActiveToasts((current) => {
553
+ const existing = current.findIndex((toastItem) => toastItem.id === published.id);
554
+ if (existing !== -1) {
555
+ return [
556
+ ...current.slice(0, existing),
557
+ { ...current[existing], ...published },
558
+ ...current.slice(existing + 1),
559
+ ];
560
+ }
561
+ return [published, ...current];
562
+ });
563
+ });
564
+ });
565
+ }),
566
+ [],
567
+ );
568
+
569
+ return { toasts: activeToasts };
570
+ }
571
+
572
+ export function Toaster(props: ToasterProps) {
573
+ const {
574
+ id,
575
+ invert,
576
+ position = 'bottom-right',
577
+ hotkey = ['altKey', 'KeyT'],
578
+ expand,
579
+ closeButton,
580
+ className,
581
+ offset: viewportOffset,
582
+ mobileOffset,
583
+ theme = 'light',
584
+ richColors,
585
+ duration,
586
+ style,
587
+ visibleToasts = VISIBLE_TOASTS_AMOUNT,
588
+ toastOptions,
589
+ dir = getDocumentDirection(),
590
+ gap = GAP,
591
+ icons,
592
+ containerAriaLabel = 'Notifications',
593
+ ref,
594
+ } = props;
595
+ const [toasts, setToasts] = useState<ToastT[]>([]);
596
+ const filteredToasts = useMemo(
597
+ () => id
598
+ ? toasts.filter((toastItem) => toastItem.toasterId === id)
599
+ : toasts.filter((toastItem) => !toastItem.toasterId),
600
+ [toasts, id],
601
+ );
602
+ const possiblePositions = useMemo(() => {
603
+ const dynamicPositions = filteredToasts.filter((toastItem) => toastItem.position).map(
604
+ (toastItem) => toastItem.position as Position,
605
+ );
606
+ return Array.from(new Set<Position>([position, ...dynamicPositions]));
607
+ }, [filteredToasts, position]);
608
+ const [heights, setHeights] = useState<HeightT[]>([]);
609
+ const [expanded, setExpanded] = useState(false);
610
+ const [interacting, setInteracting] = useState(false);
611
+ const [actualTheme, setActualTheme] = useState<'light' | 'dark'>(
612
+ theme !== 'system'
613
+ ? theme
614
+ : typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
615
+ ? 'dark'
616
+ : 'light',
617
+ );
618
+ const listRef = useRef<HTMLOListElement | null>(null);
619
+ const hotkeyLabel = hotkey.join('+').replace(/Key/g, '').replace(/Digit/g, '');
620
+ const lastFocusedElementRef = useRef<HTMLElement | null>(null);
621
+ const isFocusWithinRef = useRef(false);
622
+ const getFrontToastHeight = (currentPosition: Position, positionIndex: number): number => {
623
+ const frontToast = filteredToasts.find(
624
+ (toastItem) => !toastItem.position && positionIndex === 0 ||
625
+ toastItem.position === currentPosition,
626
+ );
627
+ return heights.find((height) => height.toastId === frontToast?.id)?.height ?? 0;
628
+ };
629
+
630
+ const removeToast = useCallback((toastToRemove: ToastT) => {
631
+ setToasts((current) => {
632
+ if (!current.find((toastItem) => toastItem.id === toastToRemove.id)?.delete) {
633
+ ToastState.dismiss(toastToRemove.id);
634
+ }
635
+ return current.filter((toastItem) => toastItem.id !== toastToRemove.id);
636
+ });
637
+ }, []);
638
+
639
+ useEffect(
640
+ () => ToastState.subscribe((published) => {
641
+ if ('dismiss' in published) {
642
+ requestAnimationFrame(() => {
643
+ setToasts(
644
+ (current) => current.map(
645
+ (toastItem) => toastItem.id === published.id
646
+ ? { ...toastItem, delete: true }
647
+ : toastItem,
648
+ ),
649
+ );
650
+ });
651
+ return;
652
+ }
653
+
654
+ setTimeout(() => {
655
+ flushSync(() => {
656
+ setToasts((current) => {
657
+ const existing = current.findIndex((toastItem) => toastItem.id === published.id);
658
+ if (existing !== -1) {
659
+ return [
660
+ ...current.slice(0, existing),
661
+ { ...current[existing], ...published },
662
+ ...current.slice(existing + 1),
663
+ ];
664
+ }
665
+ return [published, ...current];
666
+ });
667
+ });
668
+ });
669
+ }),
670
+ [],
671
+ );
672
+
673
+ useEffect(() => {
674
+ if (theme !== 'system') {
675
+ setActualTheme(theme);
676
+ return;
677
+ }
678
+
679
+ if (typeof window === 'undefined' || !window.matchMedia) return;
680
+ const darkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
681
+ const updateTheme = ({ matches }: MediaQueryListEvent): void => {
682
+ setActualTheme(matches ? 'dark' : 'light');
683
+ };
684
+ setActualTheme(darkMediaQuery.matches ? 'dark' : 'light');
685
+ try {
686
+ darkMediaQuery.addEventListener('change', updateTheme);
687
+ return () => darkMediaQuery.removeEventListener('change', updateTheme);
688
+ } catch {
689
+ darkMediaQuery.addListener(updateTheme);
690
+ return () => darkMediaQuery.removeListener(updateTheme);
691
+ }
692
+ }, [theme]);
693
+
694
+ useEffect(() => {
695
+ if (filteredToasts.length <= 1) setExpanded(false);
696
+ }, [filteredToasts]);
697
+
698
+ useEffect(() => {
699
+ const handleKeyDown = (event: KeyboardEvent): void => {
700
+ const isHotkeyPressed = hotkey.every((key) => (event as any)[key] || event.code === key);
701
+ if (isHotkeyPressed) {
702
+ setExpanded(true);
703
+ listRef.current?.focus();
704
+ }
705
+ if (
706
+ event.code === 'Escape' &&
707
+ (document.activeElement === listRef.current ||
708
+ listRef.current?.contains(document.activeElement))
709
+ ) {
710
+ setExpanded(false);
711
+ }
712
+ };
713
+ document.addEventListener('keydown', handleKeyDown);
714
+ return () => document.removeEventListener('keydown', handleKeyDown);
715
+ }, [hotkey]);
716
+
717
+ useEffect(() => {
718
+ if (!listRef.current) return;
719
+ return () => {
720
+ if (lastFocusedElementRef.current) {
721
+ lastFocusedElementRef.current.focus({ preventScroll: true });
722
+ lastFocusedElementRef.current = null;
723
+ isFocusWithinRef.current = false;
724
+ }
725
+ };
726
+ }, [listRef.current]);
727
+
728
+ return <section
729
+ ref={ref}
730
+ aria-label={`${containerAriaLabel} ${hotkeyLabel}`}
731
+ tabIndex={-1}
732
+ aria-live="polite"
733
+ aria-relevant="additions text"
734
+ aria-atomic="false"
735
+ suppressHydrationWarning
736
+ >
737
+ @for (const currentPosition of possiblePositions; index positionIndex; key currentPosition) {
738
+ @if (filteredToasts.length) {
739
+ <ol
740
+ dir={dir === 'auto' ? getDocumentDirection() : dir}
741
+ tabIndex={-1}
742
+ ref={listRef}
743
+ className={className}
744
+ data-sonner-toaster="true"
745
+ data-sonner-theme={actualTheme}
746
+ data-y-position={currentPosition.split('-')[0]}
747
+ data-x-position={currentPosition.split('-')[1]}
748
+ style={{
749
+ '--front-toast-height': `${getFrontToastHeight(currentPosition, positionIndex)}px`,
750
+ '--width': `${TOAST_WIDTH}px`,
751
+ '--gap': `${gap}px`,
752
+ ...style,
753
+ ...assignOffset(viewportOffset, mobileOffset),
754
+ }}
755
+ onBlur={(event: FocusEvent) => {
756
+ const currentTarget = event.currentTarget as HTMLOListElement;
757
+ if (
758
+ isFocusWithinRef.current &&
759
+ !currentTarget.contains(event.relatedTarget as Node | null)
760
+ ) {
761
+ isFocusWithinRef.current = false;
762
+ if (lastFocusedElementRef.current) {
763
+ lastFocusedElementRef.current.focus({ preventScroll: true });
764
+ lastFocusedElementRef.current = null;
765
+ }
766
+ }
767
+ }}
768
+ onFocus={(event: FocusEvent) => {
769
+ const target = event.target;
770
+ const isNotDismissible =
771
+ target instanceof HTMLElement && target.dataset.dismissible === 'false';
772
+ if (isNotDismissible) return;
773
+ if (!isFocusWithinRef.current) {
774
+ isFocusWithinRef.current = true;
775
+ lastFocusedElementRef.current = event.relatedTarget as HTMLElement;
776
+ }
777
+ }}
778
+ onMouseEnter={() => setExpanded(true)}
779
+ onMouseMove={() => setExpanded(true)}
780
+ onMouseLeave={() => {
781
+ if (!interacting) setExpanded(false);
782
+ }}
783
+ onDragEnd={() => setExpanded(false)}
784
+ onPointerDown={(event: PointerEvent) => {
785
+ const target = event.target;
786
+ const isNotDismissible =
787
+ target instanceof HTMLElement && target.dataset.dismissible === 'false';
788
+ if (!isNotDismissible) setInteracting(true);
789
+ }}
790
+ onPointerUp={() => setInteracting(false)}
791
+ >
792
+ @for (const toastItem of filteredToasts.filter(
793
+ (item) => !item.position && positionIndex === 0 || item.position === currentPosition,
794
+ ); index toastIndex; key toastItem.id) {
795
+ <Toast
796
+ icons={icons}
797
+ index={toastIndex}
798
+ toast={toastItem}
799
+ defaultRichColors={richColors}
800
+ duration={toastOptions?.duration ?? duration}
801
+ className={toastOptions?.className}
802
+ descriptionClassName={toastOptions?.descriptionClassName}
803
+ invert={invert}
804
+ visibleToasts={visibleToasts}
805
+ closeButton={toastOptions?.closeButton ?? closeButton}
806
+ interacting={interacting}
807
+ position={currentPosition}
808
+ style={toastOptions?.style}
809
+ unstyled={toastOptions?.unstyled}
810
+ classNames={toastOptions?.classNames}
811
+ cancelButtonStyle={toastOptions?.cancelButtonStyle}
812
+ actionButtonStyle={toastOptions?.actionButtonStyle}
813
+ closeButtonAriaLabel={toastOptions?.closeButtonAriaLabel}
814
+ removeToast={removeToast}
815
+ toasts={filteredToasts.filter((item) => item.position == toastItem.position)}
816
+ heights={heights.filter((height) => height.position == toastItem.position)}
817
+ setHeights={setHeights}
818
+ expandByDefault={expand}
819
+ gap={gap}
820
+ expanded={expanded}
821
+ swipeDirections={props.swipeDirections}
822
+ />
823
+ }
824
+ </ol>
825
+ }
826
+ }
827
+ </section>;
828
+ }
829
+
830
+ export { toast };
831
+ export { ExternalToast, ToastT, ToasterProps };