@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.
- package/README.md +41 -6
- package/dist/animations/gsapConfig.d.ts.map +1 -1
- package/dist/index.js +1130 -877
- package/dist/plugin.d.ts.map +1 -1
- package/dist/stores/toastStore.d.ts.map +1 -1
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +3 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/animations/gsapConfig.ts +11 -8
- package/src/components/ToastContainer.vue +2 -1
- package/src/components/ToastItem.vue +258 -63
- package/src/components/ToastRegion.vue +360 -87
- package/src/plugin.ts +8 -6
- package/src/stores/toastStore.ts +43 -13
- package/src/styles/toast.css +36 -6
- package/src/types/index.ts +3 -1
|
@@ -29,6 +29,17 @@ const props = withDefaults(defineProps<ToastContainerProps>(), {
|
|
|
29
29
|
const containerRef = ref<HTMLElement | null>(null);
|
|
30
30
|
const stackRef = ref<HTMLElement | null>(null);
|
|
31
31
|
const listRef = ref<HTMLElement | null>(null);
|
|
32
|
+
const toastItemRefs = ref<Map<string, InstanceType<typeof ToastItem>>>(
|
|
33
|
+
new Map(),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const setToastItemRef = (
|
|
37
|
+
id: string,
|
|
38
|
+
el: InstanceType<typeof ToastItem> | null,
|
|
39
|
+
) => {
|
|
40
|
+
if (el) toastItemRefs.value.set(id, el);
|
|
41
|
+
else toastItemRefs.value.delete(id);
|
|
42
|
+
};
|
|
32
43
|
|
|
33
44
|
// Get toasts for this container's position
|
|
34
45
|
const positionToasts = computed(() => {
|
|
@@ -62,11 +73,21 @@ onMounted(() => {
|
|
|
62
73
|
|
|
63
74
|
// Pause all timers when user switches tab / window loses focus
|
|
64
75
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
76
|
+
// iOS: collapse expanded stack when tapping outside
|
|
77
|
+
document.addEventListener("touchstart", handleOutsideTap, { passive: true });
|
|
65
78
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
// Must be non-passive to call preventDefault and block page scroll when expanded
|
|
80
|
+
stackRef.value?.addEventListener("touchmove", handleStackTouchMove, {
|
|
81
|
+
passive: false,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
85
|
+
listResizeObserver = new ResizeObserver(() => measureOffsets());
|
|
86
|
+
itemResizeObserver = new ResizeObserver(() => measureOffsets());
|
|
87
|
+
if (listRef.value) listResizeObserver.observe(listRef.value);
|
|
69
88
|
}
|
|
89
|
+
// Set initial height
|
|
90
|
+
nextTick(() => measureOffsets());
|
|
70
91
|
});
|
|
71
92
|
|
|
72
93
|
onUnmounted(() => {
|
|
@@ -74,7 +95,12 @@ onUnmounted(() => {
|
|
|
74
95
|
document.removeEventListener("keydown", handleKeydown);
|
|
75
96
|
}
|
|
76
97
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
77
|
-
|
|
98
|
+
document.removeEventListener("touchstart", handleOutsideTap);
|
|
99
|
+
stackRef.value?.removeEventListener("touchmove", handleStackTouchMove);
|
|
100
|
+
if (listResizeObserver) listResizeObserver.disconnect();
|
|
101
|
+
if (itemResizeObserver) itemResizeObserver.disconnect();
|
|
102
|
+
clearCollapseTimer();
|
|
103
|
+
observedItems = new WeakSet<HTMLElement>();
|
|
78
104
|
});
|
|
79
105
|
|
|
80
106
|
// Re-measure when toast list changes or when a toast starts leaving
|
|
@@ -109,6 +135,7 @@ watch(
|
|
|
109
135
|
|
|
110
136
|
// Position classes
|
|
111
137
|
const positionClass = computed(() => `soft-toast-container--${props.position}`);
|
|
138
|
+
const isCenterAligned = computed(() => props.position.endsWith("-center"));
|
|
112
139
|
|
|
113
140
|
// Visual Indexing (ignore leaving toasts so the stack closes gaps instantly)
|
|
114
141
|
const activeToasts = computed(() =>
|
|
@@ -121,31 +148,77 @@ const getVisualIndex = (toast: any, realIdx: number) => {
|
|
|
121
148
|
|
|
122
149
|
// Stack expansion (hover or focus reveals the stack)
|
|
123
150
|
const isExpanded = ref(false);
|
|
151
|
+
let collapseTimerId: number | null = null;
|
|
152
|
+
|
|
153
|
+
const clearCollapseTimer = () => {
|
|
154
|
+
if (collapseTimerId === null) return;
|
|
155
|
+
window.clearTimeout(collapseTimerId);
|
|
156
|
+
collapseTimerId = null;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// DOM cap: keep a bounded, stable window mounted so expanding does not
|
|
160
|
+
// introduce new nodes mid-animation. Collapsed positioning still hides items
|
|
161
|
+
// after the front 3 via opacity/pointer-events.
|
|
162
|
+
const EXPANDED_DOM_CAP = 15;
|
|
163
|
+
const renderedToasts = computed(() => {
|
|
164
|
+
const all = positionToasts.value;
|
|
165
|
+
const windowed = all.slice(0, EXPANDED_DOM_CAP);
|
|
166
|
+
// Keep leaving toasts so their exit animation plays even if they fall
|
|
167
|
+
// outside the normal render window.
|
|
168
|
+
const leaving = all.filter((t) => t.isLeaving);
|
|
169
|
+
const seen = new Set(windowed.map((t) => t.id));
|
|
170
|
+
return [...windowed, ...leaving.filter((t) => !seen.has(t.id))];
|
|
171
|
+
});
|
|
124
172
|
|
|
125
173
|
// Measured cumulative offsets for each item when stack is expanded.
|
|
126
|
-
|
|
174
|
+
// Keyed by toast id so order of renderedToasts doesn't matter.
|
|
175
|
+
const expandedOffsets = ref<Record<string, number>>({});
|
|
127
176
|
const frontHeight = ref(0);
|
|
128
177
|
const totalHeight = ref(0);
|
|
129
178
|
|
|
179
|
+
let measureRafId: number | null = null;
|
|
130
180
|
const measureOffsets = () => {
|
|
181
|
+
if (measureRafId !== null) return; // already scheduled
|
|
182
|
+
measureRafId = requestAnimationFrame(() => {
|
|
183
|
+
measureRafId = null;
|
|
184
|
+
_doMeasureOffsets(false);
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const hasOffsetChanges = (
|
|
189
|
+
nextOffsets: Record<string, number>,
|
|
190
|
+
nextFrontHeight: number,
|
|
191
|
+
nextTotalHeight: number,
|
|
192
|
+
) => {
|
|
193
|
+
if (frontHeight.value !== nextFrontHeight) return true;
|
|
194
|
+
if (totalHeight.value !== nextTotalHeight) return true;
|
|
195
|
+
const current = expandedOffsets.value;
|
|
196
|
+
const currentKeys = Object.keys(current);
|
|
197
|
+
const nextKeys = Object.keys(nextOffsets);
|
|
198
|
+
if (currentKeys.length !== nextKeys.length) return true;
|
|
199
|
+
return nextKeys.some((key) => current[key] !== nextOffsets[key]);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const _doMeasureOffsets = (forceSync = false) => {
|
|
131
203
|
if (!listRef.value) return;
|
|
132
204
|
const items = Array.from(
|
|
133
205
|
listRef.value.querySelectorAll<HTMLElement>(".soft-toast-item"),
|
|
134
206
|
);
|
|
207
|
+
observeToastItems(items);
|
|
135
208
|
const gapPx = props.gap ?? 10;
|
|
136
209
|
const offsets: number[] = [];
|
|
137
210
|
let cumulative = 0;
|
|
138
211
|
let firstActiveHeight = 0;
|
|
139
|
-
|
|
212
|
+
|
|
140
213
|
for (let i = 0; i < items.length; i++) {
|
|
141
214
|
offsets.push(cumulative);
|
|
142
|
-
|
|
215
|
+
|
|
143
216
|
// Only accumulate height if the toast is not leaving
|
|
144
217
|
if (items[i].getAttribute("data-leaving") !== "true") {
|
|
145
218
|
cumulative += items[i].offsetHeight + gapPx;
|
|
146
219
|
// The front toast is the one that has visual index 0
|
|
147
220
|
const toastId = items[i].dataset.toastId;
|
|
148
|
-
const t = positionToasts.value.find(x => x.id === toastId);
|
|
221
|
+
const t = positionToasts.value.find((x) => x.id === toastId);
|
|
149
222
|
if (t) {
|
|
150
223
|
const vIdx = getVisualIndex(t, positionToasts.value.indexOf(t));
|
|
151
224
|
if (vIdx === 0) {
|
|
@@ -154,70 +227,113 @@ const measureOffsets = () => {
|
|
|
154
227
|
}
|
|
155
228
|
}
|
|
156
229
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
230
|
+
|
|
231
|
+
// Build id → offset map instead of positional array
|
|
232
|
+
const offsetMap: Record<string, number> = {};
|
|
233
|
+
for (let i = 0; i < items.length; i++) {
|
|
234
|
+
const toastId = items[i].dataset.toastId;
|
|
235
|
+
if (toastId) offsetMap[toastId] = offsets[i];
|
|
236
|
+
}
|
|
237
|
+
const nextTotalHeight = Math.max(0, cumulative - gapPx);
|
|
238
|
+
const changed = hasOffsetChanges(offsetMap, firstActiveHeight, nextTotalHeight);
|
|
239
|
+
if (changed) {
|
|
240
|
+
expandedOffsets.value = offsetMap;
|
|
241
|
+
frontHeight.value = firstActiveHeight;
|
|
242
|
+
totalHeight.value = nextTotalHeight;
|
|
243
|
+
}
|
|
244
|
+
|
|
162
245
|
// Re-clamp scroll if height changes while expanded
|
|
163
|
-
if (isExpanded.value && listRef.value) {
|
|
246
|
+
if (isExpanded.value && listRef.value && changed) {
|
|
164
247
|
clampAndApplyScroll(0); // Applies bounds check without adding new delta
|
|
165
248
|
}
|
|
249
|
+
|
|
250
|
+
// Sync list height via GSAP — instant set (no tween) when called from measure
|
|
251
|
+
if (listRef.value && changed) {
|
|
252
|
+
const target = isExpanded.value ? totalHeight.value : frontHeight.value;
|
|
253
|
+
gsap.set(listRef.value, { height: target });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (isExpanded.value && (changed || forceSync)) {
|
|
257
|
+
syncExpandedPositions();
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const syncExpandedPositions = () => {
|
|
262
|
+
renderedToasts.value.forEach((toast) => {
|
|
263
|
+
const item = toastItemRefs.value.get(toast.id);
|
|
264
|
+
if (!item) return;
|
|
265
|
+
item.applyStackPosition(false, expandedOffsets.value[toast.id] ?? 0);
|
|
266
|
+
});
|
|
166
267
|
};
|
|
167
268
|
|
|
168
269
|
// Custom GSAP Scrolling
|
|
169
270
|
const currentScrollY = ref(0);
|
|
170
271
|
|
|
171
|
-
const
|
|
272
|
+
const getScrollBounds = () => {
|
|
273
|
+
const buffer = 120;
|
|
274
|
+
const maxScrollNeeded = Math.max(
|
|
275
|
+
0,
|
|
276
|
+
totalHeight.value - window.innerHeight + buffer,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
return stackDirection.value === "up"
|
|
280
|
+
? { min: 0, max: maxScrollNeeded }
|
|
281
|
+
: { min: -maxScrollNeeded, max: 0 };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const applyStackScrollY = (
|
|
285
|
+
nextY: number,
|
|
286
|
+
animate = true,
|
|
287
|
+
duration = 0.18,
|
|
288
|
+
ease = "power2.out",
|
|
289
|
+
) => {
|
|
172
290
|
if (!listRef.value) return;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// To see higher items, we scroll UP (deltaY < 0), so we translate DOWN (y goes positive).
|
|
184
|
-
currentScrollY.value -= deltaY;
|
|
185
|
-
currentScrollY.value = Math.max(0, Math.min(currentScrollY.value, maxScrollNeeded));
|
|
186
|
-
} else {
|
|
187
|
-
// Stack builds downwards (y goes positive).
|
|
188
|
-
// To see lower items, we scroll DOWN (deltaY > 0), so we translate UP (y goes negative).
|
|
189
|
-
currentScrollY.value -= deltaY;
|
|
190
|
-
currentScrollY.value = Math.max(-maxScrollNeeded, Math.min(currentScrollY.value, 0));
|
|
291
|
+
const { min, max } = getScrollBounds();
|
|
292
|
+
currentScrollY.value = Math.max(min, Math.min(nextY, max));
|
|
293
|
+
|
|
294
|
+
if (!animate) {
|
|
295
|
+
gsap.set(listRef.value, {
|
|
296
|
+
y: currentScrollY.value,
|
|
297
|
+
force3D: true,
|
|
298
|
+
overwrite: true,
|
|
299
|
+
});
|
|
300
|
+
return;
|
|
191
301
|
}
|
|
192
|
-
|
|
302
|
+
|
|
193
303
|
gsap.to(listRef.value, {
|
|
194
304
|
y: currentScrollY.value,
|
|
195
|
-
duration
|
|
196
|
-
ease
|
|
197
|
-
overwrite: "auto"
|
|
305
|
+
duration,
|
|
306
|
+
ease,
|
|
307
|
+
overwrite: "auto",
|
|
198
308
|
});
|
|
199
309
|
};
|
|
200
310
|
|
|
311
|
+
const clampAndApplyScroll = (deltaY: number, animate = true) => {
|
|
312
|
+
applyStackScrollY(currentScrollY.value - deltaY, animate);
|
|
313
|
+
};
|
|
314
|
+
|
|
201
315
|
const handleWheel = (e: WheelEvent) => {
|
|
202
316
|
if (!isExpanded.value) return;
|
|
203
|
-
|
|
317
|
+
|
|
204
318
|
// Check if the scroll originated from inside a scrollable element (like a long description)
|
|
205
319
|
let target = e.target as HTMLElement | null;
|
|
206
320
|
let isInternalScroll = false;
|
|
207
|
-
|
|
321
|
+
|
|
208
322
|
while (target && target !== stackRef.value) {
|
|
209
323
|
const style = window.getComputedStyle(target);
|
|
210
324
|
const overflowY = style.overflowY;
|
|
211
|
-
|
|
325
|
+
|
|
212
326
|
// Check if the element is capable of vertical scrolling
|
|
213
327
|
if (overflowY === "auto" || overflowY === "scroll") {
|
|
214
328
|
// It is a scrollable container. Check if it actually has overflow
|
|
215
329
|
if (target.scrollHeight > target.clientHeight) {
|
|
216
330
|
// Check if we can scroll in the direction of the wheel
|
|
217
331
|
// (deltaY > 0 is scrolling down, deltaY < 0 is scrolling up)
|
|
218
|
-
const canScrollDown =
|
|
332
|
+
const canScrollDown =
|
|
333
|
+
Math.ceil(target.scrollTop + target.clientHeight) <
|
|
334
|
+
target.scrollHeight;
|
|
219
335
|
const canScrollUp = target.scrollTop > 0;
|
|
220
|
-
|
|
336
|
+
|
|
221
337
|
if ((e.deltaY > 0 && canScrollDown) || (e.deltaY < 0 && canScrollUp)) {
|
|
222
338
|
isInternalScroll = true;
|
|
223
339
|
break; // It's a valid internal scroll, stop checking parents
|
|
@@ -226,44 +342,169 @@ const handleWheel = (e: WheelEvent) => {
|
|
|
226
342
|
}
|
|
227
343
|
target = target.parentElement;
|
|
228
344
|
}
|
|
229
|
-
|
|
345
|
+
|
|
230
346
|
if (isInternalScroll) {
|
|
231
347
|
// Don't intercept the wheel event; let the internal scrollbar handle it
|
|
232
348
|
return;
|
|
233
349
|
}
|
|
234
350
|
|
|
235
351
|
// If we don't need to scroll the main stack, don't prevent default so page can scroll normally
|
|
236
|
-
const maxScrollNeeded = Math.max(
|
|
352
|
+
const maxScrollNeeded = Math.max(
|
|
353
|
+
0,
|
|
354
|
+
totalHeight.value - window.innerHeight + 120,
|
|
355
|
+
);
|
|
237
356
|
if (maxScrollNeeded <= 0) return;
|
|
238
|
-
|
|
357
|
+
|
|
239
358
|
e.preventDefault();
|
|
240
359
|
clampAndApplyScroll(e.deltaY);
|
|
241
360
|
};
|
|
242
361
|
|
|
243
|
-
//
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
362
|
+
// Apply list height via GSAP (off main thread, no Vue reactive layout thrashing)
|
|
363
|
+
const applyListHeight = (expanded: boolean) => {
|
|
364
|
+
if (!listRef.value) return;
|
|
365
|
+
const target = expanded ? totalHeight.value : frontHeight.value;
|
|
366
|
+
gsap.to(listRef.value, {
|
|
367
|
+
height: target,
|
|
368
|
+
duration: expanded ? 0.14 : 0.16,
|
|
369
|
+
ease: expanded ? "power3.out" : "power2.out",
|
|
370
|
+
overwrite: "auto",
|
|
371
|
+
});
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
let listResizeObserver: ResizeObserver | null = null;
|
|
375
|
+
let itemResizeObserver: ResizeObserver | null = null;
|
|
376
|
+
let observedItems = new WeakSet<HTMLElement>();
|
|
377
|
+
|
|
378
|
+
const observeToastItems = (items: HTMLElement[]) => {
|
|
379
|
+
if (!itemResizeObserver) return;
|
|
380
|
+
for (const item of items) {
|
|
381
|
+
if (observedItems.has(item)) continue;
|
|
382
|
+
observedItems.add(item);
|
|
383
|
+
itemResizeObserver.observe(item);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
248
386
|
|
|
249
|
-
let resizeObserver: ResizeObserver | null = null;
|
|
250
387
|
const handleStackEnter = () => {
|
|
251
|
-
|
|
388
|
+
clearCollapseTimer();
|
|
389
|
+
if (isExpanded.value) return;
|
|
252
390
|
isExpanded.value = true;
|
|
391
|
+
nextTick(() => {
|
|
392
|
+
_doMeasureOffsets(true);
|
|
393
|
+
applyListHeight(true);
|
|
394
|
+
});
|
|
253
395
|
};
|
|
254
396
|
const handleStackLeave = () => {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
397
|
+
clearCollapseTimer();
|
|
398
|
+
collapseTimerId = window.setTimeout(() => {
|
|
399
|
+
collapseTimerId = null;
|
|
400
|
+
isExpanded.value = false;
|
|
401
|
+
applyListHeight(false);
|
|
402
|
+
// Reset GSAP scroll when mouse leaves
|
|
403
|
+
currentScrollY.value = 0;
|
|
404
|
+
if (listRef.value) {
|
|
405
|
+
gsap.to(listRef.value, {
|
|
406
|
+
y: 0,
|
|
407
|
+
duration: 0.2,
|
|
408
|
+
ease: "power2.out",
|
|
409
|
+
overwrite: "auto",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}, 80);
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// iOS/touch: distinguish tap vs scroll before acting
|
|
416
|
+
let tapStartX = 0;
|
|
417
|
+
let tapStartY = 0;
|
|
418
|
+
let touchScrollLastY = 0;
|
|
419
|
+
let touchScrollLastTime = 0;
|
|
420
|
+
let touchScrollVelocity = 0;
|
|
421
|
+
let touchDidScroll = false;
|
|
422
|
+
|
|
423
|
+
const handleStackTouchStart = (e: TouchEvent) => {
|
|
424
|
+
tapStartX = e.touches[0].clientX;
|
|
425
|
+
tapStartY = e.touches[0].clientY;
|
|
426
|
+
touchScrollLastY = e.touches[0].clientY;
|
|
427
|
+
touchScrollLastTime = performance.now();
|
|
428
|
+
touchScrollVelocity = 0;
|
|
429
|
+
touchDidScroll = false;
|
|
430
|
+
if (listRef.value) gsap.killTweensOf(listRef.value);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const handleStackTouchEnd = (e: TouchEvent) => {
|
|
434
|
+
if (positionToasts.value.length <= 1) return;
|
|
435
|
+
const dx = Math.abs((e.changedTouches[0]?.clientX ?? tapStartX) - tapStartX);
|
|
436
|
+
const dy = Math.abs((e.changedTouches[0]?.clientY ?? tapStartY) - tapStartY);
|
|
437
|
+
// If finger moved more than 8px it was a scroll/swipe, not a tap
|
|
438
|
+
if (dx > 8 || dy > 8) {
|
|
439
|
+
if (isExpanded.value && touchDidScroll && Math.abs(touchScrollVelocity) > 0.08) {
|
|
440
|
+
const momentumDistance = Math.max(
|
|
441
|
+
-460,
|
|
442
|
+
Math.min(touchScrollVelocity * 560, 460),
|
|
443
|
+
);
|
|
444
|
+
const duration = Math.max(
|
|
445
|
+
0.32,
|
|
446
|
+
Math.min(Math.abs(momentumDistance) / 780, 0.62),
|
|
447
|
+
);
|
|
448
|
+
applyStackScrollY(
|
|
449
|
+
currentScrollY.value + momentumDistance,
|
|
450
|
+
true,
|
|
451
|
+
duration,
|
|
452
|
+
"power3.out",
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (isExpanded.value) {
|
|
458
|
+
handleStackLeave();
|
|
459
|
+
} else {
|
|
460
|
+
e.preventDefault();
|
|
461
|
+
handleStackEnter();
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const handleOutsideTap = (e: TouchEvent) => {
|
|
466
|
+
if (!isExpanded.value) return;
|
|
467
|
+
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
|
468
|
+
handleStackLeave();
|
|
260
469
|
}
|
|
261
470
|
};
|
|
262
471
|
|
|
472
|
+
// Prevent page scroll bleeding through AND scroll the list when stack is expanded
|
|
473
|
+
const handleStackTouchMove = (e: TouchEvent) => {
|
|
474
|
+
if (!isExpanded.value) return;
|
|
475
|
+
e.preventDefault();
|
|
476
|
+
|
|
477
|
+
const currentY = e.touches[0].clientY;
|
|
478
|
+
const deltaY = touchScrollLastY - currentY; // positive = scrolling down
|
|
479
|
+
const now = performance.now();
|
|
480
|
+
const dt = Math.max(1, now - touchScrollLastTime);
|
|
481
|
+
touchScrollLastY = currentY;
|
|
482
|
+
touchScrollLastTime = now;
|
|
483
|
+
|
|
484
|
+
const maxScrollNeeded = Math.max(
|
|
485
|
+
0,
|
|
486
|
+
totalHeight.value - window.innerHeight + 120,
|
|
487
|
+
);
|
|
488
|
+
if (maxScrollNeeded <= 0) return;
|
|
489
|
+
|
|
490
|
+
const previousScrollY = currentScrollY.value;
|
|
491
|
+
clampAndApplyScroll(deltaY, false);
|
|
492
|
+
const instantVelocity = (currentScrollY.value - previousScrollY) / dt;
|
|
493
|
+
touchScrollVelocity = touchScrollVelocity * 0.35 + instantVelocity * 0.65;
|
|
494
|
+
touchDidScroll = true;
|
|
495
|
+
};
|
|
496
|
+
|
|
263
497
|
// Direction the stack peeks toward (bottom positions peek up, top positions peek down)
|
|
264
498
|
const stackDirection = computed<"up" | "down">(() =>
|
|
265
499
|
props.position.includes("bottom") ? "up" : "down",
|
|
266
500
|
);
|
|
501
|
+
|
|
502
|
+
const listStyle = computed(() => ({
|
|
503
|
+
width: isCenterAligned.value ? "100%" : undefined,
|
|
504
|
+
}));
|
|
505
|
+
|
|
506
|
+
const shouldUseSlots = (toast: any) => props.slotFilter?.(toast) ?? true;
|
|
507
|
+
const isSwipeToDismissEnabled = computed(() => props.swipeToDismiss !== false);
|
|
267
508
|
</script>
|
|
268
509
|
|
|
269
510
|
<template>
|
|
@@ -284,30 +525,55 @@ const stackDirection = computed<"up" | "down">(() =>
|
|
|
284
525
|
:data-direction="stackDirection"
|
|
285
526
|
@mouseenter="handleStackEnter"
|
|
286
527
|
@mouseleave="handleStackLeave"
|
|
528
|
+
@touchstart.stop="handleStackTouchStart"
|
|
529
|
+
@touchend.stop="handleStackTouchEnd"
|
|
287
530
|
@wheel="handleWheel"
|
|
288
531
|
data-lenis-prevent="true"
|
|
289
532
|
>
|
|
290
|
-
<div
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
533
|
+
<div ref="listRef" class="soft-toast-list" :style="listStyle">
|
|
534
|
+
<template v-for="(toast, idx) in renderedToasts" :key="toast.id">
|
|
535
|
+
<ToastItem
|
|
536
|
+
v-if="shouldUseSlots(toast)"
|
|
537
|
+
:ref="(el) => setToastItemRef(toast.id, el as any)"
|
|
538
|
+
:toast="toast"
|
|
539
|
+
:index="getVisualIndex(toast, idx)"
|
|
540
|
+
:total="activeToasts.length"
|
|
541
|
+
:expanded="isExpanded"
|
|
542
|
+
:expanded-offset="expandedOffsets[toast.id] ?? 0"
|
|
543
|
+
:stack-direction="stackDirection"
|
|
544
|
+
:interactive="isExpanded || getVisualIndex(toast, idx) === 0"
|
|
545
|
+
:close-button="toast.closeButton ?? closeButton"
|
|
546
|
+
:swipe-to-dismiss="isSwipeToDismissEnabled"
|
|
547
|
+
:style="
|
|
548
|
+
isCenterAligned
|
|
549
|
+
? { left: '50%', marginLeft: '-50%' }
|
|
550
|
+
: undefined
|
|
551
|
+
"
|
|
552
|
+
>
|
|
553
|
+
<template v-for="(_, name) in $slots" #[name]="slotProps">
|
|
554
|
+
<slot :name="name" v-bind="slotProps || {}" />
|
|
555
|
+
</template>
|
|
556
|
+
</ToastItem>
|
|
557
|
+
|
|
558
|
+
<ToastItem
|
|
559
|
+
v-else
|
|
560
|
+
:ref="(el) => setToastItemRef(toast.id, el as any)"
|
|
561
|
+
:toast="toast"
|
|
562
|
+
:index="getVisualIndex(toast, idx)"
|
|
563
|
+
:total="activeToasts.length"
|
|
564
|
+
:expanded="isExpanded"
|
|
565
|
+
:expanded-offset="expandedOffsets[toast.id] ?? 0"
|
|
566
|
+
:stack-direction="stackDirection"
|
|
567
|
+
:interactive="isExpanded || getVisualIndex(toast, idx) === 0"
|
|
568
|
+
:close-button="toast.closeButton ?? closeButton"
|
|
569
|
+
:swipe-to-dismiss="isSwipeToDismissEnabled"
|
|
570
|
+
:style="
|
|
571
|
+
isCenterAligned
|
|
572
|
+
? { left: '50%', marginLeft: '-50%' }
|
|
573
|
+
: undefined
|
|
574
|
+
"
|
|
575
|
+
/>
|
|
576
|
+
</template>
|
|
311
577
|
</div>
|
|
312
578
|
</div>
|
|
313
579
|
</div>
|
|
@@ -327,10 +593,16 @@ const stackDirection = computed<"up" | "down">(() =>
|
|
|
327
593
|
max-width: 100%;
|
|
328
594
|
display: flex;
|
|
329
595
|
flex-direction: column;
|
|
596
|
+
/* No 300ms tap delay on iOS */
|
|
597
|
+
touch-action: manipulation;
|
|
330
598
|
/* NO overflow constraints here to prevent shadow clipping.
|
|
331
599
|
GSAP translation handles scrolling visually without clipping! */
|
|
332
600
|
}
|
|
333
601
|
|
|
602
|
+
.soft-toast-container[data-expanded="true"] .soft-toast-stack {
|
|
603
|
+
touch-action: none;
|
|
604
|
+
}
|
|
605
|
+
|
|
334
606
|
.soft-toast-list {
|
|
335
607
|
position: relative;
|
|
336
608
|
width: 100%;
|
|
@@ -355,26 +627,27 @@ const stackDirection = computed<"up" | "down">(() =>
|
|
|
355
627
|
}
|
|
356
628
|
|
|
357
629
|
/* Horizontal alignment based on container position */
|
|
358
|
-
.soft-toast-container[data-position$="left"]
|
|
630
|
+
.soft-toast-container[data-position$="left"]
|
|
631
|
+
.soft-toast-list
|
|
632
|
+
> :deep(.soft-toast-item) {
|
|
359
633
|
left: 0;
|
|
360
634
|
right: auto;
|
|
361
635
|
}
|
|
362
|
-
.soft-toast-container[data-position$="right"]
|
|
636
|
+
.soft-toast-container[data-position$="right"]
|
|
637
|
+
.soft-toast-list
|
|
638
|
+
> :deep(.soft-toast-item) {
|
|
363
639
|
right: 0;
|
|
364
640
|
left: auto;
|
|
365
641
|
}
|
|
366
|
-
.soft-toast-container[data-position$="center"] .soft-toast-list > :deep(.soft-toast-item) {
|
|
367
|
-
left: 50%;
|
|
368
|
-
transform: translateX(-50%); /* Base horizontal centering before GSAP adds translateY */
|
|
369
|
-
}
|
|
370
|
-
|
|
371
642
|
/* Bottom-anchored stacks (top positions peek down): items anchored to top edge */
|
|
372
|
-
.soft-toast-stack[data-direction="down"]
|
|
643
|
+
.soft-toast-stack[data-direction="down"]
|
|
644
|
+
:deep(.soft-toast-list > .soft-toast-item) {
|
|
373
645
|
top: 0;
|
|
374
646
|
bottom: auto;
|
|
375
647
|
}
|
|
376
648
|
/* Top-anchored stacks (bottom positions peek up): items anchored to bottom edge */
|
|
377
|
-
.soft-toast-stack[data-direction="up"]
|
|
649
|
+
.soft-toast-stack[data-direction="up"]
|
|
650
|
+
:deep(.soft-toast-list > .soft-toast-item) {
|
|
378
651
|
top: auto;
|
|
379
652
|
bottom: 0;
|
|
380
653
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -19,7 +19,8 @@ let pluginOptions: ToastPluginOptions = {
|
|
|
19
19
|
queueOverflow: 'drop-oldest',
|
|
20
20
|
dir: 'ltr',
|
|
21
21
|
swipeToDismiss: true,
|
|
22
|
-
teleportTarget: 'body'
|
|
22
|
+
teleportTarget: 'body',
|
|
23
|
+
autoMount: true
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
// Flag to check if container is mounted
|
|
@@ -30,8 +31,10 @@ export const SoftToastPlugin = {
|
|
|
30
31
|
// Merge options
|
|
31
32
|
pluginOptions = { ...pluginOptions, ...options }
|
|
32
33
|
|
|
34
|
+
app.component('SoftToastContainer', ToastContainer)
|
|
35
|
+
|
|
33
36
|
// Only mount container once
|
|
34
|
-
if (!isContainerMounted && typeof window !== 'undefined') {
|
|
37
|
+
if (pluginOptions.autoMount !== false && !isContainerMounted && typeof window !== 'undefined') {
|
|
35
38
|
// Create a container div
|
|
36
39
|
const containerId = 'soft-toast-global-container'
|
|
37
40
|
let container = document.getElementById(containerId)
|
|
@@ -42,9 +45,6 @@ export const SoftToastPlugin = {
|
|
|
42
45
|
document.body.appendChild(container)
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
// Mount ToastContainer
|
|
46
|
-
app.component('SoftToastContainer', ToastContainer)
|
|
47
|
-
|
|
48
48
|
// Create a mini-app instance just for the toast container
|
|
49
49
|
const toastContainerApp = createApp({
|
|
50
50
|
render: () => h(ToastContainer, {
|
|
@@ -62,7 +62,9 @@ export const SoftToastPlugin = {
|
|
|
62
62
|
maxQueue: pluginOptions.maxQueue,
|
|
63
63
|
queueOverflow: pluginOptions.queueOverflow,
|
|
64
64
|
dir: pluginOptions.dir,
|
|
65
|
-
swipeToDismiss: pluginOptions.swipeToDismiss
|
|
65
|
+
swipeToDismiss: pluginOptions.swipeToDismiss,
|
|
66
|
+
showTimestamp: pluginOptions.showTimestamp,
|
|
67
|
+
slotFilter: pluginOptions.slotFilter
|
|
66
68
|
})
|
|
67
69
|
})
|
|
68
70
|
|