@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,230 @@
1
+ import { describe, it, expect, beforeEach } from 'bun:test'
2
+ import { useToast, toast } from './useToast'
3
+ import { toastStore } from '../stores/toastStore'
4
+
5
+ // Reset store state before each test
6
+ beforeEach(() => {
7
+ toastStore.clearAll()
8
+ })
9
+
10
+ // ─── useToast() composable ────────────────────────────────────────────────────
11
+
12
+ describe('useToast()', () => {
13
+ it('returns an object with all API methods', () => {
14
+ const t = useToast()
15
+ expect(typeof t.default).toBe('function')
16
+ expect(typeof t.success).toBe('function')
17
+ expect(typeof t.error).toBe('function')
18
+ expect(typeof t.warning).toBe('function')
19
+ expect(typeof t.info).toBe('function')
20
+ expect(typeof t.promise).toBe('function')
21
+ expect(typeof t.custom).toBe('function')
22
+ expect(typeof t.update).toBe('function')
23
+ expect(typeof t.dismiss).toBe('function')
24
+ expect(typeof t.dismissAll).toBe('function')
25
+ expect(typeof t.pause).toBe('function')
26
+ expect(typeof t.resume).toBe('function')
27
+ })
28
+
29
+ it('default() adds a toast with type "default"', () => {
30
+ const t = useToast()
31
+ const id = t.default('Hello')
32
+ const toasts = toastStore.toasts.value
33
+ expect(toasts.length).toBe(1)
34
+ expect(toasts[0].type).toBe('default')
35
+ expect(toasts[0].title).toBe('Hello')
36
+ expect(toasts[0].id).toBe(id)
37
+ })
38
+
39
+ it('success() adds a toast with type "success"', () => {
40
+ const t = useToast()
41
+ t.success('Done!')
42
+ expect(toastStore.toasts.value[0].type).toBe('success')
43
+ expect(toastStore.toasts.value[0].title).toBe('Done!')
44
+ })
45
+
46
+ it('error() adds a toast with type "error"', () => {
47
+ const t = useToast()
48
+ t.error('Oops!')
49
+ expect(toastStore.toasts.value[0].type).toBe('error')
50
+ })
51
+
52
+ it('warning() adds a toast with type "warning"', () => {
53
+ const t = useToast()
54
+ t.warning('Watch out!')
55
+ expect(toastStore.toasts.value[0].type).toBe('warning')
56
+ })
57
+
58
+ it('info() adds a toast with type "info"', () => {
59
+ const t = useToast()
60
+ t.info('FYI')
61
+ expect(toastStore.toasts.value[0].type).toBe('info')
62
+ })
63
+
64
+ it('custom() passes options through directly', () => {
65
+ const t = useToast()
66
+ t.custom({ type: 'success', title: 'Custom', description: 'My desc', duration: 9999 })
67
+ const added = toastStore.toasts.value[0]
68
+ expect(added.title).toBe('Custom')
69
+ expect(added.description).toBe('My desc')
70
+ expect(added.duration).toBe(9999)
71
+ })
72
+
73
+ it('update() changes an existing toast', () => {
74
+ const t = useToast()
75
+ const id = t.default('Before')
76
+ t.update(id, { title: 'After', type: 'success' })
77
+ const updated = toastStore.toasts.value.find(x => x.id === id)
78
+ expect(updated?.title).toBe('After')
79
+ expect(updated?.type).toBe('success')
80
+ })
81
+
82
+ it('dismiss() marks toast as isLeaving', () => {
83
+ const t = useToast()
84
+ const id = t.default('Bye')
85
+ t.dismiss(id)
86
+ const toast = toastStore.toasts.value.find(x => x.id === id)
87
+ expect(toast?.isLeaving).toBe(true)
88
+ })
89
+
90
+ it('pause() and resume() toggle isPaused', () => {
91
+ const t = useToast()
92
+ const id = t.default('Pausing')
93
+ t.pause(id)
94
+ expect(toastStore.toasts.value[0].isPaused).toBe(true)
95
+ t.resume(id)
96
+ expect(toastStore.toasts.value[0].isPaused).toBe(false)
97
+ })
98
+
99
+ it('forward options like duration, description, position', () => {
100
+ const t = useToast()
101
+ t.success('With opts', {
102
+ description: 'More info',
103
+ duration: 1234,
104
+ position: 'bottom-center'
105
+ })
106
+ const added = toastStore.toasts.value[0]
107
+ expect(added.description).toBe('More info')
108
+ expect(added.duration).toBe(1234)
109
+ expect(added.position).toBe('bottom-center')
110
+ })
111
+ })
112
+
113
+ // ─── toast (static object) ────────────────────────────────────────────────────
114
+
115
+ describe('toast (static API)', () => {
116
+ it('toast.success() adds a success toast', () => {
117
+ toast.success('Static success')
118
+ expect(toastStore.toasts.value[0].type).toBe('success')
119
+ expect(toastStore.toasts.value[0].title).toBe('Static success')
120
+ })
121
+
122
+ it('toast.error() adds an error toast', () => {
123
+ toast.error('Static error')
124
+ expect(toastStore.toasts.value[0].type).toBe('error')
125
+ })
126
+
127
+ it('toast.warning() adds a warning toast', () => {
128
+ toast.warning('Careful')
129
+ expect(toastStore.toasts.value[0].type).toBe('warning')
130
+ })
131
+
132
+ it('toast.info() adds an info toast', () => {
133
+ toast.info('Just so you know')
134
+ expect(toastStore.toasts.value[0].type).toBe('info')
135
+ })
136
+
137
+ it('toast.default() adds a default toast', () => {
138
+ toast.default('Hello world')
139
+ expect(toastStore.toasts.value[0].type).toBe('default')
140
+ })
141
+
142
+ it('toast.custom() adds toast with arbitrary options', () => {
143
+ toast.custom({ title: 'Custom static', type: 'info', id: 'static-1' })
144
+ expect(toastStore.toasts.value[0].id).toBe('static-1')
145
+ expect(toastStore.toasts.value[0].title).toBe('Custom static')
146
+ })
147
+
148
+ it('toast.update() modifies a toast', () => {
149
+ const id = toast.default('Initial')
150
+ toast.update(id, { title: 'Updated' })
151
+ expect(toastStore.toasts.value.find(x => x.id === id)?.title).toBe('Updated')
152
+ })
153
+
154
+ it('toast.pause() and toast.resume() work', () => {
155
+ const id = toast.info('Pausable')
156
+ toast.pause(id)
157
+ expect(toastStore.toasts.value[0].isPaused).toBe(true)
158
+ toast.resume(id)
159
+ expect(toastStore.toasts.value[0].isPaused).toBe(false)
160
+ })
161
+
162
+ it('toast.dismiss() marks toast as leaving', () => {
163
+ const id = toast.success('Going away')
164
+ toast.dismiss(id)
165
+ expect(toastStore.toasts.value.find(x => x.id === id)?.isLeaving).toBe(true)
166
+ })
167
+
168
+ it('multiple toasts are ordered newest-first', () => {
169
+ toast.default('First')
170
+ toast.success('Second')
171
+ toast.error('Third')
172
+ const titles = toastStore.toasts.value.map(t => t.title)
173
+ expect(titles).toEqual(['Third', 'Second', 'First'])
174
+ })
175
+ })
176
+
177
+ // ─── toast.promise() ─────────────────────────────────────────────────────────
178
+
179
+ describe('toast.promise()', () => {
180
+ it('resolves: updates toast to success', async () => {
181
+ const id = await toast.promise(
182
+ Promise.resolve('ok'),
183
+ { loading: 'Loading…', success: 'Done!', error: 'Failed' }
184
+ )
185
+ // After resolve the store should have a success toast
186
+ const t = toastStore.toasts.value.find(x => x.title === 'Done!')
187
+ expect(t?.type).toBe('success')
188
+ expect(id).toBe('ok')
189
+ })
190
+
191
+ it('rejects: updates toast to error and re-throws', async () => {
192
+ let caught: unknown
193
+ try {
194
+ await toast.promise(
195
+ Promise.reject(new Error('boom')),
196
+ { loading: 'Loading…', success: 'Done!', error: 'Something failed' }
197
+ )
198
+ } catch (e) {
199
+ caught = e
200
+ }
201
+ expect(caught).toBeInstanceOf(Error)
202
+ const t = toastStore.toasts.value.find(x => x.title === 'Something failed')
203
+ expect(t?.type).toBe('error')
204
+ })
205
+ })
206
+
207
+ // ─── Flash & sound API surface ────────────────────────────────────────────────
208
+
209
+ describe('useToast() — flash API methods', () => {
210
+ it('returns flash, showFlashes, hasFlashes methods', () => {
211
+ const t = useToast()
212
+ expect(typeof t.flash).toBe('function')
213
+ expect(typeof t.showFlashes).toBe('function')
214
+ expect(typeof t.hasFlashes).toBe('function')
215
+ })
216
+ })
217
+
218
+ describe('toast (static) — flash API methods', () => {
219
+ it('toast.flash is a function', () => {
220
+ expect(typeof toast.flash).toBe('function')
221
+ })
222
+
223
+ it('toast.showFlashes is a function', () => {
224
+ expect(typeof toast.showFlashes).toBe('function')
225
+ })
226
+
227
+ it('toast.hasFlashes is a function', () => {
228
+ expect(typeof toast.hasFlashes).toBe('function')
229
+ })
230
+ })
@@ -0,0 +1,95 @@
1
+ import type { ToastOptions, ToastPromiseMessages } from '../types'
2
+ import { toastStore } from '../stores/toastStore'
3
+ import { queueFlash, consumeFlashes, hasPendingFlashes } from './useFlash'
4
+
5
+ // ─── Composable API ───────────────────────────────────────────────────────────
6
+
7
+ export const useToast = () => ({
8
+ default: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
9
+ toastStore.add({ ...options, type: 'default', title }),
10
+
11
+ success: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
12
+ toastStore.add({ ...options, type: 'success', title }),
13
+
14
+ error: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
15
+ toastStore.add({ ...options, type: 'error', title }),
16
+
17
+ warning: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
18
+ toastStore.add({ ...options, type: 'warning', title }),
19
+
20
+ info: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
21
+ toastStore.add({ ...options, type: 'info', title }),
22
+
23
+ loading: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
24
+ toastStore.loading(title, options),
25
+
26
+ promise: <T>(
27
+ promiseFn: Promise<T>,
28
+ messages: ToastPromiseMessages,
29
+ options?: Omit<ToastOptions, 'type' | 'promise' | 'promiseMessages'>
30
+ ): Promise<T> => toastStore.promise(promiseFn, messages, options),
31
+
32
+ custom: (options: ToastOptions) => toastStore.add(options),
33
+
34
+ update: (id: string, options: Partial<ToastOptions>) => toastStore.update(id, options),
35
+
36
+ dismiss: (id?: string) => toastStore.dismiss(id),
37
+ dismissAll: () => toastStore.dismiss(),
38
+
39
+ pause: (id: string) => toastStore.pause(id),
40
+ resume: (id: string) => toastStore.resume(id),
41
+
42
+ /**
43
+ * Queue a toast that will be shown on the next page load / route navigation.
44
+ * Perfect for the "submit → redirect → show success" pattern.
45
+ *
46
+ * @example
47
+ * const { flash } = useToast()
48
+ * await api.save()
49
+ * flash('Saved!', { type: 'success' })
50
+ * router.push('/dashboard')
51
+ */
52
+ flash: (title: string, options: Partial<Omit<ToastOptions, 'id'>> = {}) =>
53
+ queueFlash(title, options),
54
+
55
+ /**
56
+ * Show any toasts that were queued with flash() before a page navigation.
57
+ * Call this in onMounted() of your root layout or App.vue.
58
+ */
59
+ showFlashes: () => consumeFlashes(),
60
+
61
+ /** Check if there are pending flash messages without consuming them. */
62
+ hasFlashes: () => hasPendingFlashes(),
63
+ })
64
+
65
+ // ─── Singleton API (usable outside components) ────────────────────────────────
66
+
67
+ export const toast = {
68
+ default: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
69
+ toastStore.add({ ...options, type: 'default', title }),
70
+ success: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
71
+ toastStore.add({ ...options, type: 'success', title }),
72
+ error: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
73
+ toastStore.add({ ...options, type: 'error', title }),
74
+ warning: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
75
+ toastStore.add({ ...options, type: 'warning', title }),
76
+ info: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
77
+ toastStore.add({ ...options, type: 'info', title }),
78
+ loading: (title: string, options?: Omit<ToastOptions, 'type' | 'title'>) =>
79
+ toastStore.loading(title, options),
80
+ promise: <T>(
81
+ promiseFn: Promise<T>,
82
+ messages: ToastPromiseMessages,
83
+ options?: Omit<ToastOptions, 'type' | 'promise' | 'promiseMessages'>
84
+ ): Promise<T> => toastStore.promise(promiseFn, messages, options),
85
+ custom: (options: ToastOptions) => toastStore.add(options),
86
+ update: (id: string, options: Partial<ToastOptions>) => toastStore.update(id, options),
87
+ dismiss: (id?: string) => toastStore.dismiss(id),
88
+ dismissAll: () => toastStore.dismiss(),
89
+ pause: (id: string) => toastStore.pause(id),
90
+ resume: (id: string) => toastStore.resume(id),
91
+ flash: (title: string, options: Partial<Omit<ToastOptions, 'id'>> = {}) =>
92
+ queueFlash(title, options),
93
+ showFlashes: () => consumeFlashes(),
94
+ hasFlashes: () => hasPendingFlashes(),
95
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+
3
+ // Test that all public exports exist and have the right shape
4
+ // This acts as a "contract test" — if something is accidentally removed, CI will catch it.
5
+
6
+ describe('Package exports', () => {
7
+ it('exports SoftToastPlugin with install()', async () => {
8
+ const { SoftToastPlugin } = await import('./index')
9
+ expect(SoftToastPlugin).toBeDefined()
10
+ expect(typeof SoftToastPlugin.install).toBe('function')
11
+ })
12
+
13
+ it('exports getToastOptions()', async () => {
14
+ const { getToastOptions } = await import('./index')
15
+ expect(typeof getToastOptions).toBe('function')
16
+ const opts = getToastOptions()
17
+ // Should return an object with default values
18
+ expect(opts).toHaveProperty('position')
19
+ expect(opts).toHaveProperty('duration')
20
+ expect(opts).toHaveProperty('theme')
21
+ })
22
+
23
+ it('exports useToast()', async () => {
24
+ const { useToast } = await import('./index')
25
+ expect(typeof useToast).toBe('function')
26
+ const api = useToast()
27
+ expect(typeof api.success).toBe('function')
28
+ expect(typeof api.error).toBe('function')
29
+ })
30
+
31
+ it('exports toast object', async () => {
32
+ const { toast } = await import('./index')
33
+ expect(toast).toBeDefined()
34
+ expect(typeof toast.success).toBe('function')
35
+ expect(typeof toast.error).toBe('function')
36
+ expect(typeof toast.warning).toBe('function')
37
+ expect(typeof toast.info).toBe('function')
38
+ expect(typeof toast.default).toBe('function')
39
+ expect(typeof toast.promise).toBe('function')
40
+ expect(typeof toast.custom).toBe('function')
41
+ expect(typeof toast.update).toBe('function')
42
+ expect(typeof toast.dismiss).toBe('function')
43
+ expect(typeof toast.dismissAll).toBe('function')
44
+ expect(typeof toast.pause).toBe('function')
45
+ expect(typeof toast.resume).toBe('function')
46
+ // Flash & sound API
47
+ expect(typeof toast.flash).toBe('function')
48
+ expect(typeof toast.showFlashes).toBe('function')
49
+ expect(typeof toast.hasFlashes).toBe('function')
50
+ })
51
+
52
+ it('exports toastStore with all methods', async () => {
53
+ const { toastStore } = await import('./index')
54
+ expect(toastStore).toBeDefined()
55
+ expect(typeof toastStore.add).toBe('function')
56
+ expect(typeof toastStore.remove).toBe('function')
57
+ expect(typeof toastStore.clearAll).toBe('function')
58
+ expect(typeof toastStore.dismiss).toBe('function')
59
+ expect(typeof toastStore.pause).toBe('function')
60
+ expect(typeof toastStore.resume).toBe('function')
61
+ expect(typeof toastStore.update).toBe('function')
62
+ expect(typeof toastStore.success).toBe('function')
63
+ expect(typeof toastStore.error).toBe('function')
64
+ expect(typeof toastStore.warning).toBe('function')
65
+ expect(typeof toastStore.info).toBe('function')
66
+ expect(typeof toastStore.promise).toBe('function')
67
+ expect(typeof toastStore.getToastsByPosition).toBe('function')
68
+ // toasts should be a computed ref
69
+ expect(toastStore.toasts).toBeDefined()
70
+ expect(typeof toastStore.toasts.value).toBe('object')
71
+ })
72
+ })
package/src/icons.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { addIcon } from "@iconify/vue";
2
+
3
+ let registered = false;
4
+
5
+ const iconDefaults = {
6
+ width: 24,
7
+ height: 24,
8
+ };
9
+
10
+ export const registerToastIcons = () => {
11
+ if (registered) return;
12
+ registered = true;
13
+
14
+ addIcon("lucide:circle-check", {
15
+ ...iconDefaults,
16
+ body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m9 12l2 2l4-4"/></g>',
17
+ });
18
+ addIcon("lucide:circle-x", {
19
+ ...iconDefaults,
20
+ body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m15 9l-6 6m0-6l6 6"/></g>',
21
+ });
22
+ addIcon("lucide:triangle-alert", {
23
+ ...iconDefaults,
24
+ body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21.73 18l-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3M12 9v4m0 4h.01"/>',
25
+ });
26
+ addIcon("lucide:info", {
27
+ ...iconDefaults,
28
+ body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4m0-4h.01"/></g>',
29
+ });
30
+ addIcon("lucide:bell", {
31
+ ...iconDefaults,
32
+ body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.268 21a2 2 0 0 0 3.464 0m-10.47-5.674A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
33
+ });
34
+ addIcon("lucide:loader-circle", {
35
+ ...iconDefaults,
36
+ body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 1 1-6.219-8.56"/>',
37
+ });
38
+ };
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Main exports
2
+ export { SoftToastPlugin, getToastOptions } from './plugin'
3
+ export { useToast, toast } from './composables/useToast'
4
+ export { useFlash, queueFlash, consumeFlashes, hasPendingFlashes } from './composables/useFlash'
5
+ export { toastStore } from './stores/toastStore'
6
+
7
+ // Components
8
+ export { default as ToastContainer } from './components/ToastContainer.vue'
9
+ export { default as ToastItem } from './components/ToastItem.vue'
10
+
11
+ // Types
12
+ export type {
13
+ Toast,
14
+ ToastType,
15
+ ToastPosition,
16
+ ToastOptions,
17
+ ToastAction,
18
+ ToastPromiseMessages,
19
+ ToastClassNames,
20
+ ToastContainerProps,
21
+ ToastPluginOptions,
22
+ AnimationPreset,
23
+ QueueOverflow
24
+ } from './types'
25
+
package/src/plugin.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { createApp, h, type App } from 'vue'
2
+ import type { ToastPluginOptions } from './types'
3
+ import ToastContainer from './components/ToastContainer.vue'
4
+ import './styles/toast.css'
5
+
6
+ // Global state for plugin options
7
+ let pluginOptions: ToastPluginOptions = {
8
+ position: 'top-right',
9
+ duration: 4000,
10
+ theme: 'light',
11
+ spring: true,
12
+ bounce: 0.4,
13
+ preset: 'smooth',
14
+ closeOnEscape: true,
15
+ closeButton: false,
16
+ showProgress: false,
17
+ showTimestamp: false,
18
+ maxQueue: 10,
19
+ queueOverflow: 'drop-oldest',
20
+ dir: 'ltr',
21
+ swipeToDismiss: true,
22
+ teleportTarget: 'body'
23
+ }
24
+
25
+ // Flag to check if container is mounted
26
+ let isContainerMounted = false
27
+
28
+ export const SoftToastPlugin = {
29
+ install(app: App, options: ToastPluginOptions = {}) {
30
+ // Merge options
31
+ pluginOptions = { ...pluginOptions, ...options }
32
+
33
+ // Only mount container once
34
+ if (!isContainerMounted && typeof window !== 'undefined') {
35
+ // Create a container div
36
+ const containerId = 'soft-toast-global-container'
37
+ let container = document.getElementById(containerId)
38
+
39
+ if (!container) {
40
+ container = document.createElement('div')
41
+ container.id = containerId
42
+ document.body.appendChild(container)
43
+ }
44
+
45
+ // Mount ToastContainer
46
+ app.component('SoftToastContainer', ToastContainer)
47
+
48
+ // Create a mini-app instance just for the toast container
49
+ const toastContainerApp = createApp({
50
+ render: () => h(ToastContainer, {
51
+ position: pluginOptions.position,
52
+ duration: pluginOptions.duration,
53
+ gap: pluginOptions.gap,
54
+ offset: pluginOptions.offset,
55
+ theme: pluginOptions.theme,
56
+ spring: pluginOptions.spring,
57
+ bounce: pluginOptions.bounce,
58
+ preset: pluginOptions.preset,
59
+ closeOnEscape: pluginOptions.closeOnEscape,
60
+ closeButton: pluginOptions.closeButton,
61
+ showProgress: pluginOptions.showProgress,
62
+ maxQueue: pluginOptions.maxQueue,
63
+ queueOverflow: pluginOptions.queueOverflow,
64
+ dir: pluginOptions.dir,
65
+ swipeToDismiss: pluginOptions.swipeToDismiss
66
+ })
67
+ })
68
+
69
+ toastContainerApp.mount(container)
70
+ isContainerMounted = true
71
+ }
72
+
73
+ // Provide options to all components
74
+ app.provide('softToastOptions', pluginOptions)
75
+
76
+ // Set theme attribute on body
77
+ if (typeof document !== 'undefined') {
78
+ document.body.setAttribute('data-soft-toast-theme', pluginOptions.theme || 'light')
79
+ document.body.setAttribute('data-soft-toast-dir', pluginOptions.dir || 'ltr')
80
+ }
81
+ }
82
+ }
83
+
84
+ // Export options getter
85
+ export const getToastOptions = () => pluginOptions
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { toastStore } from "./toastStore";
3
+
4
+ describe("toastStore", () => {
5
+ beforeEach(() => {
6
+ toastStore.clearAll();
7
+ });
8
+
9
+ it("should add a toast with default options", () => {
10
+ const id = toastStore.add({ title: "Test Toast" });
11
+ const toasts = toastStore.toasts.value;
12
+ expect(toasts.length).toBe(1);
13
+ expect(toasts[0].id).toBe(id);
14
+ expect(toasts[0].title).toBe("Test Toast");
15
+ expect(toasts[0].position).toBe("top-right"); // Default position
16
+ expect(toasts[0].type).toBe("default");
17
+ });
18
+
19
+ it("should remove a toast by id", () => {
20
+ const id = toastStore.add({ title: "Toast to remove" });
21
+ expect(toastStore.toasts.value.length).toBe(1);
22
+
23
+ toastStore.remove(id);
24
+ expect(toastStore.toasts.value.length).toBe(0);
25
+ });
26
+
27
+ it("should clear all toasts", () => {
28
+ toastStore.add({ title: "1" });
29
+ toastStore.add({ title: "2" });
30
+ expect(toastStore.toasts.value.length).toBe(2);
31
+
32
+ toastStore.clearAll();
33
+ expect(toastStore.toasts.value.length).toBe(0);
34
+ });
35
+
36
+ it("should filter toasts by position", () => {
37
+ toastStore.add({ title: "TR 1", position: "top-right" });
38
+ toastStore.add({ title: "TR 2", position: "top-right" });
39
+ toastStore.add({ title: "BL 1", position: "bottom-left" });
40
+
41
+ const trToasts = toastStore.getToastsByPosition("top-right").value;
42
+ const blToasts = toastStore.getToastsByPosition("bottom-left").value;
43
+
44
+ expect(trToasts.length).toBe(2);
45
+ expect(blToasts.length).toBe(1);
46
+ });
47
+
48
+ it("should set isLeaving to true when dismissing", () => {
49
+ const id = toastStore.add({ title: "Dismiss me" });
50
+ toastStore.dismiss(id);
51
+
52
+ const toast = toastStore.toasts.value.find(t => t.id === id);
53
+ expect(toast).toBeDefined();
54
+ expect(toast?.isLeaving).toBe(true);
55
+ });
56
+
57
+ it("should pause and resume a toast", () => {
58
+ const id = toastStore.add({ title: "1" });
59
+
60
+ toastStore.pause(id);
61
+ expect(toastStore.toasts.value[0].isPaused).toBe(true);
62
+
63
+ toastStore.resume(id);
64
+ expect(toastStore.toasts.value[0].isPaused).toBe(false);
65
+ });
66
+
67
+ it("should generate a unique id if not provided", () => {
68
+ const id1 = toastStore.add({ title: "A" });
69
+ const id2 = toastStore.add({ title: "B" });
70
+ expect(id1).not.toBe(id2);
71
+ });
72
+
73
+ it("should use provided id", () => {
74
+ const id = toastStore.add({ id: "my-custom-id", title: "C" });
75
+ expect(id).toBe("my-custom-id");
76
+ });
77
+ });
78
+
79
+ // ─── Smart Deduplication ──────────────────────────────────────────────────────
80
+
81
+ describe("toastStore — smart deduplication", () => {
82
+ beforeEach(() => {
83
+ toastStore.clearAll();
84
+ });
85
+
86
+ it("adding same id twice updates existing toast instead of creating a new one", () => {
87
+ toastStore.add({ id: "dup-id", title: "Original" });
88
+ toastStore.add({ id: "dup-id", title: "Updated" });
89
+
90
+ const toasts = toastStore.toasts.value;
91
+ expect(toasts.length).toBe(1);
92
+ expect(toasts[0].title).toBe("Updated");
93
+ });
94
+
95
+ it("dedup updates description and type of existing toast", () => {
96
+ toastStore.add({ id: "upd-id", title: "Error A", type: "error" });
97
+ toastStore.add({ id: "upd-id", title: "Error B", type: "warning", description: "Retry" });
98
+
99
+ const toasts = toastStore.toasts.value;
100
+ expect(toasts.length).toBe(1);
101
+ expect(toasts[0].type).toBe("warning");
102
+ expect(toasts[0].description).toBe("Retry");
103
+ });
104
+
105
+ it("dedup resets remainingTime to new duration", () => {
106
+ toastStore.add({ id: "timer-id", title: "First", duration: 2000 });
107
+ // Manually reduce remaining time to simulate partial expiry
108
+ toastStore.toasts.value[0].remainingTime = 500;
109
+
110
+ toastStore.add({ id: "timer-id", title: "Refreshed", duration: 4000 });
111
+ expect(toastStore.toasts.value[0].remainingTime).toBe(4000);
112
+ });
113
+
114
+ it("dedup skips a toast that is already leaving (creates new one)", () => {
115
+ toastStore.add({ id: "leaving-id", title: "Leaving" });
116
+ toastStore.toasts.value[0].isLeaving = true;
117
+
118
+ toastStore.add({ id: "leaving-id", title: "New one" });
119
+ // The original (leaving) toast stays + a new one is added
120
+ expect(toastStore.toasts.value.length).toBe(2);
121
+ });
122
+
123
+ it("returned id is the same when dedup update occurs", () => {
124
+ const id1 = toastStore.add({ id: "same-id", title: "A" });
125
+ const id2 = toastStore.add({ id: "same-id", title: "B" });
126
+ expect(id1).toBe("same-id");
127
+ expect(id2).toBe("same-id");
128
+ });
129
+ });