@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,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
+ })