varsel 0.5.1 → 0.5.2

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 (50) hide show
  1. package/dist/VarselItem.svelte +900 -0
  2. package/dist/VarselItem.svelte.d.ts +23 -0
  3. package/dist/VarselItem.svelte.d.ts.map +1 -0
  4. package/dist/VarselManager.svelte +431 -0
  5. package/dist/VarselManager.svelte.d.ts +18 -0
  6. package/dist/VarselManager.svelte.d.ts.map +1 -0
  7. package/dist/VarselToaster.svelte +98 -0
  8. package/dist/VarselToaster.svelte.d.ts +32 -0
  9. package/dist/VarselToaster.svelte.d.ts.map +1 -0
  10. package/dist/core/accessibility.d.ts +6 -0
  11. package/dist/core/accessibility.d.ts.map +1 -0
  12. package/dist/core/accessibility.js +12 -0
  13. package/dist/core/animations.d.ts +29 -0
  14. package/dist/core/animations.d.ts.map +1 -0
  15. package/dist/core/animations.js +28 -0
  16. package/dist/core/positions.d.ts +71 -0
  17. package/dist/core/positions.d.ts.map +1 -0
  18. package/dist/core/positions.js +30 -0
  19. package/dist/core/swipe.d.ts +20 -0
  20. package/dist/core/swipe.d.ts.map +1 -0
  21. package/dist/core/swipe.js +30 -0
  22. package/dist/core/toast-factory.d.ts +3 -0
  23. package/dist/core/toast-factory.d.ts.map +1 -0
  24. package/dist/core/toast-factory.js +123 -0
  25. package/dist/core/toast-state.d.ts +49 -0
  26. package/dist/core/toast-state.d.ts.map +1 -0
  27. package/dist/core/toast-state.js +80 -0
  28. package/dist/core/toaster-instances.d.ts +27 -0
  29. package/dist/core/toaster-instances.d.ts.map +1 -0
  30. package/dist/core/toaster-instances.js +38 -0
  31. package/dist/core/types.d.ts +144 -0
  32. package/dist/core/types.d.ts.map +1 -0
  33. package/dist/core/types.js +1 -0
  34. package/dist/core/utils.d.ts +10 -0
  35. package/dist/core/utils.d.ts.map +1 -0
  36. package/dist/core/utils.js +9 -0
  37. package/dist/core/variants.d.ts +18 -0
  38. package/dist/core/variants.d.ts.map +1 -0
  39. package/dist/core/variants.js +56 -0
  40. package/dist/index.d.ts +10 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +8 -0
  43. package/dist/internals.d.ts +16 -0
  44. package/dist/internals.d.ts.map +1 -0
  45. package/dist/internals.js +14 -0
  46. package/dist/styles.css +195 -0
  47. package/dist/variant-icons.d.ts +108 -0
  48. package/dist/variant-icons.d.ts.map +1 -0
  49. package/dist/variant-icons.js +45 -0
  50. package/package.json +6 -4
