Make the calendar's gestures work on a touchscreen

The drag gestures did nothing on a phone. touch-action was set to none only
once the pointer had already travelled far enough to count as a drag, and by
then it is too late: a browser decides at touch-START whether a gesture scrolls
the page, so the page scrolled, the pointer stream was cancelled, and the block
never moved. The rule that works for a mouse cannot work for a finger.

Touch now arms by HOLDING. A press that stays put for a moment takes the
gesture over — at which point scrolling is suppressed and the block dims — while
a press that moves first is left alone as the scroll it plainly is. Everything
that is not a drag still scrolls exactly as a phone user expects. A mouse or pen
is unchanged and arms as soon as it has travelled.

Tapping empty space now creates a default one-hour slot at that time. On a
phone that is the only practical way to create, since drawing a range with a
finger is awkward, and on a desktop it is a shortcut worth having anyway.

The arming rule is a function rather than a pointerType check at each site, so
the touch and mouse paths cannot drift apart, and it is tested — including that
the hold is long enough to mean intent without feeling stuck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-07-28 17:56:48 -05:00
parent d2d7911efb
commit ce7d8642fa
3 changed files with 83 additions and 2 deletions

View file

@ -82,6 +82,22 @@ export function formatRange(startMin, endMin) {
return `${fmt(startMin)} ${fmt(endMin)}`;
}
// How long a touch must be held before it becomes a drag. A touchscreen cannot use the mouse
// rule: the browser decides at touch-START whether the gesture is a page scroll, so a drag that
// only declares itself after the finger moves has already lost — the page scrolls and the pointer
// stream is cancelled. Holding still first is the signal that this is a drag and not a scroll.
export const LONG_PRESS_MS = 350;
// Touch arms by holding; a mouse or pen arms as soon as it has travelled. Returning the mode
// rather than branching on pointerType at each site keeps the two paths from drifting apart.
export function dragArmMode(pointerType) {
return pointerType === 'touch' ? 'longpress' : 'immediate';
}
// A default slot for "I tapped a time" rather than dragging one out — the whole gesture on a
// phone, where dragging out a range is awkward.
export const DEFAULT_NEW_MIN = 60;
// Has the pointer moved far enough to mean "drag" rather than "click"?
export function isDrag(dx, dy, threshold = DRAG_THRESHOLD_PX) {
return Math.hypot(dx, dy) >= threshold;

View file

@ -4,6 +4,7 @@ import { t } from '../i18n.js';
import {
HOUR_PX, pxToMinutes, minutesToPx, rangeFromDrag, moveRange, resizeRange,
toLocalStamp, formatRange, canMoveAcrossDays, editsWholeSeries, isDrag,
dragArmMode, LONG_PRESS_MS, DEFAULT_NEW_MIN,
} from '../lib/schedule-grid.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
@ -370,6 +371,24 @@ export async function render(container) {
} else {
dragState = { kind: 'create', anchorMin: startMin, refCell: cell, moved: false, origin, dayIdx: dayColumnOf(cell) };
}
// A touchscreen cannot arm the way a mouse does. The browser decides at touch-START
// whether this gesture scrolls the page; by the time a finger has moved far enough to look
// like a drag, scrolling has already begun and the pointer stream is cancelled. So on touch
// we wait for a HOLD, and only then take the gesture over. Everything else still scrolls
// normally, which is what a phone user expects a calendar to do.
dragState.armMode = dragArmMode(e.pointerType);
if (dragState.armMode === 'longpress') {
dragState.armed = false;
dragState.longPressTimer = setTimeout(() => {
if (!dragState) return;
dragState.armed = true;
cal.style.touchAction = 'none'; // taken over — now the page must NOT scroll
if (dragState.block) dragState.block.style.opacity = '0.35';
if (navigator.vibrate) { try { navigator.vibrate(10); } catch (_) { /* optional */ } }
}, LONG_PRESS_MS);
} else {
dragState.armed = true;
}
cal.setPointerCapture?.(e.pointerId);
});
@ -377,8 +396,15 @@ export async function render(container) {
if (!dragState) return;
// Ignore the jitter of an ordinary click. Until the pointer has actually travelled, this
// is still a click and must stay one, or click-to-edit never fires.
const travelled = isDrag(e.clientX - dragState.origin.x, e.clientY - dragState.origin.y);
// Touch that moves BEFORE the hold completes is the user scrolling. Let go of it entirely
// rather than fighting the browser for the gesture.
if (dragState.armMode === 'longpress' && !dragState.armed) {
if (travelled) { clearTimeout(dragState.longPressTimer); dragState = null; clearGhost(); }
return;
}
if (!dragState.moved) {
if (!isDrag(e.clientX - dragState.origin.x, e.clientY - dragState.origin.y)) return;
if (!travelled) return;
dragState.moved = true;
cal.style.touchAction = 'none'; // stop a touch drag scrolling the page
cal.style.cursor = dragState.kind === 'resize' ? 'ns-resize' : 'grabbing';
@ -412,9 +438,20 @@ export async function render(container) {
const st = dragState;
dragState = null;
clearGhost();
if (st) clearTimeout(st.longPressTimer);
resetDragChrome(st);
try { cal.releasePointerCapture?.(e.pointerId); } catch (_) { /* already released */ }
if (!st || !st.pending || !st.moved) { setTimeout(() => { if (!dragState) { /* let click through */ } }, 0); return; }
if (!st) return;
// A tap or click on empty space with no drag still means "put something here" — and on a
// phone it is the ONLY create gesture, since dragging out a range with a finger is awkward.
if (!st.moved && st.kind === 'create' && st.anchorMin != null) {
const d0 = new Date(currentWeekStart);
d0.setDate(d0.getDate() + (st.dayIdx || 0));
const start = Math.floor(st.anchorMin / 15) * 15;
openCreateAt(d0, start, Math.min(start + DEFAULT_NEW_MIN, 24 * 60));
return;
}
if (!st.pending || !st.moved) return; // a tap on a block: let the click handler edit it
const { range, day } = st.pending;
const dayDate = new Date(currentWeekStart);
dayDate.setDate(dayDate.getDate() + day);

View file

@ -127,6 +127,34 @@ test('a whole day still fits a laptop screen', async () => {
assert.ok(24 * G.HOUR_PX <= 1100, `full day is ${24 * G.HOUR_PX}px`);
});
test('MOBILE: touch arms by HOLDING, a mouse arms by moving', async () => {
// The two cannot share a rule. A browser decides at touch-start whether a gesture scrolls the
// page, so a touch drag that only declares itself after the finger moves has already lost — the
// page scrolls and the pointer stream is cancelled. That is exactly why the first version did
// nothing on a phone: it set touch-action only AFTER the move threshold.
assert.equal(G.dragArmMode('touch'), 'longpress');
assert.equal(G.dragArmMode('mouse'), 'immediate');
assert.equal(G.dragArmMode('pen'), 'immediate');
assert.equal(G.dragArmMode(undefined), 'immediate', 'unknown input behaves like a mouse');
});
test('the hold is long enough to mean intent, short enough not to feel stuck', async () => {
assert.ok(G.LONG_PRESS_MS >= 250 && G.LONG_PRESS_MS <= 600, `${G.LONG_PRESS_MS}ms`);
});
test('a tap with no drag still yields a sensible slot', async () => {
// On a phone this is the only create gesture — dragging a range with a finger is awkward.
assert.equal(G.DEFAULT_NEW_MIN, 60);
const r = G.clampRange(9 * 60, 9 * 60 + G.DEFAULT_NEW_MIN);
assert.equal(r.endMin - r.startMin, 60);
});
test('a tap late in the day does not produce an invalid slot', async () => {
const start = 23 * 60 + 30;
const r = G.clampRange(start, Math.min(start + G.DEFAULT_NEW_MIN, G.DAY_MIN));
assert.ok(r.endMin <= G.DAY_MIN && r.endMin > r.startMin);
});
test('the drag readout is human, not 24h minutes', async () => {
assert.equal(G.formatRange(540, 630), '9:00 AM 10:30 AM');
assert.equal(G.formatRange(0, 45), '12:00 AM 12:45 AM');