@soft-toast/vue 1.0.0 → 1.0.2

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.
@@ -26,6 +26,9 @@ const defaultOptions: Required<Pick<
26
26
 
27
27
  const toasts = ref<Toast[]>([])
28
28
 
29
+ // Non-reactive timer state — lives outside Vue reactivity to avoid per-frame re-renders
30
+ const timerMap = new Map<string, { remainingTime: number; isPaused: boolean }>()
31
+
29
32
  // ─── Computed ─────────────────────────────────────────────────────────────────
30
33
 
31
34
  const getToastsByPosition = (position: ToastPosition) =>
@@ -65,12 +68,13 @@ const add = (options: ToastOptions): string => {
65
68
  }
66
69
 
67
70
  // ── New toast ──
71
+ const duration = options.duration ?? defaultOptions.duration
68
72
  const toast: Toast = {
69
73
  ...defaultOptions,
70
74
  ...options,
71
75
  id,
72
76
  createdAt: Date.now(),
73
- remainingTime: options.duration ?? defaultOptions.duration,
77
+ remainingTime: duration,
74
78
  isPaused: false,
75
79
  isExpanded: true,
76
80
  isLeaving: false,
@@ -81,6 +85,9 @@ const add = (options: ToastOptions): string => {
81
85
  showProgress: options.showProgress ?? defaultOptions.showProgress,
82
86
  }
83
87
 
88
+ // Register in non-reactive timer map
89
+ timerMap.set(id, { remainingTime: duration, isPaused: false })
90
+
84
91
  toasts.value.unshift(toast)
85
92
  startTickLoop()
86
93
 
@@ -102,6 +109,7 @@ const update = (id: string, options: Partial<ToastOptions>) => {
102
109
  const dismiss = (id?: string | { type?: ToastType | ToastType[] }) => {
103
110
  if (!id) {
104
111
  toasts.value.forEach((t) => { t.isLeaving = true })
112
+ timerMap.clear()
105
113
  setTimeout(() => { toasts.value = [] }, 400)
106
114
  return
107
115
  }
@@ -111,6 +119,7 @@ const dismiss = (id?: string | { type?: ToastType | ToastType[] }) => {
111
119
  if (toast) {
112
120
  toast.isLeaving = true
113
121
  toast.onDismiss?.(id)
122
+ timerMap.delete(id)
114
123
  setTimeout(() => {
115
124
  toasts.value = toasts.value.filter((t) => t.id !== id)
116
125
  }, 400)
@@ -121,6 +130,7 @@ const dismiss = (id?: string | { type?: ToastType | ToastType[] }) => {
121
130
  if (types.includes(t.type)) {
122
131
  t.isLeaving = true
123
132
  t.onDismiss?.(t.id)
133
+ timerMap.delete(t.id)
124
134
  }
125
135
  })
126
136
  setTimeout(() => {
@@ -130,11 +140,15 @@ const dismiss = (id?: string | { type?: ToastType | ToastType[] }) => {
130
140
  }
131
141
 
132
142
  const pause = (id: string) => {
143
+ const timer = timerMap.get(id)
144
+ if (timer) timer.isPaused = true
133
145
  const toast = toasts.value.find((t) => t.id === id)
134
146
  if (toast) toast.isPaused = true
135
147
  }
136
148
 
137
149
  const resume = (id: string) => {
150
+ const timer = timerMap.get(id)
151
+ if (timer) timer.isPaused = false
138
152
  const toast = toasts.value.find((t) => t.id === id)
139
153
  if (toast) toast.isPaused = false
140
154
  }
@@ -163,19 +177,31 @@ const startTickLoop = () => {
163
177
  const delta = currentTime - lastTime
164
178
  lastTime = currentTime
165
179
 
166
- toasts.value.forEach((toast) => {
167
- if (!toast.isPaused && !toast.isLeaving && toast.remainingTime > 0 && toast.duration !== Infinity) {
168
- toast.remainingTime -= delta
169
- if (toast.remainingTime <= 0) {
170
- toast.isLeaving = true
171
- toast.onAutoClose?.(toast.id)
172
- setTimeout(() => {
173
- toasts.value = toasts.value.filter((t) => t.id !== toast.id)
174
- }, 400)
175
- }
176
- }
180
+ // Tick non-reactive timer map — zero Vue reactivity cost per frame
181
+ const expiredIds: string[] = []
182
+ timerMap.forEach((timer, id) => {
183
+ if (timer.isPaused) return
184
+ const toast = toasts.value.find((t) => t.id === id)
185
+ if (!toast || toast.isLeaving || toast.duration === Infinity) return
186
+ timer.remainingTime -= delta
187
+ // Sync remainingTime back to toast only for progress bar consumers
188
+ if (toast.showProgress) toast.remainingTime = timer.remainingTime
189
+ if (timer.remainingTime <= 0) expiredIds.push(id)
177
190
  })
178
191
 
192
+ // Mutate Vue state only when a toast actually expires
193
+ for (const id of expiredIds) {
194
+ timerMap.delete(id)
195
+ const toast = toasts.value.find((t) => t.id === id)
196
+ if (toast && !toast.isLeaving) {
197
+ toast.isLeaving = true
198
+ toast.onAutoClose?.(id)
199
+ setTimeout(() => {
200
+ toasts.value = toasts.value.filter((t) => t.id !== id)
201
+ }, 400)
202
+ }
203
+ }
204
+
179
205
  if (toasts.value.length > 0) {
180
206
  rafId = requestAnimationFrame(loop)
181
207
  } else {
@@ -237,9 +263,13 @@ const promise = async <T>(
237
263
  }
238
264
  }
239
265
 
240
- const clearAll = () => { toasts.value = [] }
266
+ const clearAll = () => {
267
+ timerMap.clear()
268
+ toasts.value = []
269
+ }
241
270
 
242
271
  const remove = (id: string) => {
272
+ timerMap.delete(id)
243
273
  toasts.value = toasts.value.filter((t) => t.id !== id)
244
274
  }
245
275
 
@@ -28,7 +28,6 @@
28
28
  .soft-toast-container[data-position="bottom"] { bottom: 32px; left: 50%; transform: translateX(-50%); }
29
29
  .soft-toast-container[data-position="left"] { top: 50%; left: 32px; transform: translateY(-50%); }
30
30
  .soft-toast-container[data-position="right"] { top: 50%; right: 32px; transform: translateY(-50%); }
31
- .soft-toast-container[data-position="center"] { top: 50%; left: 50%; transform: translate(-50%, -50%); }
32
31
 
33
32
  /* ---- Toast Item ---- */
34
33
  .soft-toast-item {
@@ -45,8 +44,7 @@
45
44
  line-height: 1.45;
46
45
  cursor: default;
47
46
  user-select: none;
48
- will-change: transform, opacity;
49
- touch-action: pan-y; /* allow vertical scroll, intercept horizontal for swipe */
47
+ touch-action: pan-y;
50
48
 
51
49
  /* Clean modern white card */
52
50
  background: rgba(255, 255, 255, 0.98);
@@ -56,6 +54,11 @@
56
54
  inset 0 1px 0 rgba(255, 255, 255, 1);
57
55
  }
58
56
 
57
+ .soft-toast-item[data-interactive="false"] {
58
+ pointer-events: none;
59
+ touch-action: none;
60
+ }
61
+
59
62
  /* Show grab cursor on swipeable toasts to hint at the gesture */
60
63
  .soft-toast-item--swipeable {
61
64
  cursor: grab;
@@ -101,6 +104,8 @@
101
104
 
102
105
  /* ---- Content layout ---- */
103
106
  .soft-toast-content {
107
+ position: relative;
108
+ z-index: 1;
104
109
  display: flex;
105
110
  align-items: flex-start;
106
111
  gap: 11px;
@@ -111,7 +116,7 @@
111
116
  flex-shrink: 0;
112
117
  width: 24px;
113
118
  height: 24px;
114
- margin-top: 1px;
119
+ margin-top: 0;
115
120
  display: flex;
116
121
  align-items: center;
117
122
  justify-content: center;
@@ -155,6 +160,7 @@
155
160
  display: flex;
156
161
  align-items: center;
157
162
  gap: 12px;
163
+ min-height: 24px;
158
164
  }
159
165
 
160
166
  .soft-toast-title {
@@ -162,8 +168,8 @@
162
168
  font-size: 14px;
163
169
  color: #111827;
164
170
  margin: 0;
165
- line-height: 1.4;
166
- letter-spacing: -0.01em;
171
+ line-height: 1.3;
172
+ letter-spacing: 0;
167
173
  /* Slight right padding by default so text doesn't bump against close button on hover */
168
174
  padding-right: 4px;
169
175
  }
@@ -217,6 +223,7 @@
217
223
  width: 100%;
218
224
  padding: 8px 16px;
219
225
  border-radius: 12px;
226
+ background: rgba(100, 116, 139, 0.12); /* fallback for Safari < 16.2 */
220
227
  background: color-mix(in srgb, var(--st-accent) 15%, transparent);
221
228
  color: var(--st-icon-color);
222
229
  border: none;
@@ -229,6 +236,7 @@
229
236
  text-align: center;
230
237
  }
231
238
  .soft-toast-action-button:hover {
239
+ background: rgba(100, 116, 139, 0.22); /* fallback for Safari < 16.2 */
232
240
  background: color-mix(in srgb, var(--st-accent) 25%, transparent);
233
241
  transform: scale(1.02);
234
242
  }
@@ -247,6 +255,7 @@
247
255
  /* Close Button */
248
256
  .soft-toast-close {
249
257
  position: absolute;
258
+ z-index: 3;
250
259
  top: 10px;
251
260
  right: 10px;
252
261
  width: 22px;
@@ -269,6 +278,10 @@
269
278
  transform: scale(1.1);
270
279
  }
271
280
  .soft-toast-item:hover .soft-toast-close { opacity: 1; }
281
+ /* iOS/touch: no hover state — always show close button */
282
+ @media (hover: none) {
283
+ .soft-toast-close { opacity: 0.7; }
284
+ }
272
285
  .soft-toast-close[data-position="top-left"] { right: auto; left: 10px; }
273
286
 
274
287
  /* Morph X to Minus on Hover */
@@ -344,6 +357,23 @@
344
357
  .soft-toast-close { opacity: 1; }
345
358
  }
346
359
 
360
+ /* ---- Collapsed stack: only front toast is touchable on mobile ---- */
361
+ /* Prevents hidden stacked toasts from intercepting touch/scroll on the page */
362
+ @media (hover: none) {
363
+ .soft-toast-container:not([data-expanded="true"]) .soft-toast-item[data-interactive="false"] {
364
+ pointer-events: none;
365
+ touch-action: none;
366
+ }
367
+ /* Front toast in collapsed stack: no swipe gesture — tap to expand only */
368
+ .soft-toast-container:not([data-expanded="true"]) .soft-toast-item[data-st-index="0"] {
369
+ touch-action: manipulation;
370
+ }
371
+ /* Expanded: all toasts can swipe */
372
+ .soft-toast-container[data-expanded="true"] .soft-toast-item[data-interactive="true"] {
373
+ touch-action: pan-y;
374
+ }
375
+ }
376
+
347
377
  /* ---- Reduced Motion ---- */
348
378
  @media (prefers-reduced-motion: reduce) {
349
379
  .soft-toast-item,
@@ -1,7 +1,7 @@
1
1
  import type { VNode, Component } from 'vue'
2
2
 
3
3
  export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' | 'promise'
4
- export type ToastPosition = 'top' | 'bottom' | 'left' | 'right' | 'center' | 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
4
+ export type ToastPosition = 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
5
5
  export type AnimationPreset = 'smooth' | 'bouncy' | 'subtle' | 'snappy'
6
6
  export type QueueOverflow = 'drop-oldest' | 'drop-newest'
7
7
 
@@ -108,8 +108,10 @@ export interface ToastContainerProps {
108
108
  queueOverflow?: QueueOverflow
109
109
  dir?: 'ltr' | 'rtl'
110
110
  swipeToDismiss?: boolean
111
+ slotFilter?: (toast: Toast) => boolean
111
112
  }
112
113
 
113
114
  export interface ToastPluginOptions extends ToastContainerProps {
114
115
  teleportTarget?: string
116
+ autoMount?: boolean
115
117
  }