@soft-toast/vue 1.0.0 → 1.0.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.
@@ -29,6 +29,17 @@ const props = withDefaults(defineProps<ToastContainerProps>(), {
29
29
  const containerRef = ref<HTMLElement | null>(null);
30
30
  const stackRef = ref<HTMLElement | null>(null);
31
31
  const listRef = ref<HTMLElement | null>(null);
32
+ const toastItemRefs = ref<Map<string, InstanceType<typeof ToastItem>>>(
33
+ new Map(),
34
+ );
35
+
36
+ const setToastItemRef = (
37
+ id: string,
38
+ el: InstanceType<typeof ToastItem> | null,
39
+ ) => {
40
+ if (el) toastItemRefs.value.set(id, el);
41
+ else toastItemRefs.value.delete(id);
42
+ };
32
43
 
33
44
  // Get toasts for this container's position
34
45
  const positionToasts = computed(() => {
@@ -62,11 +73,21 @@ onMounted(() => {
62
73
 
63
74
  // Pause all timers when user switches tab / window loses focus
64
75
  document.addEventListener("visibilitychange", handleVisibilityChange);
76
+ // iOS: collapse expanded stack when tapping outside
77
+ document.addEventListener("touchstart", handleOutsideTap, { passive: true });
65
78
 
66
- if (listRef.value && typeof ResizeObserver !== "undefined") {
67
- resizeObserver = new ResizeObserver(() => measureOffsets());
68
- resizeObserver.observe(listRef.value);
79
+ // Must be non-passive to call preventDefault and block page scroll when expanded
80
+ stackRef.value?.addEventListener("touchmove", handleStackTouchMove, {
81
+ passive: false,
82
+ });
83
+
84
+ if (typeof ResizeObserver !== "undefined") {
85
+ listResizeObserver = new ResizeObserver(() => measureOffsets());
86
+ itemResizeObserver = new ResizeObserver(() => measureOffsets());
87
+ if (listRef.value) listResizeObserver.observe(listRef.value);
69
88
  }
89
+ // Set initial height
90
+ nextTick(() => measureOffsets());
70
91
  });
71
92
 
72
93
  onUnmounted(() => {
@@ -74,7 +95,12 @@ onUnmounted(() => {
74
95
  document.removeEventListener("keydown", handleKeydown);
75
96
  }
76
97
  document.removeEventListener("visibilitychange", handleVisibilityChange);
77
- if (resizeObserver) resizeObserver.disconnect();
98
+ document.removeEventListener("touchstart", handleOutsideTap);
99
+ stackRef.value?.removeEventListener("touchmove", handleStackTouchMove);
100
+ if (listResizeObserver) listResizeObserver.disconnect();
101
+ if (itemResizeObserver) itemResizeObserver.disconnect();
102
+ clearCollapseTimer();
103
+ observedItems = new WeakSet<HTMLElement>();
78
104
  });
79
105
 
80
106
  // Re-measure when toast list changes or when a toast starts leaving
@@ -109,6 +135,7 @@ watch(
109
135
 
110
136
  // Position classes
111
137
  const positionClass = computed(() => `soft-toast-container--${props.position}`);
138
+ const isCenterAligned = computed(() => props.position.endsWith("-center"));
112
139
 
113
140
  // Visual Indexing (ignore leaving toasts so the stack closes gaps instantly)
114
141
  const activeToasts = computed(() =>
@@ -121,31 +148,77 @@ const getVisualIndex = (toast: any, realIdx: number) => {
121
148
 
122
149
  // Stack expansion (hover or focus reveals the stack)
123
150
  const isExpanded = ref(false);
151
+ let collapseTimerId: number | null = null;
152
+
153
+ const clearCollapseTimer = () => {
154
+ if (collapseTimerId === null) return;
155
+ window.clearTimeout(collapseTimerId);
156
+ collapseTimerId = null;
157
+ };
158
+
159
+ // DOM cap: keep a bounded, stable window mounted so expanding does not
160
+ // introduce new nodes mid-animation. Collapsed positioning still hides items
161
+ // after the front 3 via opacity/pointer-events.
162
+ const EXPANDED_DOM_CAP = 15;
163
+ const renderedToasts = computed(() => {
164
+ const all = positionToasts.value;
165
+ const windowed = all.slice(0, EXPANDED_DOM_CAP);
166
+ // Keep leaving toasts so their exit animation plays even if they fall
167
+ // outside the normal render window.
168
+ const leaving = all.filter((t) => t.isLeaving);
169
+ const seen = new Set(windowed.map((t) => t.id));
170
+ return [...windowed, ...leaving.filter((t) => !seen.has(t.id))];
171
+ });
124
172
 
125
173
  // Measured cumulative offsets for each item when stack is expanded.
126
- const expandedOffsets = ref<number[]>([]);
174
+ // Keyed by toast id so order of renderedToasts doesn't matter.
175
+ const expandedOffsets = ref<Record<string, number>>({});
127
176
  const frontHeight = ref(0);
128
177
  const totalHeight = ref(0);
129
178
 
179
+ let measureRafId: number | null = null;
130
180
  const measureOffsets = () => {
181
+ if (measureRafId !== null) return; // already scheduled
182
+ measureRafId = requestAnimationFrame(() => {
183
+ measureRafId = null;
184
+ _doMeasureOffsets(false);
185
+ });
186
+ };
187
+
188
+ const hasOffsetChanges = (
189
+ nextOffsets: Record<string, number>,
190
+ nextFrontHeight: number,
191
+ nextTotalHeight: number,
192
+ ) => {
193
+ if (frontHeight.value !== nextFrontHeight) return true;
194
+ if (totalHeight.value !== nextTotalHeight) return true;
195
+ const current = expandedOffsets.value;
196
+ const currentKeys = Object.keys(current);
197
+ const nextKeys = Object.keys(nextOffsets);
198
+ if (currentKeys.length !== nextKeys.length) return true;
199
+ return nextKeys.some((key) => current[key] !== nextOffsets[key]);
200
+ };
201
+
202
+ const _doMeasureOffsets = (forceSync = false) => {
131
203
  if (!listRef.value) return;
132
204
  const items = Array.from(
133
205
  listRef.value.querySelectorAll<HTMLElement>(".soft-toast-item"),
134
206
  );
207
+ observeToastItems(items);
135
208
  const gapPx = props.gap ?? 10;
136
209
  const offsets: number[] = [];
137
210
  let cumulative = 0;
138
211
  let firstActiveHeight = 0;
139
-
212
+
140
213
  for (let i = 0; i < items.length; i++) {
141
214
  offsets.push(cumulative);
142
-
215
+
143
216
  // Only accumulate height if the toast is not leaving
144
217
  if (items[i].getAttribute("data-leaving") !== "true") {
145
218
  cumulative += items[i].offsetHeight + gapPx;
146
219
  // The front toast is the one that has visual index 0
147
220
  const toastId = items[i].dataset.toastId;
148
- const t = positionToasts.value.find(x => x.id === toastId);
221
+ const t = positionToasts.value.find((x) => x.id === toastId);
149
222
  if (t) {
150
223
  const vIdx = getVisualIndex(t, positionToasts.value.indexOf(t));
151
224
  if (vIdx === 0) {
@@ -154,70 +227,113 @@ const measureOffsets = () => {
154
227
  }
155
228
  }
156
229
  }
157
-
158
- expandedOffsets.value = offsets;
159
- frontHeight.value = firstActiveHeight;
160
- totalHeight.value = Math.max(0, cumulative - gapPx);
161
-
230
+
231
+ // Build id → offset map instead of positional array
232
+ const offsetMap: Record<string, number> = {};
233
+ for (let i = 0; i < items.length; i++) {
234
+ const toastId = items[i].dataset.toastId;
235
+ if (toastId) offsetMap[toastId] = offsets[i];
236
+ }
237
+ const nextTotalHeight = Math.max(0, cumulative - gapPx);
238
+ const changed = hasOffsetChanges(offsetMap, firstActiveHeight, nextTotalHeight);
239
+ if (changed) {
240
+ expandedOffsets.value = offsetMap;
241
+ frontHeight.value = firstActiveHeight;
242
+ totalHeight.value = nextTotalHeight;
243
+ }
244
+
162
245
  // Re-clamp scroll if height changes while expanded
163
- if (isExpanded.value && listRef.value) {
246
+ if (isExpanded.value && listRef.value && changed) {
164
247
  clampAndApplyScroll(0); // Applies bounds check without adding new delta
165
248
  }
249
+
250
+ // Sync list height via GSAP — instant set (no tween) when called from measure
251
+ if (listRef.value && changed) {
252
+ const target = isExpanded.value ? totalHeight.value : frontHeight.value;
253
+ gsap.set(listRef.value, { height: target });
254
+ }
255
+
256
+ if (isExpanded.value && (changed || forceSync)) {
257
+ syncExpandedPositions();
258
+ }
259
+ };
260
+
261
+ const syncExpandedPositions = () => {
262
+ renderedToasts.value.forEach((toast) => {
263
+ const item = toastItemRefs.value.get(toast.id);
264
+ if (!item) return;
265
+ item.applyStackPosition(false, expandedOffsets.value[toast.id] ?? 0);
266
+ });
166
267
  };
167
268
 
168
269
  // Custom GSAP Scrolling
169
270
  const currentScrollY = ref(0);
170
271
 
171
- const clampAndApplyScroll = (deltaY: number) => {
272
+ const getScrollBounds = () => {
273
+ const buffer = 120;
274
+ const maxScrollNeeded = Math.max(
275
+ 0,
276
+ totalHeight.value - window.innerHeight + buffer,
277
+ );
278
+
279
+ return stackDirection.value === "up"
280
+ ? { min: 0, max: maxScrollNeeded }
281
+ : { min: -maxScrollNeeded, max: 0 };
282
+ };
283
+
284
+ const applyStackScrollY = (
285
+ nextY: number,
286
+ animate = true,
287
+ duration = 0.18,
288
+ ease = "power2.out",
289
+ ) => {
172
290
  if (!listRef.value) return;
173
-
174
- // Viewport padding buffer
175
- const buffer = 120;
176
- const windowHeight = window.innerHeight;
177
-
178
- // We only need to scroll if the total stack height exceeds the viewport
179
- const maxScrollNeeded = Math.max(0, totalHeight.value - windowHeight + buffer);
180
-
181
- if (stackDirection.value === "up") {
182
- // Stack builds upwards (y goes negative).
183
- // To see higher items, we scroll UP (deltaY < 0), so we translate DOWN (y goes positive).
184
- currentScrollY.value -= deltaY;
185
- currentScrollY.value = Math.max(0, Math.min(currentScrollY.value, maxScrollNeeded));
186
- } else {
187
- // Stack builds downwards (y goes positive).
188
- // To see lower items, we scroll DOWN (deltaY > 0), so we translate UP (y goes negative).
189
- currentScrollY.value -= deltaY;
190
- currentScrollY.value = Math.max(-maxScrollNeeded, Math.min(currentScrollY.value, 0));
291
+ const { min, max } = getScrollBounds();
292
+ currentScrollY.value = Math.max(min, Math.min(nextY, max));
293
+
294
+ if (!animate) {
295
+ gsap.set(listRef.value, {
296
+ y: currentScrollY.value,
297
+ force3D: true,
298
+ overwrite: true,
299
+ });
300
+ return;
191
301
  }
192
-
302
+
193
303
  gsap.to(listRef.value, {
194
304
  y: currentScrollY.value,
195
- duration: 0.4,
196
- ease: "power3.out",
197
- overwrite: "auto"
305
+ duration,
306
+ ease,
307
+ overwrite: "auto",
198
308
  });
199
309
  };
200
310
 
311
+ const clampAndApplyScroll = (deltaY: number, animate = true) => {
312
+ applyStackScrollY(currentScrollY.value - deltaY, animate);
313
+ };
314
+
201
315
  const handleWheel = (e: WheelEvent) => {
202
316
  if (!isExpanded.value) return;
203
-
317
+
204
318
  // Check if the scroll originated from inside a scrollable element (like a long description)
205
319
  let target = e.target as HTMLElement | null;
206
320
  let isInternalScroll = false;
207
-
321
+
208
322
  while (target && target !== stackRef.value) {
209
323
  const style = window.getComputedStyle(target);
210
324
  const overflowY = style.overflowY;
211
-
325
+
212
326
  // Check if the element is capable of vertical scrolling
213
327
  if (overflowY === "auto" || overflowY === "scroll") {
214
328
  // It is a scrollable container. Check if it actually has overflow
215
329
  if (target.scrollHeight > target.clientHeight) {
216
330
  // Check if we can scroll in the direction of the wheel
217
331
  // (deltaY > 0 is scrolling down, deltaY < 0 is scrolling up)
218
- const canScrollDown = Math.ceil(target.scrollTop + target.clientHeight) < target.scrollHeight;
332
+ const canScrollDown =
333
+ Math.ceil(target.scrollTop + target.clientHeight) <
334
+ target.scrollHeight;
219
335
  const canScrollUp = target.scrollTop > 0;
220
-
336
+
221
337
  if ((e.deltaY > 0 && canScrollDown) || (e.deltaY < 0 && canScrollUp)) {
222
338
  isInternalScroll = true;
223
339
  break; // It's a valid internal scroll, stop checking parents
@@ -226,44 +342,169 @@ const handleWheel = (e: WheelEvent) => {
226
342
  }
227
343
  target = target.parentElement;
228
344
  }
229
-
345
+
230
346
  if (isInternalScroll) {
231
347
  // Don't intercept the wheel event; let the internal scrollbar handle it
232
348
  return;
233
349
  }
234
350
 
235
351
  // If we don't need to scroll the main stack, don't prevent default so page can scroll normally
236
- const maxScrollNeeded = Math.max(0, totalHeight.value - window.innerHeight + 120);
352
+ const maxScrollNeeded = Math.max(
353
+ 0,
354
+ totalHeight.value - window.innerHeight + 120,
355
+ );
237
356
  if (maxScrollNeeded <= 0) return;
238
-
357
+
239
358
  e.preventDefault();
240
359
  clampAndApplyScroll(e.deltaY);
241
360
  };
242
361
 
243
- // Dynamic list height: just the front toast when collapsed, full stack when expanded.
244
- const listHeightPx = computed(() => {
245
- if (positionToasts.value.length === 0) return 0;
246
- return isExpanded.value ? totalHeight.value : frontHeight.value;
247
- });
362
+ // Apply list height via GSAP (off main thread, no Vue reactive layout thrashing)
363
+ const applyListHeight = (expanded: boolean) => {
364
+ if (!listRef.value) return;
365
+ const target = expanded ? totalHeight.value : frontHeight.value;
366
+ gsap.to(listRef.value, {
367
+ height: target,
368
+ duration: expanded ? 0.14 : 0.16,
369
+ ease: expanded ? "power3.out" : "power2.out",
370
+ overwrite: "auto",
371
+ });
372
+ };
373
+
374
+ let listResizeObserver: ResizeObserver | null = null;
375
+ let itemResizeObserver: ResizeObserver | null = null;
376
+ let observedItems = new WeakSet<HTMLElement>();
377
+
378
+ const observeToastItems = (items: HTMLElement[]) => {
379
+ if (!itemResizeObserver) return;
380
+ for (const item of items) {
381
+ if (observedItems.has(item)) continue;
382
+ observedItems.add(item);
383
+ itemResizeObserver.observe(item);
384
+ }
385
+ };
248
386
 
249
- let resizeObserver: ResizeObserver | null = null;
250
387
  const handleStackEnter = () => {
251
- measureOffsets();
388
+ clearCollapseTimer();
389
+ if (isExpanded.value) return;
252
390
  isExpanded.value = true;
391
+ nextTick(() => {
392
+ _doMeasureOffsets(true);
393
+ applyListHeight(true);
394
+ });
253
395
  };
254
396
  const handleStackLeave = () => {
255
- isExpanded.value = false;
256
- // Reset GSAP scroll when mouse leaves
257
- currentScrollY.value = 0;
258
- if (listRef.value) {
259
- gsap.to(listRef.value, { y: 0, duration: 0.5, ease: "power3.out", overwrite: "auto" });
397
+ clearCollapseTimer();
398
+ collapseTimerId = window.setTimeout(() => {
399
+ collapseTimerId = null;
400
+ isExpanded.value = false;
401
+ applyListHeight(false);
402
+ // Reset GSAP scroll when mouse leaves
403
+ currentScrollY.value = 0;
404
+ if (listRef.value) {
405
+ gsap.to(listRef.value, {
406
+ y: 0,
407
+ duration: 0.2,
408
+ ease: "power2.out",
409
+ overwrite: "auto",
410
+ });
411
+ }
412
+ }, 80);
413
+ };
414
+
415
+ // iOS/touch: distinguish tap vs scroll before acting
416
+ let tapStartX = 0;
417
+ let tapStartY = 0;
418
+ let touchScrollLastY = 0;
419
+ let touchScrollLastTime = 0;
420
+ let touchScrollVelocity = 0;
421
+ let touchDidScroll = false;
422
+
423
+ const handleStackTouchStart = (e: TouchEvent) => {
424
+ tapStartX = e.touches[0].clientX;
425
+ tapStartY = e.touches[0].clientY;
426
+ touchScrollLastY = e.touches[0].clientY;
427
+ touchScrollLastTime = performance.now();
428
+ touchScrollVelocity = 0;
429
+ touchDidScroll = false;
430
+ if (listRef.value) gsap.killTweensOf(listRef.value);
431
+ };
432
+
433
+ const handleStackTouchEnd = (e: TouchEvent) => {
434
+ if (positionToasts.value.length <= 1) return;
435
+ const dx = Math.abs((e.changedTouches[0]?.clientX ?? tapStartX) - tapStartX);
436
+ const dy = Math.abs((e.changedTouches[0]?.clientY ?? tapStartY) - tapStartY);
437
+ // If finger moved more than 8px it was a scroll/swipe, not a tap
438
+ if (dx > 8 || dy > 8) {
439
+ if (isExpanded.value && touchDidScroll && Math.abs(touchScrollVelocity) > 0.08) {
440
+ const momentumDistance = Math.max(
441
+ -460,
442
+ Math.min(touchScrollVelocity * 560, 460),
443
+ );
444
+ const duration = Math.max(
445
+ 0.32,
446
+ Math.min(Math.abs(momentumDistance) / 780, 0.62),
447
+ );
448
+ applyStackScrollY(
449
+ currentScrollY.value + momentumDistance,
450
+ true,
451
+ duration,
452
+ "power3.out",
453
+ );
454
+ }
455
+ return;
456
+ }
457
+ if (isExpanded.value) {
458
+ handleStackLeave();
459
+ } else {
460
+ e.preventDefault();
461
+ handleStackEnter();
462
+ }
463
+ };
464
+
465
+ const handleOutsideTap = (e: TouchEvent) => {
466
+ if (!isExpanded.value) return;
467
+ if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
468
+ handleStackLeave();
260
469
  }
261
470
  };
262
471
 
472
+ // Prevent page scroll bleeding through AND scroll the list when stack is expanded
473
+ const handleStackTouchMove = (e: TouchEvent) => {
474
+ if (!isExpanded.value) return;
475
+ e.preventDefault();
476
+
477
+ const currentY = e.touches[0].clientY;
478
+ const deltaY = touchScrollLastY - currentY; // positive = scrolling down
479
+ const now = performance.now();
480
+ const dt = Math.max(1, now - touchScrollLastTime);
481
+ touchScrollLastY = currentY;
482
+ touchScrollLastTime = now;
483
+
484
+ const maxScrollNeeded = Math.max(
485
+ 0,
486
+ totalHeight.value - window.innerHeight + 120,
487
+ );
488
+ if (maxScrollNeeded <= 0) return;
489
+
490
+ const previousScrollY = currentScrollY.value;
491
+ clampAndApplyScroll(deltaY, false);
492
+ const instantVelocity = (currentScrollY.value - previousScrollY) / dt;
493
+ touchScrollVelocity = touchScrollVelocity * 0.35 + instantVelocity * 0.65;
494
+ touchDidScroll = true;
495
+ };
496
+
263
497
  // Direction the stack peeks toward (bottom positions peek up, top positions peek down)
264
498
  const stackDirection = computed<"up" | "down">(() =>
265
499
  props.position.includes("bottom") ? "up" : "down",
266
500
  );
501
+
502
+ const listStyle = computed(() => ({
503
+ width: isCenterAligned.value ? "100%" : undefined,
504
+ }));
505
+
506
+ const shouldUseSlots = (toast: any) => props.slotFilter?.(toast) ?? true;
507
+ const isSwipeToDismissEnabled = computed(() => props.swipeToDismiss !== false);
267
508
  </script>
268
509
 
269
510
  <template>
@@ -284,30 +525,55 @@ const stackDirection = computed<"up" | "down">(() =>
284
525
  :data-direction="stackDirection"
285
526
  @mouseenter="handleStackEnter"
286
527
  @mouseleave="handleStackLeave"
528
+ @touchstart.stop="handleStackTouchStart"
529
+ @touchend.stop="handleStackTouchEnd"
287
530
  @wheel="handleWheel"
288
531
  data-lenis-prevent="true"
289
532
  >
290
- <div
291
- ref="listRef"
292
- class="soft-toast-list"
293
- :style="{ height: listHeightPx + 'px' }"
294
- >
295
- <ToastItem
296
- v-for="(toast, idx) in positionToasts"
297
- :key="toast.id"
298
- :toast="toast"
299
- :index="getVisualIndex(toast, idx)"
300
- :total="activeToasts.length"
301
- :expanded="isExpanded"
302
- :expanded-offset="expandedOffsets[idx] ?? 0"
303
- :stack-direction="stackDirection"
304
- :close-button="toast.closeButton ?? closeButton"
305
- :swipe-to-dismiss="swipeToDismiss"
306
- >
307
- <template v-for="(_, name) in $slots" #[name]="slotProps">
308
- <slot :name="name" v-bind="slotProps || {}" />
309
- </template>
310
- </ToastItem>
533
+ <div ref="listRef" class="soft-toast-list" :style="listStyle">
534
+ <template v-for="(toast, idx) in renderedToasts" :key="toast.id">
535
+ <ToastItem
536
+ v-if="shouldUseSlots(toast)"
537
+ :ref="(el) => setToastItemRef(toast.id, el as any)"
538
+ :toast="toast"
539
+ :index="getVisualIndex(toast, idx)"
540
+ :total="activeToasts.length"
541
+ :expanded="isExpanded"
542
+ :expanded-offset="expandedOffsets[toast.id] ?? 0"
543
+ :stack-direction="stackDirection"
544
+ :interactive="isExpanded || getVisualIndex(toast, idx) === 0"
545
+ :close-button="toast.closeButton ?? closeButton"
546
+ :swipe-to-dismiss="isSwipeToDismissEnabled"
547
+ :style="
548
+ isCenterAligned
549
+ ? { left: '50%', marginLeft: '-50%' }
550
+ : undefined
551
+ "
552
+ >
553
+ <template v-for="(_, name) in $slots" #[name]="slotProps">
554
+ <slot :name="name" v-bind="slotProps || {}" />
555
+ </template>
556
+ </ToastItem>
557
+
558
+ <ToastItem
559
+ v-else
560
+ :ref="(el) => setToastItemRef(toast.id, el as any)"
561
+ :toast="toast"
562
+ :index="getVisualIndex(toast, idx)"
563
+ :total="activeToasts.length"
564
+ :expanded="isExpanded"
565
+ :expanded-offset="expandedOffsets[toast.id] ?? 0"
566
+ :stack-direction="stackDirection"
567
+ :interactive="isExpanded || getVisualIndex(toast, idx) === 0"
568
+ :close-button="toast.closeButton ?? closeButton"
569
+ :swipe-to-dismiss="isSwipeToDismissEnabled"
570
+ :style="
571
+ isCenterAligned
572
+ ? { left: '50%', marginLeft: '-50%' }
573
+ : undefined
574
+ "
575
+ />
576
+ </template>
311
577
  </div>
312
578
  </div>
313
579
  </div>
@@ -327,10 +593,16 @@ const stackDirection = computed<"up" | "down">(() =>
327
593
  max-width: 100%;
328
594
  display: flex;
329
595
  flex-direction: column;
596
+ /* No 300ms tap delay on iOS */
597
+ touch-action: manipulation;
330
598
  /* NO overflow constraints here to prevent shadow clipping.
331
599
  GSAP translation handles scrolling visually without clipping! */
332
600
  }
333
601
 
602
+ .soft-toast-container[data-expanded="true"] .soft-toast-stack {
603
+ touch-action: none;
604
+ }
605
+
334
606
  .soft-toast-list {
335
607
  position: relative;
336
608
  width: 100%;
@@ -355,26 +627,27 @@ const stackDirection = computed<"up" | "down">(() =>
355
627
  }
356
628
 
357
629
  /* Horizontal alignment based on container position */
358
- .soft-toast-container[data-position$="left"] .soft-toast-list > :deep(.soft-toast-item) {
630
+ .soft-toast-container[data-position$="left"]
631
+ .soft-toast-list
632
+ > :deep(.soft-toast-item) {
359
633
  left: 0;
360
634
  right: auto;
361
635
  }
362
- .soft-toast-container[data-position$="right"] .soft-toast-list > :deep(.soft-toast-item) {
636
+ .soft-toast-container[data-position$="right"]
637
+ .soft-toast-list
638
+ > :deep(.soft-toast-item) {
363
639
  right: 0;
364
640
  left: auto;
365
641
  }
366
- .soft-toast-container[data-position$="center"] .soft-toast-list > :deep(.soft-toast-item) {
367
- left: 50%;
368
- transform: translateX(-50%); /* Base horizontal centering before GSAP adds translateY */
369
- }
370
-
371
642
  /* Bottom-anchored stacks (top positions peek down): items anchored to top edge */
372
- .soft-toast-stack[data-direction="down"] :deep(.soft-toast-list > .soft-toast-item) {
643
+ .soft-toast-stack[data-direction="down"]
644
+ :deep(.soft-toast-list > .soft-toast-item) {
373
645
  top: 0;
374
646
  bottom: auto;
375
647
  }
376
648
  /* Top-anchored stacks (bottom positions peek up): items anchored to bottom edge */
377
- .soft-toast-stack[data-direction="up"] :deep(.soft-toast-list > .soft-toast-item) {
649
+ .soft-toast-stack[data-direction="up"]
650
+ :deep(.soft-toast-list > .soft-toast-item) {
378
651
  top: auto;
379
652
  bottom: 0;
380
653
  }
package/src/plugin.ts CHANGED
@@ -19,7 +19,8 @@ let pluginOptions: ToastPluginOptions = {
19
19
  queueOverflow: 'drop-oldest',
20
20
  dir: 'ltr',
21
21
  swipeToDismiss: true,
22
- teleportTarget: 'body'
22
+ teleportTarget: 'body',
23
+ autoMount: true
23
24
  }
24
25
 
25
26
  // Flag to check if container is mounted
@@ -30,8 +31,10 @@ export const SoftToastPlugin = {
30
31
  // Merge options
31
32
  pluginOptions = { ...pluginOptions, ...options }
32
33
 
34
+ app.component('SoftToastContainer', ToastContainer)
35
+
33
36
  // Only mount container once
34
- if (!isContainerMounted && typeof window !== 'undefined') {
37
+ if (pluginOptions.autoMount !== false && !isContainerMounted && typeof window !== 'undefined') {
35
38
  // Create a container div
36
39
  const containerId = 'soft-toast-global-container'
37
40
  let container = document.getElementById(containerId)
@@ -42,9 +45,6 @@ export const SoftToastPlugin = {
42
45
  document.body.appendChild(container)
43
46
  }
44
47
 
45
- // Mount ToastContainer
46
- app.component('SoftToastContainer', ToastContainer)
47
-
48
48
  // Create a mini-app instance just for the toast container
49
49
  const toastContainerApp = createApp({
50
50
  render: () => h(ToastContainer, {
@@ -62,7 +62,9 @@ export const SoftToastPlugin = {
62
62
  maxQueue: pluginOptions.maxQueue,
63
63
  queueOverflow: pluginOptions.queueOverflow,
64
64
  dir: pluginOptions.dir,
65
- swipeToDismiss: pluginOptions.swipeToDismiss
65
+ swipeToDismiss: pluginOptions.swipeToDismiss,
66
+ showTimestamp: pluginOptions.showTimestamp,
67
+ slotFilter: pluginOptions.slotFilter
66
68
  })
67
69
  })
68
70