diff --git a/static/app.js b/static/app.js index 7fc8688..74f826b 100644 --- a/static/app.js +++ b/static/app.js @@ -667,6 +667,76 @@ document.addEventListener("submit", async (e) => { document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); }); +// --- swipe the menu open and shut (phones) -------------------------------- +// A mostly-sideways swipe drags the drawer with the finger; letting go snaps +// it open or shut by distance or speed. Vertical scrolling is left alone. +// Move and end listeners go on the touched element itself: touch events keep +// going to it even if a re-render detaches it mid-swipe, and then they no +// longer bubble up to the document. + +(function swipeMenu() { + const phone = matchMedia("(max-width: 900px)"); + let drag = null; + + const reset = () => { + $("#side").style.transform = $("#side").style.transition = ""; + $("#scrim").style.opacity = ""; + $("#app").classList.remove("dragging"); + }; + + document.addEventListener("touchstart", (e) => { + if (drag) release(); + if (!phone.matches || e.touches.length !== 1 || !$("#modal").hidden || e.target.closest("input, textarea, select")) return; + const t = e.touches[0]; + drag = { x: t.clientX, y: t.clientY, at: e.timeStamp, open: $("#app").classList.contains("open"), axis: null, dx: 0, target: e.target }; + drag.target.addEventListener("touchmove", move, { passive: false }); + drag.target.addEventListener("touchend", finish); + drag.target.addEventListener("touchcancel", finish); + }, { passive: true }); + + function release() { + drag.target.removeEventListener("touchmove", move); + drag.target.removeEventListener("touchend", finish); + drag.target.removeEventListener("touchcancel", finish); + if (drag.axis === "x") reset(); + drag = null; + } + + function move(e) { + if (!drag) return; + const t = e.touches[0]; + const dx = t.clientX - drag.x, dy = t.clientY - drag.y; + if (!drag.axis) { + if (Math.hypot(dx, dy) < 10) return; + const sideways = Math.abs(dx) > Math.abs(dy) * 1.3 && (drag.open ? dx < 0 : dx > 0); + drag.axis = sideways ? "x" : "y"; + if (!sideways) return; + drag.width = $("#side").offsetWidth; + $("#side").style.transition = "none"; + $("#app").classList.add("dragging"); + } + if (drag.axis !== "x") return; + e.preventDefault(); + drag.dx = dx; + // 0 is fully open, -width fully shut. + const offset = Math.max(-drag.width, Math.min(0, drag.open ? dx : dx - drag.width)); + $("#side").style.transform = `translateX(${offset}px)`; + $("#scrim").style.opacity = String(1 + offset / drag.width); + } + + function finish(e) { + if (!drag) return; + const swiped = drag.axis === "x"; + const speed = drag.dx / Math.max(1, e.timeStamp - drag.at); // px per ms + const open = drag.open + ? !(drag.dx < -drag.width * 0.35 || speed < -0.4) + : drag.dx > drag.width * 0.35 || speed > 0.4; + // Hand back to the stylesheet; its transition carries on from here. + release(); + if (swiped) setMenu(open); + } +})(); + (async function boot() { try { C = await api("/api/content"); diff --git a/static/styles.css b/static/styles.css index 48b6ba6..37468a8 100644 --- a/static/styles.css +++ b/static/styles.css @@ -17,7 +17,7 @@ } * { box-sizing: border-box; } -html, body { margin: 0; height: 100%; } +html, body { margin: 0; height: 100%; overscroll-behavior-x: none; } body { background: var(--bg); color: var(--text); @@ -305,7 +305,7 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num padding-bottom: calc(24px + env(safe-area-inset-bottom)); } #app.open #side { transform: none; box-shadow: 0 0 40px rgba(0, 0, 0, .5); } - #app.open #scrim { display: block; position: fixed; inset: 0; z-index: 15; background: rgba(0, 0, 0, .5); } + #app.open #scrim, #app.dragging #scrim { display: block; position: fixed; inset: 0; z-index: 15; background: rgba(0, 0, 0, .5); } html.menu-open, html.menu-open body { overflow: hidden; } .menu-btn { display: block; } #top { padding: 8px max(16px, env(safe-area-inset-right)) 8px max(16px, env(safe-area-inset-left)); flex-wrap: wrap; gap: 8px; }