vue-toast-kit 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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1145 -0
  3. package/dist/composables/useToast.d.ts +28 -0
  4. package/dist/composables/useToastContext.d.ts +6 -0
  5. package/dist/composables/useToastState.d.ts +7 -0
  6. package/dist/core/GroupManager.d.ts +16 -0
  7. package/dist/core/ToastBuffer.d.ts +19 -0
  8. package/dist/core/ToastQueue.d.ts +55 -0
  9. package/dist/core/UndoTimer.d.ts +23 -0
  10. package/dist/core/types.d.ts +114 -0
  11. package/dist/index.d.ts +23 -0
  12. package/dist/module.d.ts +1 -0
  13. package/dist/nuxt/module.cjs +2 -0
  14. package/dist/nuxt/module.cjs.map +1 -0
  15. package/dist/nuxt/module.d.ts +1 -0
  16. package/dist/nuxt/module.js +34 -0
  17. package/dist/nuxt/module.js.map +1 -0
  18. package/dist/plugin.d.ts +6 -0
  19. package/dist/style.css +1 -0
  20. package/dist/testing.d.ts +14 -0
  21. package/dist/vue-toast-kit.cjs +2 -0
  22. package/dist/vue-toast-kit.cjs.map +1 -0
  23. package/dist/vue-toast-kit.d.ts +540 -0
  24. package/dist/vue-toast-kit.js +1000 -0
  25. package/dist/vue-toast-kit.js.map +1 -0
  26. package/package.json +89 -0
  27. package/src/components/Toast.vue +222 -0
  28. package/src/components/ToastActions.vue +34 -0
  29. package/src/components/ToastContainer.vue +257 -0
  30. package/src/components/ToastIcon.vue +53 -0
  31. package/src/components/ToastProgressBar.vue +18 -0
  32. package/src/composables/useToast.ts +152 -0
  33. package/src/composables/useToastContext.ts +63 -0
  34. package/src/composables/useToastState.ts +18 -0
  35. package/src/core/GroupManager.ts +105 -0
  36. package/src/core/ToastBuffer.ts +45 -0
  37. package/src/core/ToastQueue.ts +377 -0
  38. package/src/core/UndoTimer.ts +90 -0
  39. package/src/core/types.ts +142 -0
  40. package/src/env.d.ts +7 -0
  41. package/src/index.ts +51 -0
  42. package/src/nuxt/composables.ts +13 -0
  43. package/src/nuxt/module.ts +52 -0
  44. package/src/nuxt/plugin.ts +8 -0
  45. package/src/plugin.ts +18 -0
  46. package/src/styles/animations.css +106 -0
  47. package/src/styles/base.css +201 -0
  48. package/src/styles/themes/dark.css +30 -0
  49. package/src/styles/themes/light.css +30 -0
  50. package/src/styles/themes/system.css +32 -0
  51. package/src/styles/tokens.css +74 -0
  52. package/src/testing.ts +81 -0
