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); + }); });