@@ -0,0 +1,900 @@
1
+ <script lang="ts">
2
+ /**
3
+ * @component
4
+ * @description
5
+ * Individual toast component. Handles its own enter/exit animations,
6
+ * swipe-to-dismiss gestures, auto-closing timer, and rendering of content.
7
+ */
8
+ import { onDestroy, onMount } from "svelte";
9
+ import {
10
+ ANIMATION_CONFIG,
11
+ FOCUSABLE_SELECTORS,
12
+ POSITION_CONFIGS,
13
+ SWIPE_DISMISS_THRESHOLD,
14
+ SWIPE_DISMISS_VELOCITY,
15
+ SWIPE_EXIT_DISTANCE,
16
+ cn,
17
+ getDefaultSwipeDirections,
18
+ toastContainerVariants,
19
+ toastContentVariants,
20
+ toastState,
21
+ type PositionedToast,
22
+ type ToastPosition,
23
+ type SwipeAxis,
24
+ type SwipeDirection,
25
+ } from "./internals";
26
+ import {
27
+ hasVariantIcon,
28
+ variantIconMap,
29
+ } from "./variant-icons";
30
+
31
+ let {
32
+ toast,
33
+ onRemove,
34
+ isGroupHovered = false,
35
+ expandedOffset = 0,
36
+ expandedGap = ANIMATION_CONFIG.EXPANDED_GAP,
37
+ collapsedOffset = undefined,
38
+ hiddenCollapsedOffset = undefined,
39
+ onHeightChange = undefined,
40
+ onGroupHoverEnter = undefined,
41
+ onGroupHoldChange = undefined,
42
+ defaultDuration = 5000,
43
+ defaultShowClose = true,
44
+ pauseOnHover = true,
45
+ offset = undefined,
46
+ expand = true,
47
+ visibleToasts = ANIMATION_CONFIG.MAX_VISIBLE_TOASTS
48
+ }: {
49
+ toast: PositionedToast;
50
+ onRemove: (id: string) => void;
51
+ isGroupHovered?: boolean;
52
+ expandedOffset?: number;
53
+ expandedGap?: number;
54
+ collapsedOffset?: number;
55
+ hiddenCollapsedOffset?: number;
56
+ onHeightChange?: (id: string, height: number) => void;
57
+ onGroupHoverEnter?: () => void;
58
+ onGroupHoldChange?: (holding: boolean) => void;
59
+ defaultDuration?: number;
60
+ defaultShowClose?: boolean;
61
+ pauseOnHover?: boolean;
62
+ offset?: number | string;
63
+ expand?: boolean;
64
+ visibleToasts?: number;
65
+ } = $props();
66
+
67
+ let id = $derived(toast.id);
68
+ let title = $derived(toast.title);
69
+ let description = $derived(toast.description);
70
+ let variant = $derived(toast.variant || "default");
71
+ let duration = $derived(toast.duration ?? defaultDuration);
72
+ let action = $derived(toast.action);
73
+ let isLoading = $derived(toast.isLoading || false);
74
+ let index = $derived(toast.index);
75
+ let renderIndex = $derived(toast.renderIndex);
76
+ let shouldClose = $derived(toast.shouldClose);
77
+ let position = $derived(toast.position || "bottom-center");
78
+ let className = $derived(toast.className || "");
79
+ let onAutoClose = $derived(toast.onAutoClose);
80
+ let onDismiss = $derived(toast.onDismiss);
81
+ let showClose = $derived(toast.showClose ?? defaultShowClose);
82
+
83
+ let toastRef = $state<HTMLDivElement | null>(null);
84
+ let isItemHovered = $state(false);
85
+ let isSwiping = $state(false);
86
+ let swipeDismissDirection = $state<SwipeDirection | null>(null);
87
+ let animationState = $state<"entering" | "entered" | "exiting" | "stacking">(
88
+ "entering",
89
+ );
90
+
91
+ let timeoutRef: ReturnType<typeof setTimeout> | null = null;
92
+ let timerStartRef: number | null = null;
93
+ let remainingTime = $state<number | null>(Number.NaN);
94
+ let enterAnimationFrame: number | null = null;
95
+ let focusTimeout: ReturnType<typeof setTimeout> | null = null;
96
+ let pointerStart: { x: number; y: number } | null = null;
97
+ let dragStartTime: number | null = null;
98
+ let swipeAxis: SwipeAxis | null = null;
99
+ let lastSwipe = { x: 0, y: 0 };
100
+ let mounted = $state(false);
101
+ let prevShouldClose = false;
102
+ let previousDuration: number | undefined;
103
+ let isExiting = $state(false);
104
+ let exitAnimationComplete = false;
105
+ let hasAnimatedIn = $state(false);
106
+ let isPointerHeld = false;
107
+ type SpinnerState = "hidden" | "loading" | "finishing";
108
+ let spinnerState = $state<SpinnerState>("hidden");
109
+ let spinnerFinishTimer: ReturnType<typeof setTimeout> | null = null;
110
+ let hasShownSpinner = $state(false);
111
+
112
+ $effect(() => {
113
+ if (isLoading) {
114
+ hasShownSpinner = true;
115
+ }
116
+ });
117
+
118
+ $effect(() => {
119
+ if (isLoading) {
120
+ spinnerState = "loading";
121
+ } else if (spinnerState === "loading") {
122
+ spinnerState = "finishing";
123
+ }
124
+ });
125
+
126
+ $effect(() => {
127
+ if (spinnerState === "finishing") {
128
+ if (!spinnerFinishTimer) {
129
+ spinnerFinishTimer = setTimeout(() => {
130
+ spinnerState = "hidden";
131
+ spinnerFinishTimer = null;
132
+ }, 420);
133
+ }
134
+ } else if (spinnerFinishTimer) {
135
+ clearTimeout(spinnerFinishTimer);
136
+ spinnerFinishTimer = null;
137
+ }
138
+ });
139
+
140
+ let shouldRenderSpinner = $derived(spinnerState !== "hidden");
141
+
142
+ const handleSpinnerAnimationEnd = (event: AnimationEvent) => {
143
+ if (event.animationName !== "vs-spinner-finish") return;
144
+ spinnerState = "hidden";
145
+ };
146
+
147
+ let iconConfig = $derived(
148
+ hasVariantIcon(variant) ? variantIconMap[variant] : undefined,
149
+ );
150
+ let showStatusIcon = $derived(isLoading || Boolean(iconConfig));
151
+
152
+ let iconStateClass = $derived(
153
+ !iconConfig
154
+ ? undefined
155
+ : !hasShownSpinner
156
+ ? "vs-icon--static"
157
+ : isLoading
158
+ ? "vs-icon--waiting"
159
+ : "vs-icon--pop",
160
+ );
161
+
162
+ const clearSwipeRefs = () => {
163
+ pointerStart = null;
164
+ dragStartTime = null;
165
+ swipeAxis = null;
166
+ lastSwipe = { x: 0, y: 0 };
167
+ };
168
+
169
+ const getFocusableElements = () => {
170
+ if (!toastRef) return [];
171
+ return Array.from(
172
+ toastRef.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS),
173
+ );
174
+ };
175
+
176
+ type CloseReason = "auto" | "dismiss" | null;
177
+ let closeReason: CloseReason = null;
178
+
179
+ const handleTransitionEnd = (event: TransitionEvent) => {
180
+ if (event.target !== toastRef) return;
181
+ if (event.propertyName !== "opacity" && event.propertyName !== "transform")
182
+ return;
183
+ if (animationState !== "exiting") return;
184
+ if (exitAnimationComplete) return;
185
+
186
+ exitAnimationComplete = true;
187
+ if (closeReason === "auto") {
188
+ onAutoClose?.();
189
+ } else if (closeReason === "dismiss") {
190
+ onDismiss?.();
191
+ }
192
+ onRemove(id);
193
+ };
194
+
195
+ const handleClose = (reason: Exclude<CloseReason, null> = "dismiss") => {
196
+ if (!toastRef || isExiting) return;
197
+
198
+ isExiting = true;
199
+ exitAnimationComplete = false;
200
+ closeReason = reason;
201
+
202
+ toastState.update(id, { shouldClose: true });
203
+
204
+ if (enterAnimationFrame) {
205
+ cancelAnimationFrame(enterAnimationFrame);
206
+ enterAnimationFrame = null;
207
+ }
208
+
209
+ if (timeoutRef) {
210
+ clearTimeout(timeoutRef);
211
+ timeoutRef = null;
212
+ }
213
+
214
+ animationState = "exiting";
215
+ toastState.update(id, { shouldClose: true, isLeaving: true });
216
+ };
217
+
218
+ $effect(() => {
219
+ const desiredClose = Boolean(shouldClose);
220
+ if (desiredClose && !prevShouldClose) {
221
+ handleClose();
222
+ }
223
+ prevShouldClose = desiredClose;
224
+ });
225
+
226
+ onMount(() => {
227
+ mounted = true;
228
+ return () => {
229
+ mounted = false;
230
+ if (enterAnimationFrame) cancelAnimationFrame(enterAnimationFrame);
231
+ if (timeoutRef) clearTimeout(timeoutRef);
232
+ if (focusTimeout) clearTimeout(focusTimeout);
233
+ };
234
+ });
235
+
236
+ onDestroy(() => {
237
+ if (enterAnimationFrame) cancelAnimationFrame(enterAnimationFrame);
238
+ if (timeoutRef) clearTimeout(timeoutRef);
239
+ if (focusTimeout) clearTimeout(focusTimeout);
240
+ if (spinnerFinishTimer) {
241
+ clearTimeout(spinnerFinishTimer);
242
+ spinnerFinishTimer = null;
243
+ }
244
+ if (isPointerHeld) {
245
+ isPointerHeld = false;
246
+ onGroupHoldChange?.(false);
247
+ }
248
+ });
249
+
250
+ $effect(() => {
251
+ if (mounted && duration !== previousDuration) {
252
+ remainingTime = duration;
253
+ previousDuration = duration;
254
+ }
255
+ });
256
+
257
+ $effect(() => {
258
+ if (mounted) {
259
+ if (!toastRef || !onHeightChange) {
260
+ // do nothing
261
+ } else {
262
+ const el = toastRef;
263
+ const notify = () => onHeightChange?.(id, el.offsetHeight);
264
+ const ro = new ResizeObserver(() => notify());
265
+ ro.observe(el);
266
+ notify();
267
+ return () => ro.disconnect();
268
+ }
269
+ }
270
+ });
271
+
272
+ const setFocusToToast = () => {
273
+ if (!toastRef) return;
274
+ const focusableElements = getFocusableElements();
275
+ const firstFocusable = focusableElements[0];
276
+ if (firstFocusable) {
277
+ firstFocusable.focus();
278
+ return;
279
+ }
280
+ toastRef.focus();
281
+ };
282
+
283
+ $effect(() => {
284
+ if (mounted && toastRef && !isExiting) {
285
+ if (!hasAnimatedIn && isLatest) {
286
+ hasAnimatedIn = true;
287
+ animationState = "entering";
288
+ if (enterAnimationFrame) {
289
+ cancelAnimationFrame(enterAnimationFrame);
290
+ }
291
+ enterAnimationFrame = requestAnimationFrame(() => {
292
+ enterAnimationFrame = requestAnimationFrame(() => {
293
+ animationState = "entered";
294
+ if (action) {
295
+ if (focusTimeout) clearTimeout(focusTimeout);
296
+ focusTimeout = setTimeout(
297
+ () => setFocusToToast(),
298
+ ANIMATION_CONFIG.ENTER_DURATION * 1000,
299
+ );
300
+ }
301
+ });
302
+ });
303
+ } else if (hasAnimatedIn) {
304
+ if (animationState !== "stacking" || index > 0) {
305
+ animationState = "stacking";
306
+ }
307
+ } else {
308
+ animationState = "stacking";
309
+ }
310
+ }
311
+ });
312
+
313
+ $effect(() => {
314
+ if (mounted) {
315
+ if (shouldClose || !hasAnimatedIn || duration <= 0) {
316
+ if (timeoutRef) {
317
+ clearTimeout(timeoutRef);
318
+ timeoutRef = null;
319
+ }
320
+ timerStartRef = null;
321
+ } else {
322
+ if (remainingTime == null || Number.isNaN(remainingTime)) {
323
+ remainingTime = duration;
324
+ }
325
+
326
+ const isHovering = isGroupHovered || isItemHovered;
327
+ const isPaused =
328
+ (pauseOnHover && isHovering) || isSwiping || hiddenByStacking;
329
+
330
+ if (isPaused) {
331
+ if (timeoutRef) {
332
+ clearTimeout(timeoutRef);
333
+ timeoutRef = null;
334
+ }
335
+ if (timerStartRef !== null) {
336
+ const elapsed = Date.now() - timerStartRef;
337
+ remainingTime = Math.max(0, (remainingTime ?? duration) - elapsed);
338
+ timerStartRef = null;
339
+ }
340
+ } else {
341
+ if (!timeoutRef) {
342
+ const ms = Math.max(0, remainingTime ?? duration);
343
+ if (!Number.isFinite(ms)) {
344
+ // Do not set timeout for infinite duration
345
+ } else if (ms === 0) {
346
+ handleClose("auto");
347
+ } else {
348
+ timerStartRef = Date.now();
349
+ timeoutRef = setTimeout(() => {
350
+ handleClose("auto");
351
+ }, ms);
352
+ }
353
+ }
354
+ }
355
+ }
356
+ }
357
+ });
358
+
359
+ $effect(() => {
360
+ if (mounted && toastRef && !isSwiping && !swipeDismissDirection) {
361
+ toastRef.style.setProperty("--swipe-translate-x", "0px");
362
+ toastRef.style.setProperty("--swipe-translate-y", "0px");
363
+ }
364
+ });
365
+
366
+ let swipeDirections = $derived(
367
+ showClose ? getDefaultSwipeDirections(position) : [],
368
+ );
369
+
370
+ const handlePointerDown = (event: PointerEvent) => {
371
+ if (!showClose) return;
372
+ if (event.pointerType === "mouse" && event.button !== 0) return;
373
+ if (event.button === 2) return;
374
+ if (isExiting) return;
375
+
376
+ const target = event.target as HTMLElement;
377
+ if (target.closest("button, a, input, textarea, select")) {
378
+ return;
379
+ }
380
+
381
+ clearSwipeRefs();
382
+ pointerStart = { x: event.clientX, y: event.clientY };
383
+ dragStartTime = Date.now();
384
+ if (toastRef) {
385
+ toastRef.style.setProperty("--swipe-translate-x", "0px");
386
+ toastRef.style.setProperty("--swipe-translate-y", "0px");
387
+ }
388
+ swipeDismissDirection = null;
389
+ isSwiping = true;
390
+ if (!isPointerHeld) {
391
+ isPointerHeld = true;
392
+ onGroupHoldChange?.(true);
393
+ }
394
+ const currentTarget = event.currentTarget as HTMLElement | null;
395
+ currentTarget?.setPointerCapture(event.pointerId);
396
+ };
397
+
398
+ const handlePointerMove = (event: PointerEvent) => {
399
+ if (!showClose) return;
400
+ if (!pointerStart) return;
401
+ if (isExiting) return;
402
+
403
+ if (event.pointerType === "touch") {
404
+ event.preventDefault();
405
+ }
406
+
407
+ const xDelta = event.clientX - pointerStart.x;
408
+ const yDelta = event.clientY - pointerStart.y;
409
+
410
+ let axis = swipeAxis;
411
+ if (!axis) {
412
+ if (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1) {
413
+ axis = Math.abs(xDelta) > Math.abs(yDelta) ? "x" : "y";
414
+ swipeAxis = axis;
415
+ } else {
416
+ return;
417
+ }
418
+ }
419
+
420
+ const dampen = (delta: number) => {
421
+ const factor = Math.abs(delta) / 20;
422
+ return delta * (1 / (1.5 + factor));
423
+ };
424
+
425
+ let nextX = 0;
426
+ let nextY = 0;
427
+
428
+ if (axis === "x") {
429
+ const allowLeft = swipeDirections.includes("left");
430
+ const allowRight = swipeDirections.includes("right");
431
+ if (!allowLeft && !allowRight) {
432
+ swipeAxis = "y";
433
+ axis = "y";
434
+ } else if ((allowLeft && xDelta < 0) || (allowRight && xDelta > 0)) {
435
+ nextX = xDelta;
436
+ } else {
437
+ nextX = dampen(xDelta);
438
+ }
439
+ }
440
+
441
+ if (axis === "y") {
442
+ const allowTop = swipeDirections.includes("top");
443
+ const allowBottom = swipeDirections.includes("bottom");
444
+ if (!allowTop && !allowBottom) {
445
+ swipeAxis = "x";
446
+ axis = "x";
447
+ } else if ((allowTop && yDelta < 0) || (allowBottom && yDelta > 0)) {
448
+ nextY = yDelta;
449
+ } else {
450
+ nextY = dampen(yDelta);
451
+ }
452
+ }
453
+
454
+ lastSwipe = { x: nextX, y: nextY };
455
+ if (toastRef) {
456
+ toastRef.style.setProperty("--swipe-translate-x", `${nextX}px`);
457
+ toastRef.style.setProperty("--swipe-translate-y", `${nextY}px`);
458
+ }
459
+ };
460
+
461
+ const handlePointerUp = (event: PointerEvent) => {
462
+ if (!showClose) return;
463
+ const currentTarget = event.currentTarget as HTMLElement | null;
464
+ if (currentTarget?.hasPointerCapture(event.pointerId)) {
465
+ currentTarget.releasePointerCapture(event.pointerId);
466
+ }
467
+
468
+ if (!pointerStart) {
469
+ swipeDismissDirection = null;
470
+ isSwiping = false;
471
+ clearSwipeRefs();
472
+ return;
473
+ }
474
+
475
+ const elapsed = dragStartTime ? Date.now() - dragStartTime : 0;
476
+
477
+ const axis = swipeAxis;
478
+ const { x, y } = lastSwipe;
479
+ let dismissed = false;
480
+
481
+ if (axis) {
482
+ const distance = axis === "x" ? x : y;
483
+ const velocity = elapsed > 0 ? Math.abs(distance) / elapsed : 0;
484
+ const meetsThreshold =
485
+ Math.abs(distance) >= SWIPE_DISMISS_THRESHOLD ||
486
+ velocity > SWIPE_DISMISS_VELOCITY;
487
+
488
+ if (meetsThreshold && Math.abs(distance) > 0) {
489
+ let direction: SwipeDirection;
490
+ if (axis === "x") {
491
+ direction = distance > 0 ? "right" : "left";
492
+ } else {
493
+ direction = distance > 0 ? "bottom" : "top";
494
+ }
495
+
496
+ if (swipeDirections.includes(direction)) {
497
+ swipeDismissDirection = direction;
498
+ dismissed = true;
499
+ handleClose();
500
+ }
501
+ }
502
+ }
503
+
504
+ if (!dismissed) {
505
+ swipeDismissDirection = null;
506
+ }
507
+
508
+ isSwiping = false;
509
+ clearSwipeRefs();
510
+ if (isPointerHeld) {
511
+ isPointerHeld = false;
512
+ onGroupHoldChange?.(false);
513
+ }
514
+ };
515
+
516
+ const handlePointerCancel = (event: PointerEvent) => {
517
+ if (!showClose) return;
518
+ const currentTarget = event.currentTarget as HTMLElement | null;
519
+ if (currentTarget?.hasPointerCapture(event.pointerId)) {
520
+ currentTarget.releasePointerCapture(event.pointerId);
521
+ }
522
+ swipeDismissDirection = null;
523
+ isSwiping = false;
524
+ clearSwipeRefs();
525
+ if (isPointerHeld) {
526
+ isPointerHeld = false;
527
+ onGroupHoldChange?.(false);
528
+ }
529
+ };
530
+
531
+ const zIndexBase = Number(ANIMATION_CONFIG.Z_INDEX_BASE);
532
+
533
+ let isTopPosition = $derived(position?.startsWith("top-") ?? false);
534
+ let maxVisibleIndex = $derived(
535
+ Math.max(0, visibleToasts - 1),
536
+ );
537
+ let visibleIndex = $derived(Math.min(index, maxVisibleIndex));
538
+ let defaultCollapsedOffset = $derived(
539
+ isTopPosition
540
+ ? index * ANIMATION_CONFIG.STACK_OFFSET
541
+ : -(index * ANIMATION_CONFIG.STACK_OFFSET),
542
+ );
543
+ let resolvedCollapsedOffset = $derived(
544
+ typeof collapsedOffset === "number" && Number.isFinite(collapsedOffset)
545
+ ? collapsedOffset
546
+ : defaultCollapsedOffset,
547
+ );
548
+ let resolvedHiddenCollapsedOffset = $derived(
549
+ typeof hiddenCollapsedOffset === "number" &&
550
+ Number.isFinite(hiddenCollapsedOffset)
551
+ ? hiddenCollapsedOffset
552
+ : resolvedCollapsedOffset,
553
+ );
554
+ let scale = $derived(
555
+ Math.max(ANIMATION_CONFIG.MIN_SCALE, 1 - index * ANIMATION_CONFIG.SCALE_FACTOR),
556
+ );
557
+ let visibleScale = $derived(
558
+ Math.max(
559
+ ANIMATION_CONFIG.MIN_SCALE,
560
+ 1 - visibleIndex * ANIMATION_CONFIG.SCALE_FACTOR,
561
+ ),
562
+ );
563
+ let zIndex = $derived(zIndexBase - renderIndex);
564
+ let stackHidden = $derived(index >= visibleToasts);
565
+ let hiddenByStacking = $derived(stackHidden && animationState !== "exiting");
566
+ let isStackLeader = $derived(index === 0);
567
+ let isLatest = $derived(isStackLeader && !shouldClose);
568
+ type PositionConfig = (typeof POSITION_CONFIGS)[ToastPosition];
569
+ let config = $derived(
570
+ POSITION_CONFIGS[(position ?? "bottom-center") as ToastPosition],
571
+ );
572
+
573
+ let transformStyle = $derived.by(() => {
574
+ const baseOffsetY = stackHidden
575
+ ? resolvedHiddenCollapsedOffset
576
+ : resolvedCollapsedOffset;
577
+ const promotionOffset =
578
+ typeof expandedGap === "number"
579
+ ? expandedGap
580
+ : ANIMATION_CONFIG.EXPANDED_GAP;
581
+ const expandedTranslateY = isTopPosition ? expandedOffset : -expandedOffset;
582
+ const hiddenExpandedTranslateY = expandedTranslateY - promotionOffset;
583
+
584
+ let translateX = 0;
585
+ let translateY = baseOffsetY;
586
+ let scaleValue = stackHidden
587
+ ? visibleIndex === 0
588
+ ? 1
589
+ : visibleScale
590
+ : isStackLeader
591
+ ? 1
592
+ : scale;
593
+ let opacityValue = stackHidden ? 0 : 1;
594
+
595
+ if (stackHidden) {
596
+ if (expand && isGroupHovered && animationState !== "exiting") {
597
+ translateX = 0;
598
+ translateY = hiddenExpandedTranslateY;
599
+ scaleValue = 1;
600
+ }
601
+ } else if (expand && isGroupHovered && animationState !== "exiting") {
602
+ translateX = 0;
603
+ translateY = expandedTranslateY;
604
+ scaleValue = 1;
605
+ opacityValue = 1;
606
+ } else {
607
+ switch (animationState) {
608
+ case "entering":
609
+ translateX = config.animateIn.x;
610
+ translateY = config.animateIn.y;
611
+ scaleValue = 1;
612
+ opacityValue = 0;
613
+ break;
614
+ case "entered":
615
+ translateX = 0;
616
+ translateY = baseOffsetY;
617
+ scaleValue = 1;
618
+ opacityValue = 1;
619
+ break;
620
+ case "exiting": {
621
+ scaleValue = 1;
622
+ opacityValue = 0;
623
+ if (swipeDismissDirection) {
624
+ switch (swipeDismissDirection) {
625
+ case "left":
626
+ translateX = -SWIPE_EXIT_DISTANCE;
627
+ translateY = 0;
628
+ break;
629
+ case "right":
630
+ translateX = SWIPE_EXIT_DISTANCE;
631
+ translateY = 0;
632
+ break;
633
+ case "top":
634
+ translateX = 0;
635
+ translateY = -SWIPE_EXIT_DISTANCE;
636
+ break;
637
+ case "bottom":
638
+ translateX = 0;
639
+ translateY = SWIPE_EXIT_DISTANCE;
640
+ break;
641
+ default:
642
+ translateX = config.animateOut.x;
643
+ translateY = config.animateOut.y;
644
+ break;
645
+ }
646
+ } else {
647
+ translateX = config.animateOut.x;
648
+ translateY = config.animateOut.y;
649
+ }
650
+ break;
651
+ }
652
+ default:
653
+ translateX = 0;
654
+ translateY = baseOffsetY;
655
+ scaleValue = isStackLeader ? 1 : scale;
656
+ opacityValue = stackHidden ? 0 : 1;
657
+ break;
658
+ }
659
+ }
660
+
661
+ const transform = `translate(calc(${translateX}px + var(--swipe-translate-x, 0px)), calc(${translateY}px + var(--swipe-translate-y, 0px))) scale(${scaleValue})`;
662
+
663
+ return {
664
+ transform,
665
+ opacity: opacityValue,
666
+ };
667
+ });
668
+
669
+ let transitionDuration = $derived.by(() => {
670
+ switch (animationState) {
671
+ case "entering":
672
+ case "entered":
673
+ return `${ANIMATION_CONFIG.ENTER_DURATION}s`;
674
+ case "exiting":
675
+ return `${ANIMATION_CONFIG.EXIT_DURATION}s`;
676
+ default:
677
+ return `${ANIMATION_CONFIG.STACK_DURATION}s`;
678
+ }
679
+ });
680
+
681
+ let transitionTimingFunction = $derived(
682
+ animationState === "exiting"
683
+ ? ANIMATION_CONFIG.EASING_EXIT
684
+ : ANIMATION_CONFIG.EASING_DEFAULT,
685
+ );
686
+
687
+ let canSwipe = $derived(showClose && swipeDirections.length > 0);
688
+ let swipeCursorClass = $derived(
689
+ canSwipe ? (isSwiping ? "cursor-grabbing" : "cursor-grab") : undefined,
690
+ );
691
+
692
+ let titleId = $derived(title ? `${id}-title` : undefined);
693
+ let descriptionId = $derived(description ? `${id}-desc` : undefined);
694
+ let liveRole = $derived(variant === "destructive" ? "alert" : "status");
695
+ let livePoliteness = $derived(
696
+ (variant === "destructive" ? "assertive" : "polite") as "assertive" | "polite",
697
+ );
698
+
699
+ let offsetStyle = $derived.by(() => {
700
+ if (offset === undefined) return undefined;
701
+ const val = typeof offset === "number" ? `${offset}px` : offset;
702
+ if (position.startsWith("top")) return `top: ${val};`;
703
+ if (position.startsWith("bottom")) return `bottom: ${val};`;
704
+ return undefined;
705
+ });
706
+
707
+ const handleBlurCapture = (event: FocusEvent) => {
708
+ const next = event.relatedTarget as Node | null;
709
+ if (!toastRef || !next || !toastRef.contains(next)) {
710
+ isItemHovered = false;
711
+ }
712
+ };
713
+ </script>
714
+
715
+ <div
716
+ bind:this={toastRef}
717
+ class={cn(
718
+ toastContainerVariants({ position, variant }),
719
+ className,
720
+ swipeCursorClass,
721
+ stackHidden && "pointer-events-none",
722
+ )}
723
+ style:transform-origin={position?.startsWith("top-")
724
+ ? "center top"
725
+ : "center bottom"}
726
+ style:z-index={zIndex}
727
+ style:transition={isSwiping
728
+ ? `transform 0s linear, opacity ${transitionDuration} ${transitionTimingFunction}`
729
+ : `transform ${transitionDuration} ${transitionTimingFunction}, opacity ${transitionDuration} ${transitionTimingFunction}`}
730
+ style:transform={transformStyle.transform}
731
+ style:opacity={transformStyle.opacity}
732
+ style={offsetStyle}
733
+ role={stackHidden ? undefined : liveRole}
734
+ aria-live={stackHidden ? undefined : livePoliteness}
735
+ aria-atomic={stackHidden ? undefined : "true"}
736
+ aria-describedby={stackHidden ? undefined : descriptionId}
737
+ aria-hidden={stackHidden ? true : undefined}
738
+ tabindex="-1"
739
+ ontransitionend={handleTransitionEnd}
740
+ data-toast-id={id}
741
+ >
742
+ <div
743
+ role="presentation"
744
+ class={cn(swipeCursorClass, "touch-none")}
745
+ aria-busy={isLoading ? "true" : undefined}
746
+ onpointerdown={handlePointerDown}
747
+ onpointermove={handlePointerMove}
748
+ onpointerup={handlePointerUp}
749
+ onpointercancel={handlePointerCancel}
750
+ onmouseenter={() => {
751
+ isItemHovered = true;
752
+ onGroupHoverEnter?.();
753
+ }}
754
+ onmouseleave={() => (isItemHovered = false)}
755
+ onfocuscapture={() => (isItemHovered = true)}
756
+ onblurcapture={handleBlurCapture}
757
+ >
758
+ {#if toast.component}
759
+ {@const Component = toast.component}
760
+ <Component {id} {toast} {...toast.componentProps} />
761
+ {:else}
762
+ <div class={cn(toastContentVariants({ variant }))}>
763
+ {#if showClose}
764
+ <button
765
+ type="button"
766
+ onclick={() => handleClose()}
767
+ class={cn(
768
+ "absolute top-2 end-2 cursor-pointer rounded-vs-sm p-1 text-vs-foreground/45 hover:bg-vs-popover-muted hover:text-vs-foreground/70 transition-[background-color,color,box-shadow] ease-vs-button duration-100 focus-visible:ring-1 focus-visible:ring-vs-ring/50 focus-visible:outline-none",
769
+ )}
770
+ aria-label="Close toast"
771
+ >
772
+ <svg
773
+ aria-hidden="true"
774
+ class="h-4 w-4"
775
+ viewBox="0 0 24 24"
776
+ fill="none"
777
+ stroke="currentColor"
778
+ stroke-width="2"
779
+ stroke-linecap="round"
780
+ stroke-linejoin="round"
781
+ >
782
+ <line x1="18" x2="6" y1="6" y2="18" />
783
+ <line x1="6" x2="18" y1="6" y2="18" />
784
+ </svg>
785
+ </button>
786
+ {/if}
787
+
788
+ <div class="p-4 pe-8">
789
+ <div class="flex gap-3">
790
+ {#if showStatusIcon}
791
+ <span class="relative inline-flex h-4 w-4 shrink-0 items-center justify-center">
792
+ {#if shouldRenderSpinner}
793
+ <span
794
+ class={cn(
795
+ "vs-spinner absolute inset-0",
796
+ spinnerState === "loading"
797
+ ? "vs-spinner--active"
798
+ : "vs-spinner--finish",
799
+ )}
800
+ role={spinnerState === "loading" ? "status" : undefined}
801
+ aria-label={spinnerState === "loading" ? "Loading..." : undefined}
802
+ aria-live={spinnerState === "loading" ? "assertive" : undefined}
803
+ onanimationend={handleSpinnerAnimationEnd}
804
+ >
805
+ <svg
806
+ viewBox="0 0 256 256"
807
+ fill="none"
808
+ stroke="currentColor"
809
+ stroke-width="24"
810
+ stroke-linecap="round"
811
+ stroke-linejoin="round"
812
+ >
813
+ <line x1="128" y1="32" x2="128" y2="64" />
814
+ <line x1="195.9" y1="60.1" x2="173.3" y2="82.7" />
815
+ <line x1="224" y1="128" x2="192" y2="128" />
816
+ <line x1="195.9" y1="195.9" x2="173.3" y2="173.3" />
817
+ <line x1="128" y1="224" x2="128" y2="192" />
818
+ <line x1="60.1" y1="195.9" x2="82.7" y2="173.3" />
819
+ <line x1="32" y1="128" x2="64" y2="128" />
820
+ <line x1="60.1" y1="60.1" x2="82.7" y2="82.7" />
821
+ </svg>
822
+ </span>
823
+ {/if}
824
+ {#if iconConfig}
825
+ <span
826
+ class={cn(
827
+ "vs-icon absolute inset-0 flex items-center justify-center",
828
+ iconStateClass,
829
+ )}
830
+ aria-hidden="true"
831
+ >
832
+ <svg
833
+ viewBox={iconConfig.viewBox}
834
+ fill="none"
835
+ stroke="currentColor"
836
+ stroke-width="2"
837
+ stroke-linecap="round"
838
+ stroke-linejoin="round"
839
+ >
840
+ {#each iconConfig.elements as element, elementIndex (elementIndex)}
841
+ {#if element.tag === "path"}
842
+ <path d={element.d} />
843
+ {:else if element.tag === "line"}
844
+ <line
845
+ x1={element.x1}
846
+ y1={element.y1}
847
+ x2={element.x2}
848
+ y2={element.y2}
849
+ />
850
+ {:else if element.tag === "circle"}
851
+ <circle
852
+ cx={element.cx}
853
+ cy={element.cy}
854
+ r={element.r}
855
+ />
856
+ {/if}
857
+ {/each}
858
+ </svg>
859
+ </span>
860
+ {/if}
861
+ </span>
862
+ {/if}
863
+ <div class="min-w-0">
864
+ {#if title}
865
+ <div
866
+ id={titleId}
867
+ class="mb-1 text-sm leading-none font-medium select-none"
868
+ >
869
+ {title}
870
+ </div>
871
+ {/if}
872
+ {#if description}
873
+ <div
874
+ id={descriptionId}
875
+ class="text-sm leading-snug text-vs-foreground/70 text-balance select-none"
876
+ >
877
+ {description}
878
+ </div>
879
+ {/if}
880
+ {#if action}
881
+ <div class="mt-3">
882
+ <button
883
+ type="button"
884
+ onclick={() => {
885
+ action.onClick();
886
+ handleClose();
887
+ }}
888
+ class="relative inline-flex cursor-pointer items-center justify-center rounded-vs-md px-3 py-1.5 text-sm font-medium bg-vs-foreground text-vs-popover shadow-vs-button transition-[background-color,color,box-shadow] ease-vs-button duration-100 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:ring-offset-vs-ring-offset/50 focus-visible:outline-none focus-visible:ring-vs-ring/50"
889
+ >
890
+ {action.label}
891
+ </button>
892
+ </div>
893
+ {/if}
894
+ </div>
895
+ </div>
896
+ </div>
897
+ </div>
898
+ {/if}
899
+ </div>
900
+ </div>