@@ -0,0 +1,257 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import { Teleport, TransitionGroup } from 'vue'
4
+ import Toast from './Toast.vue'
5
+ import { useToastContext } from '../composables/useToastContext'
6
+ import { globalBuffer } from '../core/ToastBuffer'
7
+ import { PRIORITY_ORDER } from '../core/types'
8
+ import type { ToastContext, ToastPosition, ToastDesignTokens, ToastItem } from '../core/types'
9
+
10
+ const ALL_POSITIONS: ToastPosition[] = [
11
+ 'top-left', 'top-center', 'top-right',
12
+ 'bottom-left', 'bottom-center', 'bottom-right',
13
+ ]
14
+
15
+ const props = withDefaults(defineProps<{
16
+ position?: ToastPosition
17
+ maxVisible?: number
18
+ gap?: number
19
+ offsetX?: number
20
+ offsetY?: number
21
+ zIndex?: number
22
+ expand?: boolean
23
+ teleportTo?: string
24
+ context?: ToastContext
25
+ theme?: 'light' | 'dark' | 'system' | ToastDesignTokens
26
+ stackMode?: boolean
27
+ }>(), {
28
+ position: 'bottom-right',
29
+ maxVisible: 5,
30
+ gap: 8,
31
+ offsetX: 16,
32
+ offsetY: 16,
33
+ zIndex: 9999,
34
+ expand: false,
35
+ teleportTo: 'body',
36
+ stackMode: false,
37
+ })
38
+
39
+ // Resolve context once during setup — inject() must not be called inside computed()
40
+ const ctx = props.context ?? useToastContext()
41
+ const queue = ctx.queue
42
+
43
+ // Sync maxVisible with the queue
44
+ watch(() => props.maxVisible, (n) => queue.setMaxVisible(n), { immediate: true })
45
+
46
+ const isHovered = ref(false)
47
+
48
+ // Все видимые тосты
49
+ const allToasts = computed(() => queue.active.filter(t => !queue.isHidden(t.id)))
50
+
51
+ // Тосты по позиции: тост без position попадает в props.position; critical — первыми
52
+ function toastsForPosition(pos: ToastPosition): ToastItem[] {
53
+ return allToasts.value
54
+ .filter(t => (t.options.position ?? props.position) === pos)
55
+ .sort((a, b) => PRIORITY_ORDER[b.options.priority] - PRIORITY_ORDER[a.options.priority])
56
+ }
57
+
58
+ // Stack mode: depth = расстояние от "переднего" тоста
59
+ function stackDepth(index: number, total: number, pos: ToastPosition): number {
60
+ return pos.startsWith('bottom') ? (total - 1 - index) : index
61
+ }
62
+
63
+ // Inline-стиль обёртки тоста в stack mode
64
+ function stackWrapStyle(index: number, total: number, pos: ToastPosition): Record<string, string> {
65
+ if (!props.stackMode || isHovered.value) return {}
66
+ const depth = stackDepth(index, total, pos)
67
+ if (depth === 0) return { 'z-index': '10' }
68
+ if (depth > 2) return { display: 'none' }
69
+
70
+ const isBottom = pos.startsWith('bottom')
71
+ const scale = Math.max(0.88, 1 - depth * 0.06)
72
+ const offset = depth * 8
73
+ const opacity = Math.max(0.5, 1 - depth * 0.2)
74
+ const edgeProp = isBottom ? 'bottom' : 'top'
75
+
76
+ return {
77
+ position: 'absolute',
78
+ width: '100%',
79
+ [edgeProp]: '0',
80
+ transform: `translateY(${isBottom ? offset : -offset}px) scale(${scale})`,
81
+ opacity: String(opacity),
82
+ 'pointer-events': 'none',
83
+ 'z-index': String(10 - depth),
84
+ transition: 'transform 300ms ease, opacity 300ms ease',
85
+ }
86
+ }
87
+
88
+ // CSS-классы контейнера для конкретной позиции
89
+ function containerClass(pos: ToastPosition) {
90
+ const toasts = toastsForPosition(pos)
91
+ return [
92
+ 'vtk-container',
93
+ `vtk-container--${pos}`,
94
+ props.theme && typeof props.theme === 'string' ? `vtk-theme-${props.theme}` : '',
95
+ props.stackMode && toasts.length > 1 ? 'vtk-container--stack' : '',
96
+ props.stackMode && isHovered.value ? 'vtk-container--stack-expanded' : '',
97
+ ]
98
+ }
99
+
100
+ // Inline CSS-переменные из объекта дизайн-токенов (одинаковы для всех позиций)
101
+ const containerStyle = computed(() => {
102
+ const style: Record<string, string> = {
103
+ '--vtk-z-index': String(props.zIndex),
104
+ '--vtk-gap': `${props.gap}px`,
105
+ '--vtk-container-offset-x': `${props.offsetX}px`,
106
+ '--vtk-container-offset-y': `${props.offsetY}px`,
107
+ }
108
+
109
+ if (props.theme && typeof props.theme === 'object') {
110
+ const map: Record<keyof ToastDesignTokens, string> = {
111
+ colorBg: '--vtk-color-bg',
112
+ colorText: '--vtk-color-text',
113
+ colorBorder: '--vtk-color-border',
114
+ colorSuccess: '--vtk-color-success',
115
+ colorError: '--vtk-color-error',
116
+ colorWarning: '--vtk-color-warning',
117
+ colorInfo: '--vtk-color-info',
118
+ colorLoading: '--vtk-color-loading',
119
+ fontFamily: '--vtk-font-family',
120
+ fontSize: '--vtk-font-size',
121
+ fontWeight: '--vtk-font-weight',
122
+ lineHeight: '--vtk-line-height',
123
+ borderRadius: '--vtk-border-radius',
124
+ borderWidth: '--vtk-border-width',
125
+ shadow: '--vtk-shadow',
126
+ paddingX: '--vtk-padding-x',
127
+ paddingY: '--vtk-padding-y',
128
+ iconSize: '--vtk-icon-size',
129
+ progressHeight: '--vtk-progress-height',
130
+ maxWidth: '--vtk-max-width',
131
+ minWidth: '--vtk-min-width',
132
+ transitionDuration: '--vtk-transition-duration',
133
+ transitionEasing: '--vtk-transition-easing',
134
+ zIndex: '--vtk-z-index',
135
+ }
136
+ for (const [key, cssVar] of Object.entries(map)) {
137
+ const val = (props.theme as ToastDesignTokens)[key as keyof ToastDesignTokens]
138
+ if (val) style[cssVar] = val
139
+ }
140
+ }
141
+
142
+ return style
143
+ })
144
+
145
+ // Event handlers
146
+ function handleDismiss(id: string) {
147
+ ctx.dismiss(id)
148
+ }
149
+
150
+ function handleGroupToggle(groupKey: string) {
151
+ queue.toggleGroupExpand(groupKey)
152
+ }
153
+
154
+ function onContainerEnter() {
155
+ isHovered.value = true
156
+ queue.pauseAll()
157
+ }
158
+ function onContainerLeave() {
159
+ isHovered.value = false
160
+ queue.resumeAll()
161
+ }
162
+
163
+ // SSR buffer flush on mount
164
+ onMounted(() => {
165
+ setTimeout(() => {
166
+ globalBuffer.onFlush((items) => {
167
+ for (const item of items) {
168
+ ctx.addToast(item.message, item.options)
169
+ }
170
+ })
171
+ globalBuffer.flush()
172
+ }, 100)
173
+ })
174
+
175
+ defineSlots<{
176
+ toast(props: { toast: ToastItem; dismiss: (id: string) => void }): void
177
+ 'toast-icon'(props: { toast: ToastItem }): void
178
+ 'toast-content'(props: { toast: ToastItem }): void
179
+ 'toast-action'(props: { toast: ToastItem }): void
180
+ 'toast-close'(props: { toast: ToastItem; dismiss: (id: string) => void }): void
181
+ 'toast-undo'(props: { toast: ToastItem; remaining: number }): void
182
+ }>()
183
+ </script>
184
+
185
+ <template>
186
+ <Teleport :to="teleportTo">
187
+ <!--
188
+ Все 6 позиций рендерятся всегда — даже пустыми.
189
+ Это гарантирует, что TransitionGroup всегда в DOM,
190
+ и enter/leave-анимации корректно срабатывают даже для одного тоста.
191
+ -->
192
+ <div
193
+ v-for="pos in ALL_POSITIONS"
194
+ :key="pos"
195
+ :class="containerClass(pos)"
196
+ :style="containerStyle"
197
+ :role="toastsForPosition(pos).length ? 'region' : undefined"
198
+ :aria-label="toastsForPosition(pos).length ? 'Notifications' : undefined"
199
+ @mouseenter="onContainerEnter"
200
+ @mouseleave="onContainerLeave"
201
+ >
202
+ <TransitionGroup :name="`vtk-slide-${pos}`" tag="div" class="vtk-container__list">
203
+ <div
204
+ v-for="(t, i) in toastsForPosition(pos)"
205
+ :key="t.id"
206
+ class="vtk-toast-wrap"
207
+ :style="stackWrapStyle(i, toastsForPosition(pos).length, pos)"
208
+ >
209
+ <slot
210
+ v-if="$slots.toast"
211
+ name="toast"
212
+ :toast="t"
213
+ :dismiss="handleDismiss"
214
+ />
215
+ <Toast
216
+ v-else
217
+ :toast="t"
218
+ :on-dismiss="handleDismiss"
219
+ :on-group-toggle="handleGroupToggle"
220
+ >
221
+ <template v-if="$slots['toast-icon']" #toast-icon="slotProps">
222
+ <slot name="toast-icon" v-bind="slotProps" />
223
+ </template>
224
+ <template v-if="$slots['toast-content']" #toast-content="slotProps">
225
+ <slot name="toast-content" v-bind="slotProps" />
226
+ </template>
227
+ <template v-if="$slots['toast-action']" #toast-action="slotProps">
228
+ <slot name="toast-action" v-bind="slotProps" />
229
+ </template>
230
+ <template v-if="$slots['toast-close']" #toast-close="slotProps">
231
+ <slot name="toast-close" v-bind="slotProps" />
232
+ </template>
233
+ <template v-if="$slots['toast-undo']" #toast-undo="slotProps">
234
+ <slot name="toast-undo" v-bind="slotProps" />
235
+ </template>
236
+ </Toast>
237
+ </div>
238
+ </TransitionGroup>
239
+ </div>
240
+ </Teleport>
241
+ </template>
242
+
243
+ <style scoped>
244
+ .vtk-container__list {
245
+ display: contents;
246
+ }
247
+
248
+ /* In stack mode, list becomes a positioning context */
249
+ .vtk-container--stack .vtk-container__list {
250
+ display: block;
251
+ position: relative;
252
+ }
253
+
254
+ .vtk-container--stack .vtk-toast-wrap {
255
+ display: block;
256
+ }
257
+ </style>
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ import { computed, type Component } from 'vue'
3
+ import type { ToastType } from '../core/types'
4
+
5
+ const props = defineProps<{
6
+ type: ToastType
7
+ icon?: Component | string | false
8
+ }>()
9
+
10
+ const isComponent = computed(() =>
11
+ props.icon && typeof props.icon === 'object',
12
+ )
13
+
14
+ const isString = computed(() =>
15
+ props.icon && typeof props.icon === 'string',
16
+ )
17
+
18
+ const showDefault = computed(() => props.icon !== false && !props.icon)
19
+ </script>
20
+
21
+ <template>
22
+ <span :class="['vtk-icon', `vtk-icon--${type}`]" aria-hidden="true">
23
+ <!-- Custom component -->
24
+ <component :is="icon" v-if="isComponent" />
25
+
26
+ <!-- String (emoji or text) -->
27
+ <span v-else-if="isString">{{ icon }}</span>
28
+
29
+ <!-- Loading spinner -->
30
+ <span v-else-if="type === 'loading'" class="vtk-spinner" />
31
+
32
+ <!-- Default SVG icons -->
33
+ <template v-else-if="showDefault">
34
+ <!-- success -->
35
+ <svg v-if="type === 'success'" viewBox="0 0 20 20" fill="currentColor">
36
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
37
+ </svg>
38
+ <!-- error -->
39
+ <svg v-else-if="type === 'error'" viewBox="0 0 20 20" fill="currentColor">
40
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
41
+ </svg>
42
+ <!-- warning -->
43
+ <svg v-else-if="type === 'warning'" viewBox="0 0 20 20" fill="currentColor">
44
+ <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
45
+ </svg>
46
+ <!-- info -->
47
+ <svg v-else-if="type === 'info'" viewBox="0 0 20 20" fill="currentColor">
48
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
49
+ </svg>
50
+ <!-- custom — no default icon -->
51
+ </template>
52
+ </span>
53
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ remaining: number // 0–1
6
+ }>()
7
+
8
+ const scaleX = computed(() => props.remaining)
9
+ </script>
10
+
11
+ <template>
12
+ <div class="vtk-progress" aria-hidden="true">
13
+ <div
14
+ class="vtk-progress__bar"
15
+ :style="{ transform: `scaleX(${scaleX})` }"
16
+ />
17
+ </div>
18
+ </template>
@@ -0,0 +1,152 @@
1
+ import { type Component, type VNode } from 'vue'
2
+ import { useToastContext, getOrCreateGlobalContext } from './useToastContext'
3
+ import { UndoTimer } from '../core/UndoTimer'
4
+ import type { ToastContext, ToastOptions, PromiseToastMessages, ToastPosition } from '../core/types'
5
+ import type { ToastQueue } from '../core/ToastQueue'
6
+
7
+ function restartTimer(queue: ToastQueue, id: string, duration: number): void {
8
+ const q = queue as unknown as { timers: Map<string, UndoTimer> }
9
+ q.timers.get(id)?.destroy()
10
+ q.timers.delete(id)
11
+
12
+ const item = queue.active.find(t => t.id === id)
13
+ if (!item || !duration) return
14
+
15
+ const timer = new UndoTimer(
16
+ duration,
17
+ () => {
18
+ item.options.onAutoClose?.()
19
+ queue.remove(id)
20
+ },
21
+ (r: number) => { item.remaining.value = r },
22
+ )
23
+ q.timers.set(id, timer)
24
+ timer.start()
25
+ }
26
+
27
+ function buildToastApi(ctx: ToastContext) {
28
+ const q = ctx.queue
29
+
30
+ /** Show an info toast. Returns the toast id. */
31
+ function toast(message: string, options?: ToastOptions): string {
32
+ return ctx.addToast(message, { type: 'info', ...options })
33
+ }
34
+
35
+ /** Show a success toast. */
36
+ toast.success = (message: string, options?: ToastOptions): string =>
37
+ ctx.addToast(message, { type: 'success', ...options })
38
+
39
+ /** Show an error toast (default priority: high). */
40
+ toast.error = (message: string, options?: ToastOptions): string =>
41
+ ctx.addToast(message, { type: 'error', priority: 'high', ...options })
42
+
43
+ /** Show a warning toast. */
44
+ toast.warning = (message: string, options?: ToastOptions): string =>
45
+ ctx.addToast(message, { type: 'warning', ...options })
46
+
47
+ /** Show an info toast (alias for the default call). */
48
+ toast.info = (message: string, options?: ToastOptions): string =>
49
+ ctx.addToast(message, { type: 'info', ...options })
50
+
51
+ /** Show a sticky loading toast. Returns the id to update later. */
52
+ toast.loading = (message: string, options?: ToastOptions): string =>
53
+ ctx.addToast(message, { type: 'loading', duration: 0, closable: false, ...options })
54
+
55
+ /** Show a toast with a fully custom component. */
56
+ toast.custom = (component: Component, options?: ToastOptions): string =>
57
+ ctx.addToast('', { type: 'custom', component, duration: 0, ...options })
58
+
59
+ /** Dismiss one or all toasts. */
60
+ toast.dismiss = (id?: string): void => ctx.dismiss(id)
61
+
62
+ /** Update options (and optionally the message) of an existing toast. */
63
+ toast.update = (id: string, partial: Partial<ToastOptions> & { message?: string | VNode }): void => {
64
+ const item = q.active.find(t => t.id === id)
65
+ if (item && partial.message !== undefined) {
66
+ item.message = partial.message
67
+ }
68
+ ctx.update(id, partial)
69
+ }
70
+
71
+ /** Update only the message text without touching options. */
72
+ toast.updateMessage = (id: string, message: string | VNode): void => {
73
+ const item = q.active.find(t => t.id === id)
74
+ if (item) item.message = message
75
+ }
76
+
77
+ /** Returns true if the toast with the given id is currently active. */
78
+ toast.isActive = (id: string): boolean => ctx.isActive(id)
79
+
80
+ /**
81
+ * Show a loading toast, then update it to success or error when the promise settles.
82
+ * Returns the original promise so it can be awaited or chained.
83
+ */
84
+ toast.promise = async <T>(
85
+ promise: Promise<T>,
86
+ messages: PromiseToastMessages<T>,
87
+ options?: ToastOptions,
88
+ ): Promise<T> => {
89
+ const id = toast.loading(messages.loading, options)
90
+
91
+ try {
92
+ const data = await promise
93
+ const msg = typeof messages.success === 'function' ? messages.success(data) : messages.success
94
+ const item = q.active.find(t => t.id === id)
95
+ if (item) {
96
+ item.message = msg
97
+ item.options.type = 'success'
98
+ item.options.closable = true
99
+ item.options.duration = 3000
100
+ restartTimer(q, id, 3000)
101
+ }
102
+ return data
103
+ } catch (err: unknown) {
104
+ const msg = typeof messages.error === 'function' ? messages.error(err) : messages.error
105
+ const item = q.active.find(t => t.id === id)
106
+ if (item) {
107
+ item.message = msg
108
+ item.options.type = 'error'
109
+ item.options.closable = true
110
+ item.options.duration = 5000
111
+ restartTimer(q, id, 5000)
112
+ }
113
+ throw err
114
+ }
115
+ }
116
+
117
+ /** Show an undo toast with a countdown. Call `options.undo.onUndo` if the user taps Undo. */
118
+ toast.undo = (message: string, options: ToastOptions & { undo: NonNullable<ToastOptions['undo']> }): string => {
119
+ return ctx.addToast(message, {
120
+ type: 'info',
121
+ duration: options.undo.duration ?? 5000,
122
+ closable: false,
123
+ ...options,
124
+ })
125
+ }
126
+
127
+ /** Dismiss all toasts, optionally filtered by position. */
128
+ toast.dismissAll = (position?: ToastPosition): void => q.dismissAll(position)
129
+ /** Pause all active toast timers. */
130
+ toast.pauseAll = (): void => q.pauseAll()
131
+ /** Resume all active toast timers. */
132
+ toast.resumeAll = (): void => q.resumeAll()
133
+
134
+ return toast
135
+ }
136
+
137
+ export type ToastApi = ReturnType<typeof buildToastApi>
138
+
139
+ export function useToast(context?: ToastContext): ToastApi {
140
+ if (context) return buildToastApi(context)
141
+
142
+ // Inside component — try injected context
143
+ try {
144
+ const ctx = useToastContext()
145
+ return buildToastApi(ctx)
146
+ } catch {
147
+ return buildToastApi(getOrCreateGlobalContext())
148
+ }
149
+ }
150
+
151
+ // Global singleton for use outside components (Pinia stores, axios interceptors, etc.)
152
+ export const toast: ToastApi = buildToastApi(getOrCreateGlobalContext())
@@ -0,0 +1,63 @@
1
+ import { inject, type App } from 'vue'
2
+ import { ToastQueue } from '../core/ToastQueue'
3
+ import { isServer, globalBuffer } from '../core/ToastBuffer'
4
+ import { TOAST_CONTEXT_KEY, type ToastOptions, type ToastContext, type GlobalToastOptions } from '../core/types'
5
+ import type { VNode } from 'vue'
6
+
7
+ function buildContext(queue: ToastQueue): ToastContext {
8
+ return {
9
+ queue,
10
+ addToast(message: string | VNode, options: ToastOptions = {}): string {
11
+ if (isServer) {
12
+ const id = options.id ?? `vtk-ssr-${Date.now()}`
13
+ globalBuffer.push(message, { ...options, id })
14
+ return id
15
+ }
16
+ return queue.add(message, options)
17
+ },
18
+ dismiss(id?: string): void {
19
+ queue.dismiss(id)
20
+ },
21
+ update(id: string, options: Partial<ToastOptions>): void {
22
+ queue.update(id, options)
23
+ },
24
+ isActive(id: string): boolean {
25
+ return queue.isActive(id)
26
+ },
27
+ }
28
+ }
29
+
30
+ let globalContext: ToastContext | null = null
31
+
32
+ export function getOrCreateGlobalContext(opts?: GlobalToastOptions): ToastContext {
33
+ if (!globalContext) {
34
+ const queue = new ToastQueue(opts?.maxVisible ?? 5, {
35
+ rateLimit: opts?.rateLimit,
36
+ rateLimitWindowMs: opts?.rateLimitWindowMs,
37
+ persistStorage: opts?.persistStorage,
38
+ })
39
+ globalContext = buildContext(queue)
40
+ }
41
+ return globalContext
42
+ }
43
+
44
+ export function createToastContext(opts?: GlobalToastOptions): ToastContext {
45
+ const queue = new ToastQueue(opts?.maxVisible ?? 5, {
46
+ rateLimit: opts?.rateLimit,
47
+ rateLimitWindowMs: opts?.rateLimitWindowMs,
48
+ persistStorage: opts?.persistStorage,
49
+ })
50
+ return buildContext(queue)
51
+ }
52
+
53
+ export function useToastContext(): ToastContext {
54
+ const injected = inject<ToastContext>(TOAST_CONTEXT_KEY, null as unknown as ToastContext)
55
+ if (injected) return injected
56
+ return getOrCreateGlobalContext()
57
+ }
58
+
59
+ export function installContext(app: App, opts?: GlobalToastOptions): ToastContext {
60
+ const ctx = getOrCreateGlobalContext(opts)
61
+ app.provide(TOAST_CONTEXT_KEY, ctx)
62
+ return ctx
63
+ }
@@ -0,0 +1,18 @@
1
+ import { computed } from 'vue'
2
+ import { useToastContext } from './useToastContext'
3
+ import type { ToastContext, ToastItem } from '../core/types'
4
+
5
+ export function useToastState(context?: ToastContext) {
6
+ const ctx = context ?? useToastContext()
7
+ const queue = ctx.queue
8
+
9
+ const active = computed<ToastItem[]>(() => queue.active.filter(t => !queue.isHidden(t.id)))
10
+ const pending = computed<ToastItem[]>(() => [...queue.pending])
11
+ const count = computed(() => active.value.length)
12
+
13
+ function has(id: string): boolean {
14
+ return ctx.isActive(id)
15
+ }
16
+
17
+ return { active, pending, count, has }
18
+ }
@@ -0,0 +1,105 @@
1
+ import type { ToastItem } from './types'
2
+
3
+ export class GroupManager {
4
+ private groups = new Map<string, string[]>()
5
+ private expandedGroups = new Set<string>()
6
+
7
+ private getItems: (ids: string[]) => ToastItem[]
8
+ private hideItem: (id: string) => void
9
+ private showItem: (id: string) => void
10
+
11
+ constructor(
12
+ getItems: (ids: string[]) => ToastItem[],
13
+ hideItem: (id: string) => void,
14
+ showItem: (id: string) => void,
15
+ ) {
16
+ this.getItems = getItems
17
+ this.hideItem = hideItem
18
+ this.showItem = showItem
19
+ }
20
+
21
+ add(id: string, groupKey: string): void {
22
+ if (!this.groups.has(groupKey)) {
23
+ this.groups.set(groupKey, [id])
24
+ return
25
+ }
26
+
27
+ const ids = this.groups.get(groupKey)!
28
+ ids.push(id)
29
+
30
+ const [leaderId] = ids
31
+ const leader = this.getItems([leaderId])[0]
32
+ if (leader) {
33
+ leader.groupCount.value = ids.length
34
+ }
35
+
36
+ if (!this.expandedGroups.has(groupKey)) {
37
+ this.hideItem(id)
38
+ }
39
+ }
40
+
41
+ remove(id: string, groupKey: string): void {
42
+ const ids = this.groups.get(groupKey)
43
+ if (!ids) return
44
+
45
+ const idx = ids.indexOf(id)
46
+ if (idx === -1) return
47
+
48
+ ids.splice(idx, 1)
49
+
50
+ if (ids.length === 0) {
51
+ this.groups.delete(groupKey)
52
+ this.expandedGroups.delete(groupKey)
53
+ return
54
+ }
55
+
56
+ const [leaderId] = ids
57
+ const leader = this.getItems([leaderId])[0]
58
+ if (leader) {
59
+ leader.groupCount.value = ids.length
60
+ }
61
+
62
+ if (idx === 0 && ids.length > 0) {
63
+ this.showItem(ids[0])
64
+ if (!this.expandedGroups.has(groupKey)) {
65
+ for (let i = 1; i < ids.length; i++) {
66
+ this.hideItem(ids[i])
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ toggleExpand(groupKey: string): void {
73
+ const ids = this.groups.get(groupKey)
74
+ if (!ids) return
75
+
76
+ if (this.expandedGroups.has(groupKey)) {
77
+ this.expandedGroups.delete(groupKey)
78
+ for (let i = 1; i < ids.length; i++) {
79
+ this.hideItem(ids[i])
80
+ }
81
+ } else {
82
+ this.expandedGroups.add(groupKey)
83
+ for (const id of ids) {
84
+ this.showItem(id)
85
+ }
86
+ }
87
+ }
88
+
89
+ isExpanded(groupKey: string): boolean {
90
+ return this.expandedGroups.has(groupKey)
91
+ }
92
+
93
+ getGroupIds(groupKey: string): string[] {
94
+ return this.groups.get(groupKey) ?? []
95
+ }
96
+
97
+ hasGroup(groupKey: string): boolean {
98
+ return this.groups.has(groupKey)
99
+ }
100
+
101
+ clear(): void {
102
+ this.groups.clear()
103
+ this.expandedGroups.clear()
104
+ }
105
+ }