diff --git a/meshchatx/src/frontend/components/Toast.vue b/meshchatx/src/frontend/components/Toast.vue
index dd99162..221fe5f 100644
--- a/meshchatx/src/frontend/components/Toast.vue
+++ b/meshchatx/src/frontend/components/Toast.vue
@@ -8,8 +8,14 @@
@@ -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;
+ }
+ },
},
};
@@ -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;
+}
diff --git a/tests/frontend/Toast.test.js b/tests/frontend/Toast.test.js
index ddc3a4d..8d3f7b4 100644
--- a/tests/frontend/Toast.test.js
+++ b/tests/frontend/Toast.test.js
@@ -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);
+ });
});