@soft-toast/vue 1.0.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 (51) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +210 -0
  3. package/dist/animations/gsapConfig.d.ts +42 -0
  4. package/dist/animations/gsapConfig.d.ts.map +1 -0
  5. package/dist/composables/useFlash.d.ts +41 -0
  6. package/dist/composables/useFlash.d.ts.map +1 -0
  7. package/dist/composables/useFlash.test.d.ts +2 -0
  8. package/dist/composables/useFlash.test.d.ts.map +1 -0
  9. package/dist/composables/useToast.d.ts +53 -0
  10. package/dist/composables/useToast.d.ts.map +1 -0
  11. package/dist/composables/useToast.test.d.ts +2 -0
  12. package/dist/composables/useToast.test.d.ts.map +1 -0
  13. package/dist/exports.test.d.ts +2 -0
  14. package/dist/exports.test.d.ts.map +1 -0
  15. package/dist/icons.d.ts +2 -0
  16. package/dist/icons.d.ts.map +1 -0
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +2100 -0
  20. package/dist/plugin.d.ts +7 -0
  21. package/dist/plugin.d.ts.map +1 -0
  22. package/dist/stores/toastStore.d.ts +25 -0
  23. package/dist/stores/toastStore.d.ts.map +1 -0
  24. package/dist/stores/toastStore.test.d.ts +2 -0
  25. package/dist/stores/toastStore.test.d.ts.map +1 -0
  26. package/dist/style.css +1 -0
  27. package/dist/types/index.d.ts +107 -0
  28. package/dist/types/index.d.ts.map +1 -0
  29. package/dist/utils/sound.d.ts +9 -0
  30. package/dist/utils/sound.d.ts.map +1 -0
  31. package/package.json +70 -0
  32. package/src/animations/gsapConfig.ts +303 -0
  33. package/src/components/ToastContainer.vue +36 -0
  34. package/src/components/ToastIcon.vue +33 -0
  35. package/src/components/ToastItem.vue +342 -0
  36. package/src/components/ToastProgress.vue +50 -0
  37. package/src/components/ToastRegion.vue +381 -0
  38. package/src/composables/useFlash.test.ts +164 -0
  39. package/src/composables/useFlash.ts +118 -0
  40. package/src/composables/useToast.test.ts +230 -0
  41. package/src/composables/useToast.ts +95 -0
  42. package/src/exports.test.ts +72 -0
  43. package/src/icons.ts +38 -0
  44. package/src/index.ts +25 -0
  45. package/src/plugin.ts +85 -0
  46. package/src/stores/toastStore.test.ts +129 -0
  47. package/src/stores/toastStore.ts +288 -0
  48. package/src/styles/toast.css +353 -0
  49. package/src/styles/variables.css +83 -0
  50. package/src/types/index.ts +115 -0
  51. package/src/utils/sound.ts +140 -0
