@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.
@@ -2,7 +2,7 @@
2
2
  import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
3
3
  import type { Toast } from "../types";
4
4
  import { toastStore } from "../stores/toastStore";
5
- import { Icon } from '@iconify/vue';
5
+ import { Icon } from "@iconify/vue";
6
6
  import ToastIcon from "./ToastIcon.vue";
7
7
  import ToastProgress from "./ToastProgress.vue";
8
8
  import { registerToastIcons } from "../icons";
@@ -28,6 +28,7 @@ interface Props {
28
28
  expandedOffset?: number;
29
29
  stackDirection?: "up" | "down";
30
30
  reposition?: boolean;
31
+ interactive?: boolean;
31
32
  }
32
33
 
33
34
  const props = withDefaults(defineProps<Props>(), {
@@ -39,6 +40,7 @@ const props = withDefaults(defineProps<Props>(), {
39
40
  expandedOffset: 0,
40
41
  stackDirection: "up",
41
42
  reposition: false,
43
+ interactive: true,
42
44
  });
43
45
 
44
46
  const toastRef = ref<HTMLElement | null>(null);
@@ -51,14 +53,25 @@ const formattedTime = computed(() => {
51
53
  return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
52
54
  });
53
55
 
56
+ const isSwipeToDismissEnabled = computed(() => props.swipeToDismiss !== false);
57
+
54
58
  // ─── Dismiss ────────────────────────────────────────────────────────────────
55
59
 
56
60
  const dismiss = () => {
57
61
  if (isDismissing || !toastRef.value) return;
62
+ const toastId = props.toast.id;
63
+ const wasLeaving = props.toast.isLeaving;
58
64
  isDismissing = true;
59
65
  props.toast.isLeaving = true;
60
- const tween = exitAnimation(toastRef.value);
61
- tween.then(() => toastStore.dismiss(props.toast.id));
66
+ if (!wasLeaving) props.toast.onDismiss?.(toastId);
67
+ if (removeFallbackId !== null) {
68
+ window.clearTimeout(removeFallbackId);
69
+ }
70
+ removeFallbackId = window.setTimeout(() => {
71
+ toastStore.remove(toastId);
72
+ removeFallbackId = null;
73
+ }, 320);
74
+ exitAnimation(toastRef.value);
62
75
  };
63
76
 
64
77
  // ─── Action buttons ──────────────────────────────────────────────────────────
@@ -79,13 +92,15 @@ const handleAction = async (act: any) => {
79
92
 
80
93
  const normalizedActions = computed(() => {
81
94
  if (!props.toast.action) return [];
82
- return Array.isArray(props.toast.action) ? props.toast.action : [props.toast.action];
95
+ return Array.isArray(props.toast.action)
96
+ ? props.toast.action
97
+ : [props.toast.action];
83
98
  });
84
99
 
85
100
  // ─── Stack position via GSAP ─────────────────────────────────────────────────
86
101
 
87
- const applyStackPosition = (reposition = false) => {
88
- if (!toastRef.value || props.toast.isLeaving) return;
102
+ const applyStackPosition = (reposition = false, overrideOffset?: number) => {
103
+ if (!toastRef.value || props.toast.isLeaving || isSwiping) return;
89
104
  positionAnimation(toastRef.value, {
90
105
  index: props.index,
91
106
  expanded: props.expanded,
@@ -93,7 +108,7 @@ const applyStackPosition = (reposition = false) => {
93
108
  bounce: props.toast.bounce,
94
109
  spring: props.toast.spring,
95
110
  direction: props.stackDirection,
96
- expandedOffset: props.expandedOffset,
111
+ expandedOffset: overrideOffset ?? props.expandedOffset,
97
112
  reposition,
98
113
  });
99
114
  };
@@ -107,12 +122,88 @@ const applyStackPosition = (reposition = false) => {
107
122
  // - Miss threshold → elastic snap-back spring
108
123
 
109
124
  let swipeStartX = 0;
125
+ let swipeStartY = 0;
110
126
  let swipeStartTime = 0;
111
127
  let isSwiping = false;
128
+ let directionLocked = false;
112
129
  let swipeCurrentX = 0;
130
+ let activePointerId: number | null = null;
131
+ let activePointerTarget: HTMLElement | null = null;
132
+ let lostCaptureFallbackId: number | null = null;
133
+ let removeFallbackId: number | null = null;
134
+
135
+ const clearLostCaptureFallback = () => {
136
+ if (lostCaptureFallbackId === null) return;
137
+ window.clearTimeout(lostCaptureFallbackId);
138
+ lostCaptureFallbackId = null;
139
+ };
140
+
141
+ const releaseActivePointer = () => {
142
+ if (activePointerId === null || !activePointerTarget) return;
143
+ try {
144
+ if (activePointerTarget.hasPointerCapture(activePointerId)) {
145
+ activePointerTarget.releasePointerCapture(activePointerId);
146
+ }
147
+ } catch {
148
+ // Safari can throw if capture was already lost during touch handoff.
149
+ }
150
+ };
151
+
152
+ const removeSwipeFallbackListeners = () => {
153
+ window.removeEventListener("pointermove", handlePointerMove);
154
+ window.removeEventListener("pointerup", handlePointerUp);
155
+ window.removeEventListener("pointercancel", handlePointerCancel);
156
+ };
157
+
158
+ const resetSwipeTracking = () => {
159
+ isSwiping = false;
160
+ directionLocked = false;
161
+ clearLostCaptureFallback();
162
+ releaseActivePointer();
163
+ removeSwipeFallbackListeners();
164
+ activePointerId = null;
165
+ activePointerTarget = null;
166
+ };
167
+
168
+ const snapBackSwipe = () => {
169
+ if (!toastRef.value) return;
170
+ gsap.set(toastRef.value, { rotate: 0 });
171
+ swipeSnapBack(toastRef.value);
172
+ toastStore.resume(props.toast.id);
173
+ };
174
+
175
+ const completeSwipe = () => {
176
+ if (!toastRef.value) return;
177
+
178
+ const dx = swipeCurrentX;
179
+ const elapsed = Math.max(1, Date.now() - swipeStartTime);
180
+ const velocity = (Math.abs(dx) / elapsed) * 1000; // px/s
181
+ const width = toastRef.value.offsetWidth;
182
+ const threshold = width * 0.35;
183
+ resetSwipeTracking();
184
+
185
+ if (Math.abs(dx) >= threshold || velocity >= 500) {
186
+ // --- Dismiss: fly off in swipe direction ---
187
+ isDismissing = true;
188
+ const toastId = props.toast.id;
189
+ props.toast.isLeaving = true;
190
+ if (removeFallbackId !== null) {
191
+ window.clearTimeout(removeFallbackId);
192
+ }
193
+ removeFallbackId = window.setTimeout(() => {
194
+ toastStore.remove(toastId);
195
+ removeFallbackId = null;
196
+ }, 320);
197
+ const flyX = dx > 0 ? width * 1.6 : -width * 1.6;
198
+ swipeExitAnimation(toastRef.value, flyX);
199
+ } else {
200
+ // --- Snap back with spring ---
201
+ snapBackSwipe();
202
+ }
203
+ };
113
204
 
114
205
  const handlePointerDown = (e: PointerEvent) => {
115
- if (!props.swipeToDismiss || isDismissing) return;
206
+ if (!isSwipeToDismissEnabled.value || isDismissing || isSwiping) return;
116
207
  // Only primary button for mouse; all pointers for touch/stylus
117
208
  if (e.pointerType === "mouse" && e.button !== 0) return;
118
209
  // Ignore if target is a button/link (don't hijack action clicks)
@@ -120,12 +211,24 @@ const handlePointerDown = (e: PointerEvent) => {
120
211
  if (target.closest("button, a")) return;
121
212
 
122
213
  swipeStartX = e.clientX;
214
+ swipeStartY = e.clientY;
123
215
  swipeStartTime = Date.now();
124
216
  isSwiping = true;
217
+ directionLocked = false;
125
218
  swipeCurrentX = 0;
219
+ activePointerId = e.pointerId;
220
+ activePointerTarget = e.currentTarget as HTMLElement;
221
+ gsap.killTweensOf(toastRef.value);
126
222
 
127
223
  // Capture pointer so move/up fire even if cursor leaves the element
128
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
224
+ try {
225
+ activePointerTarget.setPointerCapture(e.pointerId);
226
+ } catch {
227
+ // Some WebKit touch paths may decline capture; window fallbacks still finish the gesture.
228
+ }
229
+ window.addEventListener("pointermove", handlePointerMove);
230
+ window.addEventListener("pointerup", handlePointerUp);
231
+ window.addEventListener("pointercancel", handlePointerCancel);
129
232
 
130
233
  // Pause timer while swiping
131
234
  toastStore.pause(props.toast.id);
@@ -133,8 +236,28 @@ const handlePointerDown = (e: PointerEvent) => {
133
236
 
134
237
  const handlePointerMove = (e: PointerEvent) => {
135
238
  if (!isSwiping || !toastRef.value) return;
239
+ if (activePointerId !== null && e.pointerId !== activePointerId) return;
240
+ clearLostCaptureFallback();
136
241
 
137
242
  const dx = e.clientX - swipeStartX;
243
+ const dy = e.clientY - swipeStartY;
244
+
245
+ // Direction lock: first significant movement decides swipe vs scroll
246
+ if (!directionLocked) {
247
+ if (Math.abs(dy) > Math.abs(dx) + 4) {
248
+ // Vertical gesture — cancel swipe, let browser scroll
249
+ resetSwipeTracking();
250
+ snapBackSwipe();
251
+ return;
252
+ }
253
+ if (Math.abs(dx) > Math.abs(dy) + 4) {
254
+ directionLocked = true;
255
+ } else {
256
+ return; // not enough movement yet
257
+ }
258
+ }
259
+
260
+ e.preventDefault();
138
261
  swipeCurrentX = dx;
139
262
 
140
263
  // Clamp opacity: full at 0, gone at 70% of width
@@ -148,41 +271,39 @@ const handlePointerMove = (e: PointerEvent) => {
148
271
 
149
272
  const handlePointerUp = (e: PointerEvent) => {
150
273
  if (!isSwiping || !toastRef.value) return;
151
- isSwiping = false;
274
+ if (activePointerId !== null && e.pointerId !== activePointerId) return;
152
275
 
153
- const dx = swipeCurrentX;
154
- const elapsed = Math.max(1, Date.now() - swipeStartTime);
155
- const velocity = (Math.abs(dx) / elapsed) * 1000; // px/s
156
- const width = toastRef.value.offsetWidth;
157
- const threshold = width * 0.35;
276
+ completeSwipe();
277
+ };
158
278
 
159
- if (Math.abs(dx) >= threshold || velocity >= 500) {
160
- // --- Dismiss: fly off in swipe direction ---
161
- isDismissing = true;
162
- props.toast.isLeaving = true;
163
- const flyX = dx > 0 ? width * 1.6 : -width * 1.6;
164
- swipeExitAnimation(toastRef.value, flyX).then(() => {
165
- toastStore.remove(props.toast.id);
166
- });
167
- } else {
168
- // --- Snap back with spring ---
169
- gsap.set(toastRef.value, { rotate: 0 }); // reset tilt first
170
- swipeSnapBack(toastRef.value);
171
- toastStore.resume(props.toast.id);
172
- }
279
+ const handlePointerCancel = (e?: PointerEvent) => {
280
+ if (!isSwiping || !toastRef.value) return;
281
+ if (e && activePointerId !== null && e.pointerId !== activePointerId) return;
282
+ resetSwipeTracking();
283
+ snapBackSwipe();
173
284
  };
174
285
 
175
- const handlePointerCancel = () => {
286
+ const handleLostPointerCapture = (e: PointerEvent) => {
176
287
  if (!isSwiping || !toastRef.value) return;
177
- isSwiping = false;
178
- gsap.set(toastRef.value, { rotate: 0 });
179
- swipeSnapBack(toastRef.value);
180
- toastStore.resume(props.toast.id);
288
+ if (activePointerId !== null && e.pointerId !== activePointerId) return;
289
+ activePointerTarget = null;
290
+ clearLostCaptureFallback();
291
+ lostCaptureFallbackId = window.setTimeout(() => {
292
+ if (isSwiping) completeSwipe();
293
+ }, 300);
294
+ };
295
+
296
+ const handleVisibilityChange = () => {
297
+ if (document.hidden && isSwiping) {
298
+ resetSwipeTracking();
299
+ snapBackSwipe();
300
+ }
181
301
  };
182
302
 
183
303
  // ─── Lifecycle ───────────────────────────────────────────────────────────────
184
304
 
185
305
  onMounted(() => {
306
+ document.addEventListener("visibilitychange", handleVisibilityChange);
186
307
  if (!toastRef.value) return;
187
308
  const isBottom = props.toast.position.includes("bottom");
188
309
  const tl = landingAnimation(toastRef.value, {
@@ -198,19 +319,32 @@ onMounted(() => {
198
319
 
199
320
  // Split the watches so a dismiss only creates one restack animation.
200
321
  // Collapsed stacks move by index; expanded stacks move by measured offsets.
201
- watch(() => props.index, (newIndex, oldIndex) => {
202
- if (props.expanded) return;
203
- applyStackPosition(newIndex < oldIndex || props.reposition);
204
- })
322
+ watch(
323
+ () => props.index,
324
+ (newIndex, oldIndex) => {
325
+ if (props.expanded) return;
326
+ applyStackPosition(newIndex < oldIndex || props.reposition);
327
+ },
328
+ );
205
329
 
206
- watch(() => props.expandedOffset, (newOffset, oldOffset) => {
207
- if (!props.expanded) return;
208
- applyStackPosition(newOffset !== oldOffset);
209
- })
330
+ watch(
331
+ () => props.expandedOffset,
332
+ (newOffset, oldOffset) => {
333
+ if (!props.expanded) return;
334
+ applyStackPosition(newOffset !== oldOffset);
335
+ },
336
+ );
210
337
 
211
- watch(() => props.expanded, () => {
212
- applyStackPosition(true)
213
- })
338
+ watch(
339
+ () => props.expanded,
340
+ (expanded) => {
341
+ if (!expanded) {
342
+ // Collapse: animate immediately back to stacked position
343
+ applyStackPosition(true);
344
+ }
345
+ // Expand: ToastRegion calls applyStackPosition directly after measuring
346
+ },
347
+ );
214
348
 
215
349
  watch(
216
350
  () => props.expanded,
@@ -222,10 +356,23 @@ watch(
222
356
 
223
357
  watch(
224
358
  () => props.toast.isLeaving,
225
- (leaving) => { if (leaving) dismiss(); },
359
+ (leaving) => {
360
+ if (leaving) dismiss();
361
+ },
226
362
  );
227
363
 
364
+ defineExpose({ applyStackPosition });
365
+
228
366
  onUnmounted(() => {
367
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
368
+ if (isSwiping) {
369
+ resetSwipeTracking();
370
+ toastStore.resume(props.toast.id);
371
+ }
372
+ if (removeFallbackId !== null) {
373
+ window.clearTimeout(removeFallbackId);
374
+ removeFallbackId = null;
375
+ }
229
376
  if (toastRef.value) killAnimations(toastRef.value);
230
377
  });
231
378
  </script>
@@ -234,17 +381,23 @@ onUnmounted(() => {
234
381
  <div
235
382
  ref="toastRef"
236
383
  class="soft-toast-item"
237
- :class="{ 'soft-toast-item--swipeable': swipeToDismiss }"
384
+ :class="{ 'soft-toast-item--swipeable': isSwipeToDismissEnabled }"
238
385
  :data-type="toast.type"
239
386
  :data-st-index="index"
387
+ :data-toast-id="toast.id"
240
388
  :data-leaving="toast.isLeaving"
389
+ :data-interactive="interactive"
241
390
  :style="{ zIndex: 1000 - index }"
242
391
  @pointerdown="handlePointerDown"
243
- @pointermove="handlePointerMove"
244
- @pointerup="handlePointerUp"
245
392
  @pointercancel="handlePointerCancel"
393
+ @lostpointercapture="handleLostPointerCapture"
246
394
  >
247
- <slot name="close-button" :toast="toast" :dismiss="dismiss">
395
+ <slot
396
+ name="close-button"
397
+ :toast="toast"
398
+ :dismiss="dismiss"
399
+ :close-button="closeButton"
400
+ >
248
401
  <button
249
402
  v-if="closeButton"
250
403
  class="soft-toast-close"
@@ -254,15 +407,33 @@ onUnmounted(() => {
254
407
  @click.stop="dismiss"
255
408
  aria-label="Close"
256
409
  >
257
- <svg width="10" height="10" viewBox="0 0 10 10" fill="none" class="st-close-icon">
258
- <path class="st-close-line-1" d="M1 1L9 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
259
- <path class="st-close-line-2" d="M9 1L1 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
410
+ <svg
411
+ width="10"
412
+ height="10"
413
+ viewBox="0 0 10 10"
414
+ fill="none"
415
+ class="st-close-icon"
416
+ >
417
+ <path
418
+ class="st-close-line-1"
419
+ d="M1 1L9 9"
420
+ stroke="currentColor"
421
+ stroke-width="1.6"
422
+ stroke-linecap="round"
423
+ />
424
+ <path
425
+ class="st-close-line-2"
426
+ d="M9 1L1 9"
427
+ stroke="currentColor"
428
+ stroke-width="1.6"
429
+ stroke-linecap="round"
430
+ />
260
431
  </svg>
261
432
  </button>
262
433
  </slot>
263
434
 
264
435
  <div class="soft-toast-content">
265
- <slot name="icon" :toast="toast">
436
+ <slot name="icon" :toast="toast" :close-button="closeButton">
266
437
  <div v-if="toast.type === 'promise'" class="soft-toast-icon">
267
438
  <Icon
268
439
  class="soft-toast-icon-svg"
@@ -271,7 +442,11 @@ onUnmounted(() => {
271
442
  :height="18"
272
443
  />
273
444
  </div>
274
- <ToastIcon v-else-if="toast.icon && typeof toast.icon === 'string'" :type="toast.type" :customIcon="toast.icon" />
445
+ <ToastIcon
446
+ v-else-if="toast.icon && typeof toast.icon === 'string'"
447
+ :type="toast.type"
448
+ :customIcon="toast.icon"
449
+ />
275
450
  <div v-else-if="toast.icon" class="soft-toast-icon">
276
451
  <component :is="toast.icon" />
277
452
  </div>
@@ -280,16 +455,25 @@ onUnmounted(() => {
280
455
 
281
456
  <div class="soft-toast-body">
282
457
  <div class="soft-toast-header-row">
283
- <slot name="title" :toast="toast">
458
+ <slot name="title" :toast="toast" :close-button="closeButton">
284
459
  <p
285
460
  class="soft-toast-title"
286
- :class="{ 'soft-toast-title--has-close': closeButton === true || closeButton === 'top-right' }"
287
- >{{ toast.title }}</p>
461
+ :class="{
462
+ 'soft-toast-title--has-close':
463
+ closeButton === true || closeButton === 'top-right',
464
+ }"
465
+ >
466
+ {{ toast.title }}
467
+ </p>
288
468
  </slot>
289
469
  </div>
290
470
 
291
- <div v-if="toast.description || toast.action" class="soft-toast-extra" style="overflow: hidden;">
292
- <slot name="description" :toast="toast">
471
+ <div
472
+ v-if="toast.description || toast.action"
473
+ class="soft-toast-extra"
474
+ style="overflow: hidden"
475
+ >
476
+ <slot name="description" :toast="toast" :close-button="closeButton">
293
477
  <p v-if="toast.description" class="soft-toast-description">
294
478
  <component
295
479
  v-if="typeof toast.description === 'object'"
@@ -299,7 +483,13 @@ onUnmounted(() => {
299
483
  </p>
300
484
  </slot>
301
485
 
302
- <slot name="action" :toast="toast" :execute="handleAction" :hasSucceeded="hasActionSucceeded">
486
+ <slot
487
+ name="action"
488
+ :toast="toast"
489
+ :execute="handleAction"
490
+ :hasSucceeded="hasActionSucceeded"
491
+ :close-button="closeButton"
492
+ >
303
493
  <div
304
494
  v-if="normalizedActions.length > 0 && !hasActionSucceeded"
305
495
  class="soft-toast-action"
@@ -308,7 +498,10 @@ onUnmounted(() => {
308
498
  v-for="(act, idx) in normalizedActions"
309
499
  :key="idx"
310
500
  class="soft-toast-action-button"
311
- :class="[act.class || '', act.primary ? 'soft-toast-action-primary' : '']"
501
+ :class="[
502
+ act.class || '',
503
+ act.primary ? 'soft-toast-action-primary' : '',
504
+ ]"
312
505
  @click.stop="() => handleAction(act)"
313
506
  >
314
507
  {{ act.label }}
@@ -333,7 +526,9 @@ onUnmounted(() => {
333
526
  </div>
334
527
 
335
528
  <ToastProgress
336
- v-if="toast.showProgress && toast.duration > 0 && toast.duration !== Infinity"
529
+ v-if="
530
+ toast.showProgress && toast.duration > 0 && toast.duration !== Infinity
531
+ "
337
532
  :remaining-time="toast.remainingTime"
338
533
  :total-duration="toast.duration"
339
534
  :is-paused="toast.isPaused"