@starwind-ui/core 1.12.4 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/index.js +16 -12
  2. package/dist/index.js.map +1 -1
  3. package/dist/src/components/accordion/Accordion.astro +10 -3
  4. package/dist/src/components/alert/AlertTitle.astro +1 -1
  5. package/dist/src/components/alert-dialog/AlertDialog.astro +4 -2
  6. package/dist/src/components/alert-dialog/AlertDialogTitle.astro +1 -1
  7. package/dist/src/components/badge/Badge.astro +1 -3
  8. package/dist/src/components/button/Button.astro +2 -1
  9. package/dist/src/components/dialog/Dialog.astro +101 -9
  10. package/dist/src/components/dialog/DialogContent.astro +13 -2
  11. package/dist/src/components/dialog/DialogTitle.astro +3 -1
  12. package/dist/src/components/dropdown/Dropdown.astro +4 -2
  13. package/dist/src/components/dropzone/Dropzone.astro +5 -3
  14. package/dist/src/components/image/Image.astro +24 -0
  15. package/dist/src/components/image/index.ts +9 -0
  16. package/dist/src/components/progress/Progress.astro +1 -0
  17. package/dist/src/components/radio-group/RadioGroup.astro +7 -2
  18. package/dist/src/components/select/Select.astro +7 -2
  19. package/dist/src/components/sheet/SheetTitle.astro +1 -1
  20. package/dist/src/components/slider/Slider.astro +411 -0
  21. package/dist/src/components/slider/index.ts +9 -0
  22. package/dist/src/components/switch/Switch.astro +1 -0
  23. package/dist/src/components/tabs/Tabs.astro +4 -2
  24. package/dist/src/components/toast/ToastDescription.astro +21 -0
  25. package/dist/src/components/toast/ToastItem.astro +54 -0
  26. package/dist/src/components/toast/ToastTemplate.astro +25 -0
  27. package/dist/src/components/toast/ToastTitle.astro +57 -0
  28. package/dist/src/components/toast/Toaster.astro +982 -0
  29. package/dist/src/components/toast/index.ts +29 -0
  30. package/dist/src/components/toast/toast-manager.ts +216 -0
  31. package/dist/src/components/toggle/Toggle.astro +4 -2
  32. package/dist/src/components/tooltip/Tooltip.astro +4 -2
  33. package/dist/src/components/tooltip/TooltipContent.astro +1 -1
  34. package/dist/src/components/video/Video.astro +120 -0
  35. package/dist/src/components/video/index.ts +9 -0
  36. package/package.json +5 -3
