@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.
- package/LICENSE +31 -0
- package/README.md +210 -0
- package/dist/animations/gsapConfig.d.ts +42 -0
- package/dist/animations/gsapConfig.d.ts.map +1 -0
- package/dist/composables/useFlash.d.ts +41 -0
- package/dist/composables/useFlash.d.ts.map +1 -0
- package/dist/composables/useFlash.test.d.ts +2 -0
- package/dist/composables/useFlash.test.d.ts.map +1 -0
- package/dist/composables/useToast.d.ts +53 -0
- package/dist/composables/useToast.d.ts.map +1 -0
- package/dist/composables/useToast.test.d.ts +2 -0
- package/dist/composables/useToast.test.d.ts.map +1 -0
- package/dist/exports.test.d.ts +2 -0
- package/dist/exports.test.d.ts.map +1 -0
- package/dist/icons.d.ts +2 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2100 -0
- package/dist/plugin.d.ts +7 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/stores/toastStore.d.ts +25 -0
- package/dist/stores/toastStore.d.ts.map +1 -0
- package/dist/stores/toastStore.test.d.ts +2 -0
- package/dist/stores/toastStore.test.d.ts.map +1 -0
- package/dist/style.css +1 -0
- package/dist/types/index.d.ts +107 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/utils/sound.d.ts +9 -0
- package/dist/utils/sound.d.ts.map +1 -0
- package/package.json +70 -0
- package/src/animations/gsapConfig.ts +303 -0
- package/src/components/ToastContainer.vue +36 -0
- package/src/components/ToastIcon.vue +33 -0
- package/src/components/ToastItem.vue +342 -0
- package/src/components/ToastProgress.vue +50 -0
- package/src/components/ToastRegion.vue +381 -0
- package/src/composables/useFlash.test.ts +164 -0
- package/src/composables/useFlash.ts +118 -0
- package/src/composables/useToast.test.ts +230 -0
- package/src/composables/useToast.ts +95 -0
- package/src/exports.test.ts +72 -0
- package/src/icons.ts +38 -0
- package/src/index.ts +25 -0
- package/src/plugin.ts +85 -0
- package/src/stores/toastStore.test.ts +129 -0
- package/src/stores/toastStore.ts +288 -0
- package/src/styles/toast.css +353 -0
- package/src/styles/variables.css +83 -0
- package/src/types/index.ts +115 -0
- 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>
|