@@ -0,0 +1,342 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
3
+ import type { Toast } from "../types";
4
+ import { toastStore } from "../stores/toastStore";
5
+ import { Icon } from '@iconify/vue';
6
+ import ToastIcon from "./ToastIcon.vue";
7
+ import ToastProgress from "./ToastProgress.vue";
8
+ import { registerToastIcons } from "../icons";
9
+ import {
10
+ landingAnimation,
11
+ exitAnimation,
12
+ positionAnimation,
13
+ killAnimations,
14
+ swipeExitAnimation,
15
+ swipeSnapBack,
16
+ } from "../animations/gsapConfig";
17
+ import { gsap } from "gsap";
18
+
19
+ registerToastIcons();
20
+
21
+ interface Props {
22
+ toast: Toast;
23
+ closeButton?: boolean | "top-left" | "top-right";
24
+ swipeToDismiss?: boolean;
25
+ index?: number;
26
+ total?: number;
27
+ expanded?: boolean;
28
+ expandedOffset?: number;
29
+ stackDirection?: "up" | "down";
30
+ reposition?: boolean;
31
+ }
32
+
33
+ const props = withDefaults(defineProps<Props>(), {
34
+ closeButton: false,
35
+ swipeToDismiss: true,
36
+ index: 0,
37
+ total: 1,
38
+ expanded: false,
39
+ expandedOffset: 0,
40
+ stackDirection: "up",
41
+ reposition: false,
42
+ });
43
+
44
+ const toastRef = ref<HTMLElement | null>(null);
45
+ const hasActionSucceeded = ref(false);
46
+ const successLabelStr = ref("");
47
+ let isDismissing = false;
48
+
49
+ const formattedTime = computed(() => {
50
+ const date = new Date(props.toast.createdAt);
51
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
52
+ });
53
+
54
+ // ─── Dismiss ────────────────────────────────────────────────────────────────
55
+
56
+ const dismiss = () => {
57
+ if (isDismissing || !toastRef.value) return;
58
+ isDismissing = true;
59
+ props.toast.isLeaving = true;
60
+ const tween = exitAnimation(toastRef.value);
61
+ tween.then(() => toastStore.dismiss(props.toast.id));
62
+ };
63
+
64
+ // ─── Action buttons ──────────────────────────────────────────────────────────
65
+
66
+ const handleAction = async (act: any) => {
67
+ if (!act) return;
68
+ try {
69
+ await act.onClick();
70
+ if (act.successLabel) {
71
+ hasActionSucceeded.value = true;
72
+ successLabelStr.value = act.successLabel;
73
+ setTimeout(dismiss, 1200);
74
+ }
75
+ } catch {
76
+ /* keep open on error */
77
+ }
78
+ };
79
+
80
+ const normalizedActions = computed(() => {
81
+ if (!props.toast.action) return [];
82
+ return Array.isArray(props.toast.action) ? props.toast.action : [props.toast.action];
83
+ });
84
+
85
+ // ─── Stack position via GSAP ─────────────────────────────────────────────────
86
+
87
+ const applyStackPosition = (reposition = false) => {
88
+ if (!toastRef.value || props.toast.isLeaving) return;
89
+ positionAnimation(toastRef.value, {
90
+ index: props.index,
91
+ expanded: props.expanded,
92
+ preset: props.toast.preset,
93
+ bounce: props.toast.bounce,
94
+ spring: props.toast.spring,
95
+ direction: props.stackDirection,
96
+ expandedOffset: props.expandedOffset,
97
+ reposition,
98
+ });
99
+ };
100
+
101
+ // ─── Swipe-to-dismiss ────────────────────────────────────────────────────────
102
+ // Uses Pointer Events API — works with both touch and mouse.
103
+ // Spring physics:
104
+ // - Tracks gesture in real-time via GSAP.set
105
+ // - Velocity threshold: 500 px/s OR 35% of toast width
106
+ // - Hit threshold → fly-off animation → dismiss
107
+ // - Miss threshold → elastic snap-back spring
108
+
109
+ let swipeStartX = 0;
110
+ let swipeStartTime = 0;
111
+ let isSwiping = false;
112
+ let swipeCurrentX = 0;
113
+
114
+ const handlePointerDown = (e: PointerEvent) => {
115
+ if (!props.swipeToDismiss || isDismissing) return;
116
+ // Only primary button for mouse; all pointers for touch/stylus
117
+ if (e.pointerType === "mouse" && e.button !== 0) return;
118
+ // Ignore if target is a button/link (don't hijack action clicks)
119
+ const target = e.target as HTMLElement;
120
+ if (target.closest("button, a")) return;
121
+
122
+ swipeStartX = e.clientX;
123
+ swipeStartTime = Date.now();
124
+ isSwiping = true;
125
+ swipeCurrentX = 0;
126
+
127
+ // Capture pointer so move/up fire even if cursor leaves the element
128
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
129
+
130
+ // Pause timer while swiping
131
+ toastStore.pause(props.toast.id);
132
+ };
133
+
134
+ const handlePointerMove = (e: PointerEvent) => {
135
+ if (!isSwiping || !toastRef.value) return;
136
+
137
+ const dx = e.clientX - swipeStartX;
138
+ swipeCurrentX = dx;
139
+
140
+ // Clamp opacity: full at 0, gone at 70% of width
141
+ const width = toastRef.value.offsetWidth;
142
+ const opacity = Math.max(0, 1 - Math.abs(dx) / (width * 0.7));
143
+ // Slight vertical tilt for realism
144
+ const rotate = (dx / width) * 6; // max ±6deg
145
+
146
+ gsap.set(toastRef.value, { x: dx, opacity, rotate, overwrite: "auto" });
147
+ };
148
+
149
+ const handlePointerUp = (e: PointerEvent) => {
150
+ if (!isSwiping || !toastRef.value) return;
151
+ isSwiping = false;
152
+
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;
158
+
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
+ }
173
+ };
174
+
175
+ const handlePointerCancel = () => {
176
+ 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);
181
+ };
182
+
183
+ // ─── Lifecycle ───────────────────────────────────────────────────────────────
184
+
185
+ onMounted(() => {
186
+ if (!toastRef.value) return;
187
+ const isBottom = props.toast.position.includes("bottom");
188
+ const tl = landingAnimation(toastRef.value, {
189
+ preset: props.toast.preset,
190
+ bounce: props.toast.bounce,
191
+ spring: props.toast.spring,
192
+ direction: isBottom ? "up" : "down",
193
+ });
194
+ tl.eventCallback("onComplete", () => {
195
+ nextTick(applyStackPosition);
196
+ });
197
+ });
198
+
199
+ // Split the watches so a dismiss only creates one restack animation.
200
+ // 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
+ })
205
+
206
+ watch(() => props.expandedOffset, (newOffset, oldOffset) => {
207
+ if (!props.expanded) return;
208
+ applyStackPosition(newOffset !== oldOffset);
209
+ })
210
+
211
+ watch(() => props.expanded, () => {
212
+ applyStackPosition(true)
213
+ })
214
+
215
+ watch(
216
+ () => props.expanded,
217
+ (expanded) => {
218
+ if (expanded) toastStore.pause(props.toast.id);
219
+ else toastStore.resume(props.toast.id);
220
+ },
221
+ );
222
+
223
+ watch(
224
+ () => props.toast.isLeaving,
225
+ (leaving) => { if (leaving) dismiss(); },
226
+ );
227
+
228
+ onUnmounted(() => {
229
+ if (toastRef.value) killAnimations(toastRef.value);
230
+ });
231
+ </script>
232
+
233
+ <template>
234
+ <div
235
+ ref="toastRef"
236
+ class="soft-toast-item"
237
+ :class="{ 'soft-toast-item--swipeable': swipeToDismiss }"
238
+ :data-type="toast.type"
239
+ :data-st-index="index"
240
+ :data-leaving="toast.isLeaving"
241
+ :style="{ zIndex: 1000 - index }"
242
+ @pointerdown="handlePointerDown"
243
+ @pointermove="handlePointerMove"
244
+ @pointerup="handlePointerUp"
245
+ @pointercancel="handlePointerCancel"
246
+ >
247
+ <slot name="close-button" :toast="toast" :dismiss="dismiss">
248
+ <button
249
+ v-if="closeButton"
250
+ class="soft-toast-close"
251
+ :data-position="
252
+ typeof closeButton === 'string' ? closeButton : 'top-right'
253
+ "
254
+ @click.stop="dismiss"
255
+ aria-label="Close"
256
+ >
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" />
260
+ </svg>
261
+ </button>
262
+ </slot>
263
+
264
+ <div class="soft-toast-content">
265
+ <slot name="icon" :toast="toast">
266
+ <div v-if="toast.type === 'promise'" class="soft-toast-icon">
267
+ <Icon
268
+ class="soft-toast-icon-svg"
269
+ icon="lucide:loader-circle"
270
+ :width="18"
271
+ :height="18"
272
+ />
273
+ </div>
274
+ <ToastIcon v-else-if="toast.icon && typeof toast.icon === 'string'" :type="toast.type" :customIcon="toast.icon" />
275
+ <div v-else-if="toast.icon" class="soft-toast-icon">
276
+ <component :is="toast.icon" />
277
+ </div>
278
+ <ToastIcon v-else-if="toast.type !== 'default'" :type="toast.type" />
279
+ </slot>
280
+
281
+ <div class="soft-toast-body">
282
+ <div class="soft-toast-header-row">
283
+ <slot name="title" :toast="toast">
284
+ <p
285
+ class="soft-toast-title"
286
+ :class="{ 'soft-toast-title--has-close': closeButton === true || closeButton === 'top-right' }"
287
+ >{{ toast.title }}</p>
288
+ </slot>
289
+ </div>
290
+
291
+ <div v-if="toast.description || toast.action" class="soft-toast-extra" style="overflow: hidden;">
292
+ <slot name="description" :toast="toast">
293
+ <p v-if="toast.description" class="soft-toast-description">
294
+ <component
295
+ v-if="typeof toast.description === 'object'"
296
+ :is="toast.description"
297
+ />
298
+ <template v-else>{{ toast.description }}</template>
299
+ </p>
300
+ </slot>
301
+
302
+ <slot name="action" :toast="toast" :execute="handleAction" :hasSucceeded="hasActionSucceeded">
303
+ <div
304
+ v-if="normalizedActions.length > 0 && !hasActionSucceeded"
305
+ class="soft-toast-action"
306
+ >
307
+ <button
308
+ v-for="(act, idx) in normalizedActions"
309
+ :key="idx"
310
+ class="soft-toast-action-button"
311
+ :class="[act.class || '', act.primary ? 'soft-toast-action-primary' : '']"
312
+ @click.stop="() => handleAction(act)"
313
+ >
314
+ {{ act.label }}
315
+ </button>
316
+ </div>
317
+ <div v-else-if="hasActionSucceeded" class="soft-toast-action">
318
+ <span
319
+ class="soft-toast-action-button soft-toast-action-success"
320
+ style="opacity: 0.75; cursor: default"
321
+ >
322
+ {{ successLabelStr }}
323
+ </span>
324
+ </div>
325
+ </slot>
326
+ </div>
327
+
328
+ <!-- Timestamp lives below all content — never overlaps close button -->
329
+ <span v-if="toast.showTimestamp" class="soft-toast-timestamp">
330
+ {{ formattedTime }}
331
+ </span>
332
+ </div>
333
+ </div>
334
+
335
+ <ToastProgress
336
+ v-if="toast.showProgress && toast.duration > 0 && toast.duration !== Infinity"
337
+ :remaining-time="toast.remainingTime"
338
+ :total-duration="toast.duration"
339
+ :is-paused="toast.isPaused"
340
+ />
341
+ </div>
342
+ </template>
@@ -0,0 +1,50 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, onUnmounted, onMounted } from 'vue'
3
+ import { progressAnimation, pauseAnimation, resumeAnimation } from '../animations/gsapConfig'
4
+
5
+ interface Props {
6
+ remainingTime: number
7
+ totalDuration: number
8
+ isPaused: boolean
9
+ }
10
+
11
+ const props = defineProps<Props>()
12
+
13
+ const progressRef = ref<HTMLElement | null>(null)
14
+ const animation = ref<gsap.core.Tween | null>(null)
15
+
16
+ // Initialize animation on mount
17
+ onMounted(() => {
18
+ if (!progressRef.value) return
19
+
20
+ animation.value = progressAnimation(
21
+ progressRef.value,
22
+ props.remainingTime,
23
+ )
24
+
25
+ if (props.isPaused) {
26
+ pauseAnimation(animation.value)
27
+ }
28
+ })
29
+
30
+ // Handle pause/resume
31
+ watch(() => props.isPaused, (isPaused) => {
32
+ if (!animation.value) return
33
+
34
+ if (isPaused) {
35
+ pauseAnimation(animation.value)
36
+ } else {
37
+ resumeAnimation(animation.value)
38
+ }
39
+ })
40
+
41
+ onUnmounted(() => {
42
+ animation.value?.kill()
43
+ })
44
+ </script>
45
+
46
+ <template>
47
+ <div class="soft-toast-progress">
48
+ <div ref="progressRef" class="soft-toast-progress-bar" />
49
+ </div>
50
+ </template>