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.
- package/LICENSE +21 -0
- package/README.md +1145 -0
- package/dist/composables/useToast.d.ts +28 -0
- package/dist/composables/useToastContext.d.ts +6 -0
- package/dist/composables/useToastState.d.ts +7 -0
- package/dist/core/GroupManager.d.ts +16 -0
- package/dist/core/ToastBuffer.d.ts +19 -0
- package/dist/core/ToastQueue.d.ts +55 -0
- package/dist/core/UndoTimer.d.ts +23 -0
- package/dist/core/types.d.ts +114 -0
- package/dist/index.d.ts +23 -0
- package/dist/module.d.ts +1 -0
- package/dist/nuxt/module.cjs +2 -0
- package/dist/nuxt/module.cjs.map +1 -0
- package/dist/nuxt/module.d.ts +1 -0
- package/dist/nuxt/module.js +34 -0
- package/dist/nuxt/module.js.map +1 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/style.css +1 -0
- package/dist/testing.d.ts +14 -0
- package/dist/vue-toast-kit.cjs +2 -0
- package/dist/vue-toast-kit.cjs.map +1 -0
- package/dist/vue-toast-kit.d.ts +540 -0
- package/dist/vue-toast-kit.js +1000 -0
- package/dist/vue-toast-kit.js.map +1 -0
- package/package.json +89 -0
- package/src/components/Toast.vue +222 -0
- package/src/components/ToastActions.vue +34 -0
- package/src/components/ToastContainer.vue +257 -0
- package/src/components/ToastIcon.vue +53 -0
- package/src/components/ToastProgressBar.vue +18 -0
- package/src/composables/useToast.ts +152 -0
- package/src/composables/useToastContext.ts +63 -0
- package/src/composables/useToastState.ts +18 -0
- package/src/core/GroupManager.ts +105 -0
- package/src/core/ToastBuffer.ts +45 -0
- package/src/core/ToastQueue.ts +377 -0
- package/src/core/UndoTimer.ts +90 -0
- package/src/core/types.ts +142 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +51 -0
- package/src/nuxt/composables.ts +13 -0
- package/src/nuxt/module.ts +52 -0
- package/src/nuxt/plugin.ts +8 -0
- package/src/plugin.ts +18 -0
- package/src/styles/animations.css +106 -0
- package/src/styles/base.css +201 -0
- package/src/styles/themes/dark.css +30 -0
- package/src/styles/themes/light.css +30 -0
- package/src/styles/themes/system.css +32 -0
- package/src/styles/tokens.css +74 -0
- 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
|
+
}
|