170 lines
5.6 KiB
JavaScript
170 lines
5.6 KiB
JavaScript
/* swipe-nav.js
|
||
Mobile/desktop swipe & keyboard navigation for entry view.
|
||
- One navigation per gesture
|
||
- Edge protection: horizontal intent + distance + max duration
|
||
- Cooldown prevents multi-advance on long swipes
|
||
- Keyboard: Left/Right arrows (when not typing)
|
||
*/
|
||
(function () {
|
||
// Bail if not on an entry page with forms
|
||
const prevForm = document.getElementById("navPrevForm");
|
||
const nextForm = document.getElementById("navNextForm");
|
||
if (!prevForm && !nextForm) return;
|
||
|
||
// Prefer a dedicated swipe zone if present; fall back to body
|
||
const zone = document.getElementById("swipeZone") || document.body;
|
||
|
||
// State
|
||
let tracking = false;
|
||
let startX = 0, startY = 0, startT = 0, pointerId = null;
|
||
let didNavigate = false;
|
||
let cooldown = false;
|
||
|
||
// Tunables
|
||
const MIN_X = 48; // px horizontal distance
|
||
const MAX_ANGLE = 30; // deg off pure horizontal
|
||
const MAX_MS = 650; // must complete within this time
|
||
const COOLDOWN_MS = 450; // after we navigate, ignore further moves briefly
|
||
|
||
// Helpers
|
||
function degrees(dx, dy) {
|
||
const a = Math.atan2(Math.abs(dy), Math.abs(dx)) * (180 / Math.PI);
|
||
return a; // 0 = pure horizontal
|
||
}
|
||
function now() { return performance.now ? performance.now() : Date.now(); }
|
||
|
||
function reset() {
|
||
tracking = false;
|
||
pointerId = null;
|
||
startX = startY = 0;
|
||
startT = 0;
|
||
didNavigate = false;
|
||
}
|
||
|
||
function submit(form) {
|
||
if (!form || cooldown) return;
|
||
cooldown = true;
|
||
form.submit();
|
||
// If navigation is prevented (e.g., disabled button), release cooldown after a tick
|
||
setTimeout(() => { cooldown = false; }, COOLDOWN_MS);
|
||
}
|
||
|
||
// Pointer events if supported (best across devices). Fallback to touch.
|
||
const usePointer = "PointerEvent" in window;
|
||
|
||
function onDown(e) {
|
||
if (cooldown) return;
|
||
// Ignore right click / secondary
|
||
if (usePointer && e.pointerType === "mouse" && e.button !== 0) return;
|
||
|
||
tracking = true;
|
||
didNavigate = false;
|
||
|
||
const pt = usePointer ? e : (e.touches && e.touches[0]);
|
||
if (!pt) return;
|
||
|
||
pointerId = usePointer ? e.pointerId : pt.identifier;
|
||
startX = pt.clientX;
|
||
startY = pt.clientY;
|
||
startT = now();
|
||
|
||
// Once we suspect horizontal motion, we'll call preventDefault in move
|
||
}
|
||
|
||
function onMove(e) {
|
||
if (!tracking || didNavigate || cooldown) return;
|
||
|
||
const pt = usePointer ? e : (e.touches && e.touches[0]);
|
||
if (!pt) return;
|
||
if (usePointer && e.pointerId !== pointerId) return;
|
||
|
||
const dx = pt.clientX - startX;
|
||
const dy = pt.clientY - startY;
|
||
|
||
// If user is panning mostly horizontally, stop the browser from doing a horizontal scroll/jiggle
|
||
if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) {
|
||
// In touch events you must preventDefault during move to suppress scroll
|
||
if (e.cancelable) e.preventDefault();
|
||
}
|
||
}
|
||
|
||
function onUp(e) {
|
||
if (!tracking || didNavigate || cooldown) { reset(); return; }
|
||
|
||
let pt;
|
||
if (usePointer) {
|
||
if (e.pointerId !== pointerId) return reset();
|
||
pt = e;
|
||
} else {
|
||
// On touchend, touches[] is empty; use changedTouches[]
|
||
pt = (e.changedTouches && e.changedTouches[0]) || null;
|
||
if (!pt) return reset();
|
||
}
|
||
|
||
const dx = pt.clientX - startX;
|
||
const dy = pt.clientY - startY;
|
||
const dt = now() - startT;
|
||
|
||
const ang = degrees(dx, dy);
|
||
const horizontalEnough = ang <= MAX_ANGLE;
|
||
|
||
// Must be a deliberate, reasonably quick horizontal swipe
|
||
if (Math.abs(dx) >= MIN_X && horizontalEnough && dt <= MAX_MS) {
|
||
didNavigate = true;
|
||
if (dx < 0) {
|
||
// left swipe -> NEXT
|
||
const btn = nextForm && nextForm.querySelector("button[disabled]");
|
||
if (!btn) submit(nextForm);
|
||
} else {
|
||
// right swipe -> PREV
|
||
const btn = prevForm && prevForm.querySelector("button[disabled]");
|
||
if (!btn) submit(prevForm);
|
||
}
|
||
if (e.cancelable) e.preventDefault();
|
||
}
|
||
|
||
reset();
|
||
}
|
||
|
||
function onCancel() { reset(); }
|
||
|
||
if (usePointer) {
|
||
zone.addEventListener("pointerdown", onDown, { passive: true });
|
||
zone.addEventListener("pointermove", onMove, { passive: false });
|
||
zone.addEventListener("pointerup", onUp, { passive: false });
|
||
zone.addEventListener("pointercancel", onCancel, { passive: true });
|
||
zone.addEventListener("pointerleave", onCancel, { passive: true });
|
||
} else {
|
||
zone.addEventListener("touchstart", onDown, { passive: true });
|
||
zone.addEventListener("touchmove", onMove, { passive: false });
|
||
zone.addEventListener("touchend", onUp, { passive: false });
|
||
zone.addEventListener("touchcancel", onCancel, { passive: true });
|
||
}
|
||
|
||
// Keyboard support (left/right arrows) – ignore if typing
|
||
document.addEventListener("keydown", (e) => {
|
||
if (cooldown) return;
|
||
const tag = (e.target && e.target.tagName) || "";
|
||
const typing = /INPUT|TEXTAREA|SELECT/.test(tag);
|
||
if (typing) return;
|
||
|
||
if (e.key === "ArrowRight") {
|
||
const btn = nextForm && nextForm.querySelector("button[disabled]");
|
||
if (!btn) { e.preventDefault(); submit(nextForm); }
|
||
} else if (e.key === "ArrowLeft") {
|
||
const btn = prevForm && prevForm.querySelector("button[disabled]");
|
||
if (!btn) { e.preventDefault(); submit(prevForm); }
|
||
}
|
||
});
|
||
|
||
// Defensive CSS: lock horizontal gestures to our handler only inside the zone
|
||
(function injectCSS() {
|
||
const css = `
|
||
#swipeZone{ touch-action: pan-y; overscroll-behavior-x: none; overflow-x: hidden; }
|
||
html, body{ overscroll-behavior-x: none; }
|
||
`;
|
||
const el = document.createElement("style");
|
||
el.textContent = css;
|
||
document.head.appendChild(el);
|
||
})();
|
||
})(); |