@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,381 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
|
3
|
+
import type { ToastContainerProps } from "../types";
|
|
4
|
+
import { toastStore } from "../stores/toastStore";
|
|
5
|
+
import ToastItem from "./ToastItem.vue";
|
|
6
|
+
import { gsap } from "gsap";
|
|
7
|
+
|
|
8
|
+
// Props with defaults
|
|
9
|
+
const props = withDefaults(defineProps<ToastContainerProps>(), {
|
|
10
|
+
position: "top-right",
|
|
11
|
+
duration: 5000,
|
|
12
|
+
gap: 12,
|
|
13
|
+
offset: "24px",
|
|
14
|
+
theme: "light",
|
|
15
|
+
spring: true,
|
|
16
|
+
bounce: 0.4,
|
|
17
|
+
preset: "smooth",
|
|
18
|
+
closeOnEscape: true,
|
|
19
|
+
closeButton: false,
|
|
20
|
+
showProgress: false,
|
|
21
|
+
showTimestamp: false,
|
|
22
|
+
maxQueue: Infinity,
|
|
23
|
+
queueOverflow: "drop-oldest",
|
|
24
|
+
dir: "ltr",
|
|
25
|
+
swipeToDismiss: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Container ref for position calculations
|
|
29
|
+
const containerRef = ref<HTMLElement | null>(null);
|
|
30
|
+
const stackRef = ref<HTMLElement | null>(null);
|
|
31
|
+
const listRef = ref<HTMLElement | null>(null);
|
|
32
|
+
|
|
33
|
+
// Get toasts for this container's position
|
|
34
|
+
const positionToasts = computed(() => {
|
|
35
|
+
return toastStore.getToastsByPosition(props.position).value;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Handle keyboard (Escape to dismiss most recent)
|
|
39
|
+
const handleKeydown = (e: KeyboardEvent) => {
|
|
40
|
+
if (e.key === "Escape" && props.closeOnEscape) {
|
|
41
|
+
const toasts = positionToasts.value;
|
|
42
|
+
if (toasts.length > 0) {
|
|
43
|
+
toastStore.dismiss(toasts[0].id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Pause all timers when tab is hidden, resume when visible again
|
|
49
|
+
const handleVisibilityChange = () => {
|
|
50
|
+
const toasts = positionToasts.value;
|
|
51
|
+
if (document.hidden) {
|
|
52
|
+
toasts.forEach((t) => toastStore.pause(t.id));
|
|
53
|
+
} else {
|
|
54
|
+
toasts.forEach((t) => toastStore.resume(t.id));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
onMounted(() => {
|
|
59
|
+
if (props.closeOnEscape) {
|
|
60
|
+
document.addEventListener("keydown", handleKeydown);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Pause all timers when user switches tab / window loses focus
|
|
64
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
65
|
+
|
|
66
|
+
if (listRef.value && typeof ResizeObserver !== "undefined") {
|
|
67
|
+
resizeObserver = new ResizeObserver(() => measureOffsets());
|
|
68
|
+
resizeObserver.observe(listRef.value);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
onUnmounted(() => {
|
|
73
|
+
if (props.closeOnEscape) {
|
|
74
|
+
document.removeEventListener("keydown", handleKeydown);
|
|
75
|
+
}
|
|
76
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
77
|
+
if (resizeObserver) resizeObserver.disconnect();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Re-measure when toast list changes or when a toast starts leaving
|
|
81
|
+
watch(
|
|
82
|
+
() => positionToasts.value.map((t) => `${t.id}:${t.isLeaving}`).join(","),
|
|
83
|
+
() => {
|
|
84
|
+
nextTick(measureOffsets);
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Apply default options to new toasts
|
|
89
|
+
watch(
|
|
90
|
+
() => toastStore.toasts.value.length,
|
|
91
|
+
(newLength, oldLength) => {
|
|
92
|
+
if (newLength > oldLength) {
|
|
93
|
+
// New toast added, apply container-level defaults
|
|
94
|
+
const newToast = toastStore.toasts.value[0];
|
|
95
|
+
if (newToast) {
|
|
96
|
+
if (!newToast.duration) newToast.duration = props.duration;
|
|
97
|
+
if (!newToast.preset) newToast.preset = props.preset;
|
|
98
|
+
if (newToast.bounce === undefined) newToast.bounce = props.bounce;
|
|
99
|
+
if (newToast.spring === undefined) newToast.spring = props.spring;
|
|
100
|
+
if (newToast.showProgress === undefined)
|
|
101
|
+
newToast.showProgress = props.showProgress;
|
|
102
|
+
// Only apply container showTimestamp if the toast didn't explicitly set it
|
|
103
|
+
if (newToast.showTimestamp === undefined)
|
|
104
|
+
newToast.showTimestamp = props.showTimestamp;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Position classes
|
|
111
|
+
const positionClass = computed(() => `soft-toast-container--${props.position}`);
|
|
112
|
+
|
|
113
|
+
// Visual Indexing (ignore leaving toasts so the stack closes gaps instantly)
|
|
114
|
+
const activeToasts = computed(() =>
|
|
115
|
+
positionToasts.value.filter((t) => !t.isLeaving),
|
|
116
|
+
);
|
|
117
|
+
const getVisualIndex = (toast: any, realIdx: number) => {
|
|
118
|
+
if (toast.isLeaving) return realIdx; // Keep its place while animating out
|
|
119
|
+
return activeToasts.value.findIndex((t) => t.id === toast.id);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Stack expansion (hover or focus reveals the stack)
|
|
123
|
+
const isExpanded = ref(false);
|
|
124
|
+
|
|
125
|
+
// Measured cumulative offsets for each item when stack is expanded.
|
|
126
|
+
const expandedOffsets = ref<number[]>([]);
|
|
127
|
+
const frontHeight = ref(0);
|
|
128
|
+
const totalHeight = ref(0);
|
|
129
|
+
|
|
130
|
+
const measureOffsets = () => {
|
|
131
|
+
if (!listRef.value) return;
|
|
132
|
+
const items = Array.from(
|
|
133
|
+
listRef.value.querySelectorAll<HTMLElement>(".soft-toast-item"),
|
|
134
|
+
);
|
|
135
|
+
const gapPx = props.gap ?? 10;
|
|
136
|
+
const offsets: number[] = [];
|
|
137
|
+
let cumulative = 0;
|
|
138
|
+
let firstActiveHeight = 0;
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < items.length; i++) {
|
|
141
|
+
offsets.push(cumulative);
|
|
142
|
+
|
|
143
|
+
// Only accumulate height if the toast is not leaving
|
|
144
|
+
if (items[i].getAttribute("data-leaving") !== "true") {
|
|
145
|
+
cumulative += items[i].offsetHeight + gapPx;
|
|
146
|
+
// The front toast is the one that has visual index 0
|
|
147
|
+
const toastId = items[i].dataset.toastId;
|
|
148
|
+
const t = positionToasts.value.find(x => x.id === toastId);
|
|
149
|
+
if (t) {
|
|
150
|
+
const vIdx = getVisualIndex(t, positionToasts.value.indexOf(t));
|
|
151
|
+
if (vIdx === 0) {
|
|
152
|
+
firstActiveHeight = items[i].offsetHeight;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
expandedOffsets.value = offsets;
|
|
159
|
+
frontHeight.value = firstActiveHeight;
|
|
160
|
+
totalHeight.value = Math.max(0, cumulative - gapPx);
|
|
161
|
+
|
|
162
|
+
// Re-clamp scroll if height changes while expanded
|
|
163
|
+
if (isExpanded.value && listRef.value) {
|
|
164
|
+
clampAndApplyScroll(0); // Applies bounds check without adding new delta
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Custom GSAP Scrolling
|
|
169
|
+
const currentScrollY = ref(0);
|
|
170
|
+
|
|
171
|
+
const clampAndApplyScroll = (deltaY: number) => {
|
|
172
|
+
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));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
gsap.to(listRef.value, {
|
|
194
|
+
y: currentScrollY.value,
|
|
195
|
+
duration: 0.4,
|
|
196
|
+
ease: "power3.out",
|
|
197
|
+
overwrite: "auto"
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const handleWheel = (e: WheelEvent) => {
|
|
202
|
+
if (!isExpanded.value) return;
|
|
203
|
+
|
|
204
|
+
// Check if the scroll originated from inside a scrollable element (like a long description)
|
|
205
|
+
let target = e.target as HTMLElement | null;
|
|
206
|
+
let isInternalScroll = false;
|
|
207
|
+
|
|
208
|
+
while (target && target !== stackRef.value) {
|
|
209
|
+
const style = window.getComputedStyle(target);
|
|
210
|
+
const overflowY = style.overflowY;
|
|
211
|
+
|
|
212
|
+
// Check if the element is capable of vertical scrolling
|
|
213
|
+
if (overflowY === "auto" || overflowY === "scroll") {
|
|
214
|
+
// It is a scrollable container. Check if it actually has overflow
|
|
215
|
+
if (target.scrollHeight > target.clientHeight) {
|
|
216
|
+
// Check if we can scroll in the direction of the wheel
|
|
217
|
+
// (deltaY > 0 is scrolling down, deltaY < 0 is scrolling up)
|
|
218
|
+
const canScrollDown = Math.ceil(target.scrollTop + target.clientHeight) < target.scrollHeight;
|
|
219
|
+
const canScrollUp = target.scrollTop > 0;
|
|
220
|
+
|
|
221
|
+
if ((e.deltaY > 0 && canScrollDown) || (e.deltaY < 0 && canScrollUp)) {
|
|
222
|
+
isInternalScroll = true;
|
|
223
|
+
break; // It's a valid internal scroll, stop checking parents
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
target = target.parentElement;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (isInternalScroll) {
|
|
231
|
+
// Don't intercept the wheel event; let the internal scrollbar handle it
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 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);
|
|
237
|
+
if (maxScrollNeeded <= 0) return;
|
|
238
|
+
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
clampAndApplyScroll(e.deltaY);
|
|
241
|
+
};
|
|
242
|
+
|
|
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
|
+
});
|
|
248
|
+
|
|
249
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
250
|
+
const handleStackEnter = () => {
|
|
251
|
+
measureOffsets();
|
|
252
|
+
isExpanded.value = true;
|
|
253
|
+
};
|
|
254
|
+
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" });
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// Direction the stack peeks toward (bottom positions peek up, top positions peek down)
|
|
264
|
+
const stackDirection = computed<"up" | "down">(() =>
|
|
265
|
+
props.position.includes("bottom") ? "up" : "down",
|
|
266
|
+
);
|
|
267
|
+
</script>
|
|
268
|
+
|
|
269
|
+
<template>
|
|
270
|
+
<Teleport to="body">
|
|
271
|
+
<div
|
|
272
|
+
v-show="positionToasts.length > 0"
|
|
273
|
+
ref="containerRef"
|
|
274
|
+
class="soft-toast-container"
|
|
275
|
+
:class="positionClass"
|
|
276
|
+
:data-position="position"
|
|
277
|
+
:data-soft-toast-theme="theme"
|
|
278
|
+
:data-soft-toast-dir="dir"
|
|
279
|
+
:data-expanded="isExpanded"
|
|
280
|
+
>
|
|
281
|
+
<div
|
|
282
|
+
ref="stackRef"
|
|
283
|
+
class="soft-toast-stack"
|
|
284
|
+
:data-direction="stackDirection"
|
|
285
|
+
@mouseenter="handleStackEnter"
|
|
286
|
+
@mouseleave="handleStackLeave"
|
|
287
|
+
@wheel="handleWheel"
|
|
288
|
+
data-lenis-prevent="true"
|
|
289
|
+
>
|
|
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>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</Teleport>
|
|
315
|
+
</template>
|
|
316
|
+
|
|
317
|
+
<style scoped>
|
|
318
|
+
.soft-toast-stack {
|
|
319
|
+
position: relative;
|
|
320
|
+
pointer-events: auto;
|
|
321
|
+
/* Generous padding ensures hover states don't flicker */
|
|
322
|
+
padding-top: 32px;
|
|
323
|
+
padding-bottom: 32px;
|
|
324
|
+
margin-top: -32px;
|
|
325
|
+
margin-bottom: -32px;
|
|
326
|
+
width: 100%;
|
|
327
|
+
max-width: 100%;
|
|
328
|
+
display: flex;
|
|
329
|
+
flex-direction: column;
|
|
330
|
+
/* NO overflow constraints here to prevent shadow clipping.
|
|
331
|
+
GSAP translation handles scrolling visually without clipping! */
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.soft-toast-list {
|
|
335
|
+
position: relative;
|
|
336
|
+
width: 100%;
|
|
337
|
+
flex-shrink: 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* Align list to bottom when stack peeks UP */
|
|
341
|
+
.soft-toast-stack[data-direction="up"] .soft-toast-list {
|
|
342
|
+
margin-top: auto;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* Align list to top when stack peeks DOWN */
|
|
346
|
+
.soft-toast-stack[data-direction="down"] .soft-toast-list {
|
|
347
|
+
margin-bottom: auto;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* All toasts are absolutely positioned within the list.
|
|
351
|
+
GSAP drives their y-position based on stack index and expanded state. */
|
|
352
|
+
.soft-toast-list > :deep(.soft-toast-item) {
|
|
353
|
+
position: absolute;
|
|
354
|
+
width: 100%;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* Horizontal alignment based on container position */
|
|
358
|
+
.soft-toast-container[data-position$="left"] .soft-toast-list > :deep(.soft-toast-item) {
|
|
359
|
+
left: 0;
|
|
360
|
+
right: auto;
|
|
361
|
+
}
|
|
362
|
+
.soft-toast-container[data-position$="right"] .soft-toast-list > :deep(.soft-toast-item) {
|
|
363
|
+
right: 0;
|
|
364
|
+
left: auto;
|
|
365
|
+
}
|
|
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
|
+
/* 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) {
|
|
373
|
+
top: 0;
|
|
374
|
+
bottom: auto;
|
|
375
|
+
}
|
|
376
|
+
/* 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) {
|
|
378
|
+
top: auto;
|
|
379
|
+
bottom: 0;
|
|
380
|
+
}
|
|
381
|
+
</style>
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Flash Message system (useFlash.ts)
|
|
3
|
+
*
|
|
4
|
+
* Flash messages survive page navigation via sessionStorage.
|
|
5
|
+
* We install a Map-backed sessionStorage mock before each test so
|
|
6
|
+
* the functions run in a browser-like environment even under Bun/Node.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeEach, afterAll } from 'bun:test'
|
|
9
|
+
import { toastStore } from '../stores/toastStore'
|
|
10
|
+
|
|
11
|
+
// ── sessionStorage mock ───────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const createMockSessionStorage = () => {
|
|
14
|
+
const _store = new Map<string, string>()
|
|
15
|
+
return {
|
|
16
|
+
getItem: (key: string) => _store.get(key) ?? null,
|
|
17
|
+
setItem: (key: string, value: string) => { _store.set(key, value) },
|
|
18
|
+
removeItem: (key: string) => { _store.delete(key) },
|
|
19
|
+
clear: () => { _store.clear() },
|
|
20
|
+
get length() { return _store.size },
|
|
21
|
+
key: (i: number) => [..._store.keys()][i] ?? null,
|
|
22
|
+
_clear: () => _store.clear(),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mockStorage = createMockSessionStorage()
|
|
27
|
+
|
|
28
|
+
// Install mock BEFORE importing useFlash so that typeof checks inside
|
|
29
|
+
// the module functions find a defined sessionStorage.
|
|
30
|
+
;(globalThis as Record<string, unknown>).sessionStorage = mockStorage
|
|
31
|
+
|
|
32
|
+
// Now import — functions will see the mock when called.
|
|
33
|
+
// Dynamic import resolves after the globalThis assignment above.
|
|
34
|
+
const { queueFlash, consumeFlashes, hasPendingFlashes } = await import('./useFlash')
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
// Clear both the mock store and the toast store between tests
|
|
38
|
+
mockStorage._clear()
|
|
39
|
+
toastStore.clearAll()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
// Tidy up the global after all tests in this file
|
|
44
|
+
delete (globalThis as Record<string, unknown>).sessionStorage
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// ─── queueFlash() ─────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe('queueFlash()', () => {
|
|
50
|
+
it('stores a flash item in sessionStorage', () => {
|
|
51
|
+
queueFlash('Profile saved', { type: 'success' })
|
|
52
|
+
const raw = mockStorage.getItem('@soft-toast/vue:flash')
|
|
53
|
+
expect(raw).not.toBeNull()
|
|
54
|
+
const items = JSON.parse(raw!)
|
|
55
|
+
expect(items.length).toBe(1)
|
|
56
|
+
expect(items[0].title).toBe('Profile saved')
|
|
57
|
+
expect(items[0].options.type).toBe('success')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('accumulates multiple flash items', () => {
|
|
61
|
+
queueFlash('First')
|
|
62
|
+
queueFlash('Second')
|
|
63
|
+
const items = JSON.parse(mockStorage.getItem('@soft-toast/vue:flash') || '[]')
|
|
64
|
+
expect(items.length).toBe(2)
|
|
65
|
+
expect(items[0].title).toBe('First')
|
|
66
|
+
expect(items[1].title).toBe('Second')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('discards stale items (>30 s) when adding a new one', () => {
|
|
70
|
+
// Manually insert an expired item
|
|
71
|
+
const stale = [{ title: 'Old', options: {}, queuedAt: Date.now() - 31_000 }]
|
|
72
|
+
mockStorage.setItem('@soft-toast/vue:flash', JSON.stringify(stale))
|
|
73
|
+
|
|
74
|
+
queueFlash('Fresh')
|
|
75
|
+
const items = JSON.parse(mockStorage.getItem('@soft-toast/vue:flash') || '[]')
|
|
76
|
+
expect(items.length).toBe(1)
|
|
77
|
+
expect(items[0].title).toBe('Fresh')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('stores a timestamp (queuedAt)', () => {
|
|
81
|
+
const before = Date.now()
|
|
82
|
+
queueFlash('Timestamped')
|
|
83
|
+
const after = Date.now()
|
|
84
|
+
const item = JSON.parse(mockStorage.getItem('@soft-toast/vue:flash') || '[]')[0]
|
|
85
|
+
expect(item.queuedAt).toBeGreaterThanOrEqual(before)
|
|
86
|
+
expect(item.queuedAt).toBeLessThanOrEqual(after)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ─── consumeFlashes() ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('consumeFlashes()', () => {
|
|
93
|
+
it('returns 0 and does nothing when storage is empty', () => {
|
|
94
|
+
const count = consumeFlashes()
|
|
95
|
+
expect(count).toBe(0)
|
|
96
|
+
expect(toastStore.toasts.value.length).toBe(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('adds toasts for each queued flash', () => {
|
|
100
|
+
queueFlash('Saved!', { type: 'success' })
|
|
101
|
+
queueFlash('Watch out', { type: 'warning' })
|
|
102
|
+
|
|
103
|
+
const count = consumeFlashes()
|
|
104
|
+
expect(count).toBe(2)
|
|
105
|
+
expect(toastStore.toasts.value.length).toBe(2)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('clears sessionStorage after consuming', () => {
|
|
109
|
+
queueFlash('Gone after consume')
|
|
110
|
+
consumeFlashes()
|
|
111
|
+
const raw = mockStorage.getItem('@soft-toast/vue:flash')
|
|
112
|
+
const items = JSON.parse(raw || '[]')
|
|
113
|
+
expect(items.length).toBe(0)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('does not double-show on a second call', () => {
|
|
117
|
+
queueFlash('Once only')
|
|
118
|
+
consumeFlashes()
|
|
119
|
+
const countAgain = consumeFlashes()
|
|
120
|
+
expect(countAgain).toBe(0)
|
|
121
|
+
// Only 1 toast created in total
|
|
122
|
+
expect(toastStore.toasts.value.length).toBe(1)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('ignores flash items older than 30 seconds', () => {
|
|
126
|
+
const expired = [{ title: 'Old news', options: {}, queuedAt: Date.now() - 31_000 }]
|
|
127
|
+
mockStorage.setItem('@soft-toast/vue:flash', JSON.stringify(expired))
|
|
128
|
+
|
|
129
|
+
const count = consumeFlashes()
|
|
130
|
+
expect(count).toBe(0)
|
|
131
|
+
expect(toastStore.toasts.value.length).toBe(0)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('respects the type option from the queued flash', () => {
|
|
135
|
+
queueFlash('Error flash', { type: 'error' })
|
|
136
|
+
consumeFlashes()
|
|
137
|
+
expect(toastStore.toasts.value[0].type).toBe('error')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ─── hasPendingFlashes() ──────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
describe('hasPendingFlashes()', () => {
|
|
144
|
+
it('returns false when storage is empty', () => {
|
|
145
|
+
expect(hasPendingFlashes()).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('returns true after queueFlash()', () => {
|
|
149
|
+
queueFlash('Pending')
|
|
150
|
+
expect(hasPendingFlashes()).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('returns false after consumeFlashes()', () => {
|
|
154
|
+
queueFlash('Will be consumed')
|
|
155
|
+
consumeFlashes()
|
|
156
|
+
expect(hasPendingFlashes()).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('returns false if all items are older than 30 seconds', () => {
|
|
160
|
+
const stale = [{ title: 'Old', options: {}, queuedAt: Date.now() - 31_000 }]
|
|
161
|
+
mockStorage.setItem('@soft-toast/vue:flash', JSON.stringify(stale))
|
|
162
|
+
expect(hasPendingFlashes()).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @soft-toast/vue — Flash Message System
|
|
3
|
+
*
|
|
4
|
+
* Queues toasts that survive page navigation via sessionStorage.
|
|
5
|
+
* Perfect for the "submit form → redirect → show success" pattern.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* // Before redirect:
|
|
9
|
+
* toast.flash('Profile saved!', { type: 'success' })
|
|
10
|
+
* router.push('/dashboard')
|
|
11
|
+
*
|
|
12
|
+
* // In the destination page (or App.vue):
|
|
13
|
+
* const { showPendingFlashes } = useFlash()
|
|
14
|
+
* onMounted(showPendingFlashes)
|
|
15
|
+
*
|
|
16
|
+
* // Or let the plugin auto-show them (if autoFlash: true in plugin options)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ToastOptions } from '../types'
|
|
20
|
+
import { toastStore } from '../stores/toastStore'
|
|
21
|
+
|
|
22
|
+
const FLASH_STORAGE_KEY = '@soft-toast/vue:flash'
|
|
23
|
+
|
|
24
|
+
interface FlashItem {
|
|
25
|
+
title: string
|
|
26
|
+
options: Partial<Omit<ToastOptions, 'id'>>
|
|
27
|
+
queuedAt: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Storage helpers ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const readFlashes = (): FlashItem[] => {
|
|
33
|
+
if (typeof sessionStorage === 'undefined') return []
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(sessionStorage.getItem(FLASH_STORAGE_KEY) || '[]')
|
|
36
|
+
} catch {
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const writeFlashes = (items: FlashItem[]) => {
|
|
42
|
+
if (typeof sessionStorage === 'undefined') return
|
|
43
|
+
try {
|
|
44
|
+
sessionStorage.setItem(FLASH_STORAGE_KEY, JSON.stringify(items))
|
|
45
|
+
} catch {
|
|
46
|
+
/* storage quota or unavailable */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Queue a flash toast to show on the NEXT page load / route change.
|
|
54
|
+
* Flash items expire after 30 seconds to avoid stale messages.
|
|
55
|
+
*/
|
|
56
|
+
export const queueFlash = (
|
|
57
|
+
title: string,
|
|
58
|
+
options: Partial<Omit<ToastOptions, 'id'>> = {}
|
|
59
|
+
): void => {
|
|
60
|
+
const existing = readFlashes().filter(
|
|
61
|
+
(f) => Date.now() - f.queuedAt < 30_000 // discard stale flashes
|
|
62
|
+
)
|
|
63
|
+
existing.push({ title, options, queuedAt: Date.now() })
|
|
64
|
+
writeFlashes(existing)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Consume all pending flash messages and show them as toasts.
|
|
69
|
+
* Call this in onMounted() of your layout or App.vue.
|
|
70
|
+
* Returns the number of flashes shown.
|
|
71
|
+
*/
|
|
72
|
+
export const consumeFlashes = (): number => {
|
|
73
|
+
const flashes = readFlashes().filter(
|
|
74
|
+
(f) => Date.now() - f.queuedAt < 30_000
|
|
75
|
+
)
|
|
76
|
+
// Clear storage immediately to avoid double-show
|
|
77
|
+
writeFlashes([])
|
|
78
|
+
|
|
79
|
+
flashes.forEach((f) => {
|
|
80
|
+
toastStore.add({ title: f.title, type: 'default', ...f.options })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return flashes.length
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if there are pending flashes without consuming them.
|
|
88
|
+
*/
|
|
89
|
+
export const hasPendingFlashes = (): boolean => {
|
|
90
|
+
return readFlashes().some((f) => Date.now() - f.queuedAt < 30_000)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Vue composable ──────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* useFlash() — composable for components and route guards.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* // In a page component:
|
|
100
|
+
* const { flash } = useFlash()
|
|
101
|
+
* const save = async () => {
|
|
102
|
+
* await api.save()
|
|
103
|
+
* flash('Changes saved!', { type: 'success' })
|
|
104
|
+
* router.push('/home')
|
|
105
|
+
* }
|
|
106
|
+
*
|
|
107
|
+
* // In App.vue or layout:
|
|
108
|
+
* const { showPendingFlashes } = useFlash()
|
|
109
|
+
* onMounted(showPendingFlashes)
|
|
110
|
+
*/
|
|
111
|
+
export const useFlash = () => ({
|
|
112
|
+
/** Queue a toast that will appear on the next page/route */
|
|
113
|
+
flash: queueFlash,
|
|
114
|
+
/** Show all pending flashes now — call in onMounted */
|
|
115
|
+
showPendingFlashes: consumeFlashes,
|
|
116
|
+
/** True if there are flashes waiting to be shown */
|
|
117
|
+
hasPending: hasPendingFlashes,
|
|
118
|
+
})
|