@@ -0,0 +1,982 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+ import { tv } from "tailwind-variants";
4
+
5
+ import ToastTemplate from "./ToastTemplate.astro";
6
+
7
+ export const toastViewport = tv({
8
+ base: [
9
+ "starwind-toast-viewport fixed z-50 flex w-80 outline-none",
10
+ "data-[position=bottom-center]:bottom-4 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2",
11
+ "data-[position=bottom-left]:bottom-4 data-[position=bottom-left]:left-4",
12
+ "data-[position=bottom-right]:right-4 data-[position=bottom-right]:bottom-4",
13
+ "data-[position=top-center]:top-4 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2",
14
+ "data-[position=top-left]:top-4 data-[position=top-left]:left-4",
15
+ "data-[position=top-right]:top-4 data-[position=top-right]:right-4",
16
+ ],
17
+ });
18
+
19
+ type Position =
20
+ | "top-left"
21
+ | "top-center"
22
+ | "top-right"
23
+ | "bottom-left"
24
+ | "bottom-center"
25
+ | "bottom-right";
26
+
27
+ type Props = HTMLAttributes<"div"> & {
28
+ position?: Position;
29
+ limit?: number;
30
+ gap?: string;
31
+ peek?: string;
32
+ duration?: number;
33
+ };
34
+
35
+ const {
36
+ class: className,
37
+ position = "bottom-right",
38
+ gap = "0.5rem",
39
+ peek = "1rem",
40
+ limit = 3,
41
+ duration = 5000,
42
+ ...rest
43
+ } = Astro.props;
44
+ ---
45
+
46
+ <div
47
+ class={toastViewport({ class: className })}
48
+ data-slot="toast-viewport"
49
+ data-position={position}
50
+ data-limit={limit}
51
+ data-duration={duration}
52
+ role="region"
53
+ aria-live="polite"
54
+ aria-atomic="false"
55
+ aria-relevant="additions text"
56
+ aria-label="Notifications"
57
+ tabindex={-1}
58
+ style={`--gap: ${gap}; --peek: ${peek};`}
59
+ {...rest}
60
+ >
61
+ <!-- Hidden templates for each variant - JS will clone these -->
62
+ <slot>
63
+ <ToastTemplate variant="default" />
64
+ <ToastTemplate variant="success" />
65
+ <ToastTemplate variant="error" />
66
+ <ToastTemplate variant="warning" />
67
+ <ToastTemplate variant="info" />
68
+ <ToastTemplate variant="loading" />
69
+ </slot>
70
+ </div>
71
+
72
+ <style is:global>
73
+ .starwind-toast-viewport [data-slot="toast"] {
74
+ --scale: max(0.9, 1 - (var(--toast-index, 0) * 0.05));
75
+
76
+ z-index: calc(1000 - var(--toast-index, 0));
77
+ transform: translateX(var(--toast-swipe-movement-x, 0px))
78
+ translateY(calc(var(--toast-swipe-movement-y, 0px) - (var(--toast-index, 0) * var(--peek))))
79
+ scale(var(--scale));
80
+ }
81
+
82
+ .starwind-toast-viewport [data-slot="toast"][data-swiping] {
83
+ transition: none;
84
+ }
85
+
86
+ .starwind-toast-viewport [data-slot="toast"][data-expanded] {
87
+ --scale: 1;
88
+
89
+ transform: translateX(var(--toast-swipe-movement-x, 0px))
90
+ translateY(calc(var(--toast-swipe-movement-y, 0px) - var(--toast-offset-y, 0px)));
91
+ }
92
+
93
+ /* Exit animations by swipe direction */
94
+ .starwind-toast-viewport [data-slot="toast"][data-state="closed"][data-swipe-direction="down"] {
95
+ transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
96
+ opacity: 0;
97
+ }
98
+
99
+ .starwind-toast-viewport [data-slot="toast"][data-state="closed"][data-swipe-direction="up"] {
100
+ transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
101
+ opacity: 0;
102
+ }
103
+
104
+ .starwind-toast-viewport [data-slot="toast"][data-state="closed"][data-swipe-direction="right"] {
105
+ transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%))
106
+ translateY(calc(var(--toast-offset-y, 0px) * -1));
107
+ opacity: 0;
108
+ }
109
+
110
+ .starwind-toast-viewport [data-slot="toast"][data-state="closed"][data-swipe-direction="left"] {
111
+ transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%))
112
+ translateY(calc(var(--toast-offset-y, 0px) * -1));
113
+ opacity: 0;
114
+ }
115
+
116
+ /* Position-specific overrides for top positions */
117
+ .starwind-toast-viewport[data-position^="top"] [data-slot="toast"] {
118
+ top: 0;
119
+ bottom: auto;
120
+ transform-origin: top center;
121
+ transform: translateX(var(--toast-swipe-movement-x, 0px))
122
+ translateY(calc(var(--toast-swipe-movement-y, 0px) + (var(--toast-index, 0) * var(--peek))))
123
+ scale(var(--scale));
124
+ }
125
+
126
+ .starwind-toast-viewport[data-position^="top"] [data-slot="toast"][data-expanded] {
127
+ transform: translateX(var(--toast-swipe-movement-x, 0px))
128
+ translateY(calc(var(--toast-swipe-movement-y, 0px) + var(--toast-offset-y, 0px)));
129
+ }
130
+
131
+ .starwind-toast-viewport[data-position^="top"]
132
+ [data-slot="toast"][data-state="closed"][data-swipe-direction="up"] {
133
+ transform: translateY(calc(var(--toast-swipe-movement-y, 0px) - 150%));
134
+ }
135
+
136
+ .starwind-toast-viewport[data-position^="top"]
137
+ [data-slot="toast"][data-state="closed"][data-swipe-direction="down"] {
138
+ transform: translateY(calc(var(--toast-swipe-movement-y, 0px) + 150%));
139
+ }
140
+
141
+ .starwind-toast-viewport[data-position^="top"]
142
+ [data-slot="toast"][data-state="closed"][data-swipe-direction="left"] {
143
+ transform: translateX(calc(var(--toast-swipe-movement-x, 0px) - 150%))
144
+ translateY(var(--toast-offset-y, 0px));
145
+ opacity: 0;
146
+ }
147
+
148
+ .starwind-toast-viewport[data-position^="top"]
149
+ [data-slot="toast"][data-state="closed"][data-swipe-direction="right"] {
150
+ transform: translateX(calc(var(--toast-swipe-movement-x, 0px) + 150%))
151
+ translateY(var(--toast-offset-y, 0px));
152
+ opacity: 0;
153
+ }
154
+
155
+ /* Entry animation - starting state (must come after base transforms) */
156
+ .starwind-toast-viewport [data-slot="toast"][data-starting-style] {
157
+ transform: translateY(150%);
158
+ opacity: 0;
159
+ }
160
+
161
+ /* Default exit animation - slide down (for non-swipe closes) */
162
+ .starwind-toast-viewport [data-slot="toast"][data-state="closed"]:not([data-swipe-direction]) {
163
+ transform: translateY(150%);
164
+ opacity: 0;
165
+ }
166
+
167
+ /* Top position entry - slide from top */
168
+ .starwind-toast-viewport[data-position^="top"] [data-slot="toast"][data-starting-style] {
169
+ transform: translateY(-150%);
170
+ opacity: 0;
171
+ }
172
+
173
+ /* Top position default exit - slide up */
174
+ .starwind-toast-viewport[data-position^="top"]
175
+ [data-slot="toast"][data-state="closed"]:not([data-swipe-direction]) {
176
+ transform: translateY(-150%);
177
+ opacity: 0;
178
+ }
179
+ </style>
180
+
181
+ <script>
182
+ type Variant = "default" | "success" | "error" | "warning" | "info" | "loading";
183
+
184
+ interface ToastOptions {
185
+ id?: string;
186
+ title?: string;
187
+ description?: string;
188
+ variant?: Variant;
189
+ duration?: number;
190
+ action?: {
191
+ label: string;
192
+ onClick: () => void;
193
+ };
194
+ onClose?: () => void;
195
+ onRemove?: () => void;
196
+ }
197
+
198
+ interface ToastState extends ToastOptions {
199
+ id: string;
200
+ element?: HTMLElement;
201
+ timeoutId?: number;
202
+ height?: number;
203
+ closing?: boolean;
204
+ // Swipe state
205
+ swiping?: boolean;
206
+ swipeX?: number;
207
+ swipeY?: number;
208
+ swipeDirection?: "up" | "down" | "left" | "right";
209
+ }
210
+
211
+ // Swipe constants
212
+ const SWIPE_THRESHOLD = 40;
213
+ const DAMPING_FACTOR = 0.1;
214
+ const LONG_PRESS_DURATION = 300;
215
+
216
+ type SwipeDirection = "up" | "down" | "left" | "right";
217
+
218
+ class StarwindToastManager {
219
+ readonly viewport: HTMLElement;
220
+ private toasts: ToastState[] = [];
221
+ private queue: ToastState[] = [];
222
+ private limit: number;
223
+ private defaultDuration: number;
224
+ private expanded: boolean = false;
225
+ private expandedByTouch: boolean = false;
226
+ private counter: number = 0;
227
+ private swipeDirections: SwipeDirection[] = ["down", "right"];
228
+ private destroyed: boolean = false;
229
+
230
+ // Swipe tracking
231
+ private activeSwipe: {
232
+ toast: ToastState;
233
+ startX: number;
234
+ startY: number;
235
+ startTime: number;
236
+ didMove: boolean;
237
+ lockedDirection: "horizontal" | "vertical" | null;
238
+ } | null = null;
239
+
240
+ // Bound event handlers for cleanup
241
+ private boundHandlers: {
242
+ mouseenter: () => void;
243
+ mouseleave: (e: MouseEvent) => void;
244
+ focusin: () => void;
245
+ focusout: (e: FocusEvent) => void;
246
+ documentMousemove: (e: MouseEvent) => void;
247
+ documentPointerdown: (e: PointerEvent) => void;
248
+ };
249
+
250
+ constructor(viewport: HTMLElement) {
251
+ this.viewport = viewport;
252
+ this.limit = Number(viewport.dataset.limit) || 3;
253
+ this.defaultDuration = Number(viewport.dataset.duration) || 5000;
254
+
255
+ // Determine swipe directions based on position
256
+ const position = viewport.dataset.position || "bottom-right";
257
+ if (position.startsWith("top")) {
258
+ this.swipeDirections = ["up", "right"];
259
+ }
260
+
261
+ // Initialize bound handlers
262
+ this.boundHandlers = {
263
+ mouseenter: this.handleMouseenter.bind(this),
264
+ mouseleave: this.handleMouseleave.bind(this),
265
+ focusin: this.handleFocusin.bind(this),
266
+ focusout: this.handleFocusout.bind(this),
267
+ documentMousemove: this.handleDocumentMousemove.bind(this),
268
+ documentPointerdown: this.handleDocumentPointerdown.bind(this),
269
+ };
270
+
271
+ this.setupEvents();
272
+ }
273
+
274
+ private handleMouseenter(): void {
275
+ this.expanded = true;
276
+ this.updateExpanded();
277
+ this.pauseAllTimers();
278
+ }
279
+
280
+ private handleMouseleave(e: MouseEvent): void {
281
+ // Don't collapse if expanded by touch - let the document pointerdown handle it
282
+ if (this.expandedByTouch) return;
283
+
284
+ // Check if mouse is still within viewport bounds (handles case where toast is removed)
285
+ const rect = this.viewport.getBoundingClientRect();
286
+ const isStillInside =
287
+ e.clientX >= rect.left &&
288
+ e.clientX <= rect.right &&
289
+ e.clientY >= rect.top &&
290
+ e.clientY <= rect.bottom;
291
+
292
+ if (!isStillInside) {
293
+ this.expanded = false;
294
+ this.updateExpanded();
295
+ this.resumeAllTimers();
296
+ }
297
+ }
298
+
299
+ private handleFocusin(): void {
300
+ this.expanded = true;
301
+ this.updateExpanded();
302
+ this.pauseAllTimers();
303
+ }
304
+
305
+ private handleFocusout(e: FocusEvent): void {
306
+ // Don't collapse if expanded by touch - let the document pointerdown handle it
307
+ if (this.expandedByTouch) return;
308
+
309
+ // Check if mouse is still over viewport before collapsing
310
+ // This handles the case where focus leaves because a toast was closed
311
+ const rect = this.viewport.getBoundingClientRect();
312
+ const mouseEvent = (window as any).__lastMousePosition;
313
+ const isMouseInside =
314
+ mouseEvent &&
315
+ mouseEvent.x >= rect.left &&
316
+ mouseEvent.x <= rect.right &&
317
+ mouseEvent.y >= rect.top &&
318
+ mouseEvent.y <= rect.bottom;
319
+
320
+ if (!this.viewport.contains(e.relatedTarget as Node) && !isMouseInside) {
321
+ this.expanded = false;
322
+ this.updateExpanded();
323
+ this.resumeAllTimers();
324
+ }
325
+ }
326
+
327
+ private handleDocumentMousemove(e: MouseEvent): void {
328
+ (window as any).__lastMousePosition = { x: e.clientX, y: e.clientY };
329
+ }
330
+
331
+ private handleDocumentPointerdown(e: PointerEvent): void {
332
+ if (!this.expandedByTouch || !this.expanded) return;
333
+
334
+ const target = e.target as HTMLElement;
335
+ if (!this.viewport.contains(target)) {
336
+ this.expandedByTouch = false;
337
+ this.expanded = false;
338
+ this.updateExpanded();
339
+ this.resumeAllTimers();
340
+ }
341
+ }
342
+
343
+ private setupEvents(): void {
344
+ this.viewport.addEventListener("mouseenter", this.boundHandlers.mouseenter);
345
+ this.viewport.addEventListener("mouseleave", this.boundHandlers.mouseleave);
346
+ this.viewport.addEventListener("focusin", this.boundHandlers.focusin);
347
+ this.viewport.addEventListener("focusout", this.boundHandlers.focusout);
348
+ document.addEventListener("mousemove", this.boundHandlers.documentMousemove);
349
+ document.addEventListener("pointerdown", this.boundHandlers.documentPointerdown);
350
+ }
351
+
352
+ private removeEvents(): void {
353
+ this.viewport.removeEventListener("mouseenter", this.boundHandlers.mouseenter);
354
+ this.viewport.removeEventListener("mouseleave", this.boundHandlers.mouseleave);
355
+ this.viewport.removeEventListener("focusin", this.boundHandlers.focusin);
356
+ this.viewport.removeEventListener("focusout", this.boundHandlers.focusout);
357
+ document.removeEventListener("mousemove", this.boundHandlers.documentMousemove);
358
+ document.removeEventListener("pointerdown", this.boundHandlers.documentPointerdown);
359
+ }
360
+
361
+ /**
362
+ * Destroy the manager and clean up all resources
363
+ */
364
+ public destroy(): void {
365
+ if (this.destroyed) return;
366
+ this.destroyed = true;
367
+
368
+ // Clear all toasts and their timers
369
+ this.toasts.forEach((toast) => {
370
+ if (toast.timeoutId) {
371
+ clearTimeout(toast.timeoutId);
372
+ }
373
+ toast.element?.remove();
374
+ });
375
+ this.toasts = [];
376
+ this.queue = [];
377
+
378
+ // Remove all event listeners
379
+ this.removeEvents();
380
+
381
+ // Clear active swipe state
382
+ this.activeSwipe = null;
383
+
384
+ // Null out the scoped namespace entry
385
+ if ((window as any).__starwind__?.toast === this) {
386
+ (window as any).__starwind__.toast = null;
387
+ }
388
+ }
389
+
390
+ private updateExpanded(): void {
391
+ // Set expanded state on viewport
392
+ if (this.expanded) {
393
+ this.viewport.setAttribute("data-expanded", "");
394
+ // Calculate total height needed for expanded state
395
+ this.updateViewportHeight();
396
+ } else {
397
+ this.viewport.removeAttribute("data-expanded");
398
+ this.viewport.style.removeProperty("height");
399
+ }
400
+
401
+ this.toasts.forEach((toast) => {
402
+ if (toast.element) {
403
+ const contentEl = toast.element.querySelector('[data-slot="toast-content"]');
404
+ if (this.expanded) {
405
+ toast.element.setAttribute("data-expanded", "");
406
+ contentEl?.setAttribute("data-expanded", "");
407
+ } else {
408
+ toast.element.removeAttribute("data-expanded");
409
+ contentEl?.removeAttribute("data-expanded");
410
+ }
411
+ }
412
+ });
413
+ this.updatePositions();
414
+ }
415
+
416
+ private updateViewportHeight(): void {
417
+ if (!this.expanded || this.toasts.length === 0) return;
418
+
419
+ const gapValue = this.getCssPixelValue("--gap");
420
+ const visibleToasts = this.toasts.slice(0, this.limit);
421
+ const totalHeight = visibleToasts.reduce((sum, toast) => sum + (toast.height || 0), 0);
422
+ const totalGaps = (visibleToasts.length - 1) * gapValue;
423
+
424
+ this.viewport.style.height = `${totalHeight + totalGaps}px`;
425
+ }
426
+
427
+ private getCssPixelValue(property: string): number {
428
+ const value = getComputedStyle(this.viewport).getPropertyValue(property).trim();
429
+ if (!value) return 16;
430
+
431
+ // Create a temporary element to convert CSS value to pixels
432
+ const temp = document.createElement("div");
433
+ temp.style.position = "absolute";
434
+ temp.style.visibility = "hidden";
435
+ temp.style.width = value;
436
+ document.body.appendChild(temp);
437
+ const pixels = temp.offsetWidth;
438
+ document.body.removeChild(temp);
439
+ return pixels || 16;
440
+ }
441
+
442
+ private pauseAllTimers(): void {
443
+ this.toasts.forEach((toast) => {
444
+ if (toast.timeoutId) {
445
+ clearTimeout(toast.timeoutId);
446
+ toast.timeoutId = undefined;
447
+ }
448
+ });
449
+ }
450
+
451
+ private resumeAllTimers(): void {
452
+ this.toasts.forEach((toast) => {
453
+ const duration = toast.duration ?? this.defaultDuration;
454
+ if (duration > 0) {
455
+ toast.timeoutId = window.setTimeout(() => {
456
+ this.close(toast.id);
457
+ }, duration);
458
+ }
459
+ });
460
+ }
461
+
462
+ /**
463
+ * Add a new toast
464
+ */
465
+ public add(options: ToastOptions): string {
466
+ const id = options.id || `toast-${++this.counter}`;
467
+ const duration = options.duration ?? this.defaultDuration;
468
+
469
+ const toastState: ToastState = {
470
+ ...options,
471
+ id,
472
+ duration,
473
+ };
474
+
475
+ // Always add the new toast at the front (newest first)
476
+ this.renderToast(toastState);
477
+ this.toasts.unshift(toastState);
478
+
479
+ // updatePositions will handle hiding toasts beyond the limit via data-limited
480
+ // They'll become visible again when other toasts close
481
+
482
+ return id;
483
+ }
484
+
485
+ /**
486
+ * Update an existing toast
487
+ */
488
+ public update(id: string, options: Partial<ToastOptions>): void {
489
+ const toastIndex = this.toasts.findIndex((t) => t.id === id);
490
+ if (toastIndex === -1) return;
491
+
492
+ const toast = this.toasts[toastIndex];
493
+ const oldVariant = toast.variant;
494
+ Object.assign(toast, options);
495
+
496
+ // If variant changed, we need to re-render the toast with new template
497
+ if (options.variant && options.variant !== oldVariant && toast.element) {
498
+ this.rerenderToast(toast);
499
+ } else if (toast.element) {
500
+ this.updateToastContent(toast);
501
+ }
502
+
503
+ // Restart timer if duration changed or variant changed (new default duration)
504
+ if (toast.element && (options.duration !== undefined || options.variant)) {
505
+ if (toast.timeoutId) {
506
+ clearTimeout(toast.timeoutId);
507
+ toast.timeoutId = undefined;
508
+ }
509
+ const duration = toast.duration ?? this.defaultDuration;
510
+ if (duration > 0 && !this.expanded) {
511
+ toast.timeoutId = window.setTimeout(() => {
512
+ this.close(toast.id);
513
+ }, duration);
514
+ }
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Close a toast
520
+ */
521
+ public close(id: string): void {
522
+ const toast = this.toasts.find((t) => t.id === id);
523
+ if (!toast) return;
524
+
525
+ // Guard against closing a toast that's already closing
526
+ if (toast.closing) return;
527
+ toast.closing = true;
528
+
529
+ if (toast.timeoutId) {
530
+ clearTimeout(toast.timeoutId);
531
+ toast.timeoutId = undefined;
532
+ }
533
+
534
+ toast.onClose?.();
535
+
536
+ if (toast.element) {
537
+ toast.element.setAttribute("data-state", "closed");
538
+
539
+ setTimeout(() => {
540
+ toast.element?.remove();
541
+ toast.onRemove?.();
542
+
543
+ // Find current index (may have changed since close was called)
544
+ const currentIndex = this.toasts.findIndex((t) => t.id === id);
545
+ if (currentIndex !== -1) {
546
+ this.toasts.splice(currentIndex, 1);
547
+ }
548
+ this.updatePositions();
549
+ }, 200);
550
+ } else {
551
+ // No element, just remove from array immediately
552
+ const index = this.toasts.findIndex((t) => t.id === id);
553
+ if (index !== -1) {
554
+ this.toasts.splice(index, 1);
555
+ }
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Close all toasts
561
+ */
562
+ public closeAll(): void {
563
+ [...this.toasts].forEach((toast) => this.close(toast.id));
564
+ this.queue = [];
565
+ }
566
+
567
+ private renderToast(toast: ToastState): void {
568
+ const variant = toast.variant || "default";
569
+ const template = this.viewport.querySelector(
570
+ `template[data-toast-template="${variant}"]`,
571
+ ) as HTMLTemplateElement;
572
+
573
+ if (!template) {
574
+ console.error(`Toast template for variant "${variant}" not found`);
575
+ return;
576
+ }
577
+
578
+ const clone = template.content.cloneNode(true) as DocumentFragment;
579
+ const element = clone.firstElementChild as HTMLElement;
580
+
581
+ if (!element) return;
582
+
583
+ element.setAttribute("data-toast-id", toast.id);
584
+ element.setAttribute("data-starting-style", "");
585
+
586
+ // Update title
587
+ const titleEl = element.querySelector("[data-toast-title]");
588
+ const titleTextEl = element.querySelector("[data-toast-title-text]");
589
+ if (titleEl) {
590
+ if (toast.title) {
591
+ if (titleTextEl) titleTextEl.textContent = toast.title;
592
+ } else {
593
+ titleEl.remove();
594
+ }
595
+ }
596
+
597
+ // Update description
598
+ const descEl = element.querySelector("[data-toast-description]");
599
+ if (descEl) {
600
+ if (toast.description) {
601
+ descEl.textContent = toast.description;
602
+ } else {
603
+ descEl.remove();
604
+ }
605
+ }
606
+
607
+ // Setup close button
608
+ const closeBtn = element.querySelector('[data-slot="toast-close"]');
609
+ closeBtn?.addEventListener("click", () => this.close(toast.id));
610
+
611
+ // Setup swipe handlers
612
+ this.setupSwipeHandlers(element, toast);
613
+
614
+ // Initialize swipe CSS variables
615
+ element.style.setProperty("--toast-swipe-movement-x", "0px");
616
+ element.style.setProperty("--toast-swipe-movement-y", "0px");
617
+
618
+ // Insert at beginning (newest on top visually)
619
+ this.viewport.insertBefore(element, this.viewport.firstElementChild);
620
+ toast.element = element;
621
+
622
+ // Trigger entry animation
623
+ requestAnimationFrame(() => {
624
+ toast.height = element.offsetHeight;
625
+ this.updatePositions();
626
+
627
+ // Remove starting style after a frame to trigger animation
628
+ requestAnimationFrame(() => {
629
+ element.removeAttribute("data-starting-style");
630
+ });
631
+ });
632
+
633
+ // Start auto-dismiss timer
634
+ const duration = toast.duration ?? this.defaultDuration;
635
+ if (duration > 0 && !this.expanded) {
636
+ toast.timeoutId = window.setTimeout(() => {
637
+ this.close(toast.id);
638
+ }, duration);
639
+ }
640
+ }
641
+
642
+ private updateToastContent(toast: ToastState): void {
643
+ if (!toast.element) return;
644
+
645
+ const titleEl = toast.element.querySelector("[data-toast-title]");
646
+ const descEl = toast.element.querySelector("[data-toast-description]");
647
+
648
+ if (titleEl && toast.title) {
649
+ const titleTextEl = titleEl.querySelector("[data-toast-title-text]");
650
+ if (titleTextEl) titleTextEl.textContent = toast.title;
651
+ }
652
+ if (descEl && toast.description) {
653
+ descEl.textContent = toast.description;
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Re-render a toast with a new template (for variant changes)
659
+ */
660
+ private rerenderToast(toast: ToastState): void {
661
+ if (!toast.element) return;
662
+
663
+ const variant = toast.variant || "default";
664
+ const template = this.viewport.querySelector(
665
+ `template[data-toast-template="${variant}"]`,
666
+ ) as HTMLTemplateElement;
667
+
668
+ if (!template) {
669
+ console.error(`Toast template for variant "${variant}" not found`);
670
+ return;
671
+ }
672
+
673
+ const clone = template.content.cloneNode(true) as DocumentFragment;
674
+ const newElement = clone.firstElementChild as HTMLElement;
675
+
676
+ if (!newElement) return;
677
+
678
+ // Copy over attributes and state from old element
679
+ newElement.setAttribute("data-toast-id", toast.id);
680
+ newElement.setAttribute("data-state", "open");
681
+ if (this.expanded) {
682
+ newElement.setAttribute("data-expanded", "");
683
+ }
684
+
685
+ // Copy CSS variables
686
+ const oldStyle = toast.element.style;
687
+ newElement.style.setProperty("--toast-index", oldStyle.getPropertyValue("--toast-index"));
688
+ newElement.style.setProperty(
689
+ "--toast-offset-y",
690
+ oldStyle.getPropertyValue("--toast-offset-y"),
691
+ );
692
+ newElement.style.setProperty("--toast-swipe-movement-x", "0px");
693
+ newElement.style.setProperty("--toast-swipe-movement-y", "0px");
694
+
695
+ // Update title
696
+ const titleTextEl = newElement.querySelector("[data-toast-title-text]");
697
+ if (titleTextEl && toast.title) {
698
+ titleTextEl.textContent = toast.title;
699
+ }
700
+
701
+ // Update description
702
+ const descEl = newElement.querySelector("[data-toast-description]");
703
+ if (descEl && toast.description) {
704
+ descEl.textContent = toast.description;
705
+ }
706
+
707
+ // Setup close button
708
+ const closeBtn = newElement.querySelector('[data-slot="toast-close"]');
709
+ closeBtn?.addEventListener("click", () => this.close(toast.id));
710
+
711
+ // Setup swipe handlers
712
+ this.setupSwipeHandlers(newElement, toast);
713
+
714
+ // Replace old element with new one
715
+ toast.element.replaceWith(newElement);
716
+ toast.element = newElement;
717
+
718
+ // Update height
719
+ toast.height = newElement.offsetHeight;
720
+ }
721
+
722
+ private updatePositions(): void {
723
+ let cumulativeOffset = 0;
724
+
725
+ // Iterate through toasts - index 0 is newest (front)
726
+ this.toasts.forEach((toast, index) => {
727
+ if (!toast.element) return;
728
+
729
+ const contentEl = toast.element.querySelector('[data-slot="toast-content"]');
730
+
731
+ // Set CSS variables for this toast
732
+ toast.element.style.setProperty("--toast-index", String(index));
733
+ toast.element.style.setProperty("--toast-offset-y", `${cumulativeOffset}px`);
734
+
735
+ // Set data-behind on content for toasts behind the frontmost
736
+ if (index > 0) {
737
+ contentEl?.setAttribute("data-behind", "");
738
+ } else {
739
+ contentEl?.removeAttribute("data-behind");
740
+ }
741
+
742
+ // Mark as limited if beyond limit
743
+ if (index >= this.limit) {
744
+ toast.element.setAttribute("data-limited", "");
745
+ } else {
746
+ toast.element.removeAttribute("data-limited");
747
+ }
748
+
749
+ // Accumulate offset for expanded state (height + gap)
750
+ const gapValue = this.getCssPixelValue("--gap");
751
+ cumulativeOffset += (toast.height || 0) + gapValue;
752
+ });
753
+ }
754
+
755
+ // Swipe handlers
756
+ private handlePointerDown(e: PointerEvent, toast: ToastState): void {
757
+ if (e.button !== 0) return;
758
+
759
+ const target = e.target as HTMLElement;
760
+ const isInteractive = target.closest(
761
+ 'button, a, input, textarea, [role="button"], [data-swipe-ignore]',
762
+ );
763
+ if (isInteractive) return;
764
+
765
+ // Treat touch/pointer down as hover to expand stacked toasts
766
+ // Always mark as touch-expanded for long press detection
767
+ this.expandedByTouch = true;
768
+ if (!this.expanded) {
769
+ this.expanded = true;
770
+ this.updateExpanded();
771
+ this.pauseAllTimers();
772
+ }
773
+
774
+ this.activeSwipe = {
775
+ toast,
776
+ startX: e.clientX,
777
+ startY: e.clientY,
778
+ startTime: Date.now(),
779
+ didMove: false,
780
+ lockedDirection: null,
781
+ };
782
+
783
+ toast.swiping = true;
784
+ toast.swipeX = 0;
785
+ toast.swipeY = 0;
786
+ toast.element?.setAttribute("data-swiping", "");
787
+ toast.element?.setPointerCapture(e.pointerId);
788
+
789
+ // Pause timer while swiping
790
+ if (toast.timeoutId) {
791
+ clearTimeout(toast.timeoutId);
792
+ toast.timeoutId = undefined;
793
+ }
794
+ }
795
+
796
+ private handlePointerMove(e: PointerEvent): void {
797
+ if (!this.activeSwipe) return;
798
+
799
+ e.preventDefault();
800
+
801
+ const { toast, startX, startY } = this.activeSwipe;
802
+ const deltaX = e.clientX - startX;
803
+ const deltaY = e.clientY - startY;
804
+
805
+ // Lock direction on first significant movement
806
+ if (!this.activeSwipe.lockedDirection) {
807
+ const absX = Math.abs(deltaX);
808
+ const absY = Math.abs(deltaY);
809
+ if (absX > 5 || absY > 5) {
810
+ this.activeSwipe.didMove = true;
811
+ const hasHorizontal =
812
+ this.swipeDirections.includes("left") || this.swipeDirections.includes("right");
813
+ const hasVertical =
814
+ this.swipeDirections.includes("up") || this.swipeDirections.includes("down");
815
+ if (hasHorizontal && hasVertical) {
816
+ this.activeSwipe.lockedDirection = absX > absY ? "horizontal" : "vertical";
817
+ } else if (hasHorizontal) {
818
+ this.activeSwipe.lockedDirection = "horizontal";
819
+ } else {
820
+ this.activeSwipe.lockedDirection = "vertical";
821
+ }
822
+ }
823
+ }
824
+
825
+ // Apply damping for non-allowed directions
826
+ let swipeX = 0;
827
+ let swipeY = 0;
828
+
829
+ if (this.activeSwipe.lockedDirection === "horizontal") {
830
+ if (this.swipeDirections.includes("right") && deltaX > 0) {
831
+ swipeX = deltaX;
832
+ } else if (this.swipeDirections.includes("left") && deltaX < 0) {
833
+ swipeX = deltaX;
834
+ } else {
835
+ swipeX = deltaX * DAMPING_FACTOR;
836
+ }
837
+ } else if (this.activeSwipe.lockedDirection === "vertical") {
838
+ if (this.swipeDirections.includes("down") && deltaY > 0) {
839
+ swipeY = deltaY;
840
+ } else if (this.swipeDirections.includes("up") && deltaY < 0) {
841
+ swipeY = deltaY;
842
+ } else {
843
+ swipeY = deltaY * DAMPING_FACTOR;
844
+ }
845
+ }
846
+
847
+ toast.swipeX = swipeX;
848
+ toast.swipeY = swipeY;
849
+ toast.element?.style.setProperty("--toast-swipe-movement-x", `${swipeX}px`);
850
+ toast.element?.style.setProperty("--toast-swipe-movement-y", `${swipeY}px`);
851
+ }
852
+
853
+ private handlePointerUp(e: PointerEvent): void {
854
+ if (!this.activeSwipe) return;
855
+
856
+ const { toast } = this.activeSwipe;
857
+ toast.element?.releasePointerCapture(e.pointerId);
858
+ toast.element?.removeAttribute("data-swiping");
859
+ toast.swiping = false;
860
+
861
+ const swipeX = toast.swipeX || 0;
862
+ const swipeY = toast.swipeY || 0;
863
+
864
+ // Check if swipe exceeds threshold based on locked direction
865
+ let shouldClose = false;
866
+ let direction: SwipeDirection | undefined;
867
+ const lockedDir = this.activeSwipe.lockedDirection;
868
+
869
+ if (lockedDir === "horizontal") {
870
+ if (swipeX > SWIPE_THRESHOLD && this.swipeDirections.includes("right")) {
871
+ shouldClose = true;
872
+ direction = "right";
873
+ } else if (swipeX < -SWIPE_THRESHOLD && this.swipeDirections.includes("left")) {
874
+ shouldClose = true;
875
+ direction = "left";
876
+ }
877
+ } else if (lockedDir === "vertical") {
878
+ if (swipeY > SWIPE_THRESHOLD && this.swipeDirections.includes("down")) {
879
+ shouldClose = true;
880
+ direction = "down";
881
+ } else if (swipeY < -SWIPE_THRESHOLD && this.swipeDirections.includes("up")) {
882
+ shouldClose = true;
883
+ direction = "up";
884
+ }
885
+ }
886
+
887
+ // Check if this was a long press or if user moved (swiped)
888
+ const pressDuration = Date.now() - this.activeSwipe.startTime;
889
+ const isLongPress = pressDuration >= LONG_PRESS_DURATION;
890
+ const didMove = this.activeSwipe.didMove;
891
+
892
+ // Check if pointer is outside viewport bounds
893
+ const rect = this.viewport.getBoundingClientRect();
894
+ const isOutsideViewport =
895
+ e.clientX < rect.left ||
896
+ e.clientX > rect.right ||
897
+ e.clientY < rect.top ||
898
+ e.clientY > rect.bottom;
899
+
900
+ if (shouldClose && direction) {
901
+ toast.swipeDirection = direction;
902
+ toast.element?.setAttribute("data-swipe-direction", direction);
903
+ this.close(toast.id);
904
+ } else {
905
+ // Reset position
906
+ toast.swipeX = 0;
907
+ toast.swipeY = 0;
908
+ toast.element?.style.setProperty("--toast-swipe-movement-x", "0px");
909
+ toast.element?.style.setProperty("--toast-swipe-movement-y", "0px");
910
+
911
+ // Collapse if: short tap without movement, OR pointer ended outside viewport
912
+ if ((!isLongPress && !didMove && this.expandedByTouch) || isOutsideViewport) {
913
+ this.expandedByTouch = false;
914
+ this.expanded = false;
915
+ this.updateExpanded();
916
+ this.resumeAllTimers();
917
+ } else {
918
+ // Resume timer for this toast only
919
+ const duration = toast.duration ?? this.defaultDuration;
920
+ if (duration > 0 && !this.expanded) {
921
+ toast.timeoutId = window.setTimeout(() => {
922
+ this.close(toast.id);
923
+ }, duration);
924
+ }
925
+ }
926
+ }
927
+
928
+ this.activeSwipe = null;
929
+ }
930
+
931
+ private setupSwipeHandlers(element: HTMLElement, toast: ToastState): void {
932
+ element.addEventListener("pointerdown", (e) => this.handlePointerDown(e, toast));
933
+ element.addEventListener("pointermove", (e) => this.handlePointerMove(e));
934
+ element.addEventListener("pointerup", (e) => this.handlePointerUp(e));
935
+ element.addEventListener("pointercancel", (e) => this.handlePointerUp(e));
936
+
937
+ // Prevent default touch behavior to avoid scroll interference
938
+ element.addEventListener(
939
+ "touchmove",
940
+ (e) => {
941
+ if (this.activeSwipe?.toast === toast) {
942
+ e.preventDefault();
943
+ }
944
+ },
945
+ { passive: false },
946
+ );
947
+ }
948
+ }
949
+
950
+ // Initialize scoped namespace
951
+ (window as any).__starwind__ = (window as any).__starwind__ || {};
952
+
953
+ let currentManager: StarwindToastManager | null = null;
954
+
955
+ const setupToasts = () => {
956
+ // Find the viewport in the current DOM
957
+ const viewport = document.querySelector(".starwind-toast-viewport") as HTMLElement | null;
958
+
959
+ if (!viewport) {
960
+ // No viewport on this page, destroy existing manager and clear references
961
+ currentManager?.destroy();
962
+ currentManager = null;
963
+ return;
964
+ }
965
+
966
+ // Check if we already have a manager for this exact element
967
+ if (currentManager && currentManager.viewport === viewport) {
968
+ return;
969
+ }
970
+
971
+ // Destroy previous manager before creating new one (viewport differs)
972
+ currentManager?.destroy();
973
+
974
+ // Create new manager for the viewport
975
+ currentManager = new StarwindToastManager(viewport);
976
+ (window as any).__starwind__.toast = currentManager;
977
+ };
978
+
979
+ setupToasts();
980
+ document.addEventListener("astro:after-swap", setupToasts);
981
+ document.addEventListener("starwind:init", setupToasts);
982
+ </script>