@soft-toast/vue 1.0.1 → 1.1.0

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