Update Toast component with swipe functionality for dismissing notifications

This commit is contained in:
Ivan
2026-05-06 00:53:30 -05:00
parent bf4da5d770
commit 1afb2d99d2
2 changed files with 87 additions and 2 deletions
+55 -2
View File
@@ -8,8 +8,14 @@
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto flex items-center p-4 w-full sm:min-w-[300px] sm:max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300"
:class="toastClass(toast.type)"
ref="toastRefs"
class="pointer-events-auto flex items-center p-4 w-full sm:min-w-[300px] sm:max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300 select-none touch-pan-y"
:class="[toastClass(toast.type), toast.swipeClass]"
:style="toastSwipeStyle(toast)"
@touchstart="onTouchStart($event, toast)"
@touchmove="onTouchMove($event, toast)"
@touchend="onTouchEnd(toast)"
@touchcancel="onTouchEnd(toast)"
>
<!-- icon -->
<div class="mr-3 shrink-0">
@@ -66,6 +72,7 @@ export default {
return {
toasts: [],
counter: 0,
swipeThreshold: 100,
};
},
mounted() {
@@ -125,6 +132,9 @@ export default {
type: toast.type || "info",
duration: toast.duration !== undefined ? toast.duration : 5000,
timer: null,
_swipeX: 0,
_swiping: false,
swipeClass: "",
};
if (newToast.duration > 0) {
@@ -157,6 +167,39 @@ export default {
return "bg-white/90 dark:bg-zinc-900/90 border-blue-500/30";
}
},
toastSwipeStyle(toast) {
const x = toast._swipeX || 0;
if (x === 0) return {};
return {
transform: `translateX(${x}px)`,
transition: toast._swiping ? "none" : "transform 0.3s ease",
opacity: `${1 - Math.min(Math.abs(x) / this.swipeThreshold, 0.6)}`,
};
},
onTouchStart(event, toast) {
if (event.touches.length !== 1) return;
toast._startX = event.touches[0].clientX;
toast._swipeX = 0;
toast._swiping = true;
toast.swipeClass = "";
},
onTouchMove(event, toast) {
if (!toast._swiping || event.touches.length !== 1) return;
const deltaX = event.touches[0].clientX - toast._startX;
toast._swipeX = deltaX;
},
onTouchEnd(toast) {
if (!toast._swiping) return;
toast._swiping = false;
if (Math.abs(toast._swipeX) >= this.swipeThreshold) {
toast.swipeClass = toast._swipeX > 0 ? "toast-swipe-out-right" : "toast-swipe-out-left";
this.$nextTick(() => {
setTimeout(() => this.remove(toast.id), 250);
});
} else {
toast._swipeX = 0;
}
},
},
};
</script>
@@ -182,4 +225,14 @@ export default {
transform: translateX(30px);
}
}
.toast-swipe-out-left {
transform: translateX(-120%) !important;
opacity: 0 !important;
transition: transform 0.25s ease, opacity 0.25s ease !important;
}
.toast-swipe-out-right {
transform: translateX(120%) !important;
opacity: 0 !important;
transition: transform 0.25s ease, opacity 0.25s ease !important;
}
</style>
+32
View File
@@ -107,4 +107,36 @@ describe("Toast.vue", () => {
expect(cls).toContain("max-sm:bottom-");
expect(cls).not.toContain("max-sm:bottom-[calc(5.75rem");
});
it("dismisses toast on horizontal swipe past threshold", async () => {
GlobalEmitter.emit("toast", { message: "Swipe me", duration: 0 });
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("Swipe me");
const toastVm = wrapper.vm.toasts[0];
// Simulate swipe right by 120px (past 100px threshold)
wrapper.vm.onTouchStart({ touches: [{ clientX: 100, clientY: 50 }] }, toastVm);
wrapper.vm.onTouchMove({ touches: [{ clientX: 220, clientY: 50 }] }, toastVm);
wrapper.vm.onTouchEnd(toastVm);
await vi.advanceTimersByTimeAsync(300);
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain("Swipe me");
});
it("snaps toast back when swipe is below threshold", async () => {
GlobalEmitter.emit("toast", { message: "Stay", duration: 0 });
await wrapper.vm.$nextTick();
const toastVm = wrapper.vm.toasts[0];
wrapper.vm.onTouchStart({ touches: [{ clientX: 100, clientY: 50 }] }, toastVm);
wrapper.vm.onTouchMove({ touches: [{ clientX: 140, clientY: 50 }] }, toastVm);
wrapper.vm.onTouchEnd(toastVm);
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("Stay");
expect(toastVm._swipeX).toBe(0);
});
});