ihasmail's mail list was built for a mouse. A row is clicked, right-clicked and dragged into a folder, and on a touchscreen two of those three do not exist -- so the phone layout had the shape of a mail app and none of the handling, and the things people reach for first simply did nothing. Four gestures, all touch-only, so a mouse keeps drag-to-folder unchanged: - Swipe a row sideways to act on it. Each direction is a setting -- right archives and left deletes by default, matching the app the phone came with -- and the strip revealed behind the row names what will happen in the folder it is happening in: "Delete forever" out of Deleted Items, "Not spam" inside Junk Mail, and nothing at all where the action is a no-op, in which case the row will not move that way. - Hold a row to select it. Selection was reachable already, by aiming at a checkbox beside an avatar, which is not how anyone selects mail on a phone. The selection toolbar gained an overflow menu at the same time: report spam, mark unread and label were hidden on narrow screens and had nowhere else to be, so touch selection could not reach them at all. - Hold a folder for the menu its ⋮ button opens. - Pull the list down to refresh, and drag in from the left edge of a conversation to go back. The toolbar's button and arrow both stay: a gesture with no visible control is one only the people who already know about it can use. The arithmetic behind them is in lib/touch.ts, away from the components and under test, because the numbers are the whole thing: an axis lock biased towards the vertical, so a diagonal flick stays a scroll rather than deleting whatever it passes over. Two layout bugs turned up while checking this on a 390px screen, both older than the gestures. The app shell is a grid with only its rows named, so it took an implicit auto column sized to the top bar's min-content -- about 470px -- and every message row ran off the right of the glass with its date beyond the edge. The column is now stated as minmax(0, 1fr), and the search field is allowed to shrink. Full-screen surfaces measure in dvh rather than vh, and the tab bar, drawer and compose button keep out from under the notch and the home indicator.
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
import { useCallback, useEffect, useRef } from "react";
|
|
import type { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from "react";
|
|
|
|
/**
|
|
* The gestures a phone expects, and the arithmetic behind them.
|
|
*
|
|
* ihasmail's mail list was built for a mouse: a row is clicked, right-clicked
|
|
* and dragged into a folder. None of those exist on a phone, which instead has
|
|
* three conventions so settled that their absence reads as the app being
|
|
* broken -- swipe a row to act on it, hold a row to select it, and pull the
|
|
* top of a list to refresh it.
|
|
*
|
|
* The numbers and the decisions live here rather than in the components so
|
|
* they can be tested without a touchscreen, and so the three gestures agree
|
|
* with each other: the same slop that says "this finger is holding still"
|
|
* decides whether a long press survives, and the same axis lock keeps a swipe
|
|
* from stealing a scroll.
|
|
*
|
|
* Everything here is touch-only by design. A mouse keeps drag-to-folder, which
|
|
* shares the same pointer stream and would otherwise be fighting a swipe for
|
|
* every drag.
|
|
*/
|
|
|
|
/** A press held this long, with the finger still, is a long press. */
|
|
export const LONG_PRESS_MS = 450;
|
|
|
|
/**
|
|
* How far a finger may drift and still count as holding still.
|
|
*
|
|
* A thumb resting on glass wanders a few pixels on its own, so zero would mean
|
|
* a long press almost never fires; much more than this and a slow deliberate
|
|
* drag starts opening a selection instead of moving the row.
|
|
*/
|
|
export const PRESS_SLOP = 10;
|
|
|
|
/** How far a drag travels before it commits to being horizontal or vertical. */
|
|
export const AXIS_SLOP = 12;
|
|
|
|
export type Axis = "x" | "y" | null;
|
|
|
|
/**
|
|
* Which way a drag has committed, once it has moved far enough to tell.
|
|
*
|
|
* Deliberately biased towards the vertical. Scrolling is what a finger on a
|
|
* message list is doing almost every time, and a scroll misread as a swipe
|
|
* grabs the list out from under the reader, while a swipe misread as a scroll
|
|
* costs them a second attempt. So `x` has to win clearly -- a drag that is
|
|
* merely more sideways than not stays a scroll.
|
|
*/
|
|
export function lockAxis(dx: number, dy: number): Axis {
|
|
const ax = Math.abs(dx);
|
|
const ay = Math.abs(dy);
|
|
if (Math.max(ax, ay) < AXIS_SLOP) return null;
|
|
return ax > ay * 1.3 ? "x" : "y";
|
|
}
|
|
|
|
/**
|
|
* How far past the row's edge a swipe must reach before letting go fires it.
|
|
*
|
|
* A share of the row rather than a fixed distance, so the gesture feels the
|
|
* same on a phone and on a tablet, but bounded at both ends: on a narrow
|
|
* screen a percentage is a flick nobody meant, and on a wide one it is a
|
|
* reach across the whole device.
|
|
*/
|
|
export function swipeThreshold(width: number): number {
|
|
return Math.max(56, Math.min(96, width * 0.28));
|
|
}
|
|
|
|
/**
|
|
* How far the row actually moves for a finger that has travelled `dx`.
|
|
*
|
|
* One-to-one until the action would fire, and increasingly reluctant after
|
|
* that. The resistance is the only thing that tells a thumb, without the
|
|
* reader looking down at the exact moment, that it has gone far enough.
|
|
*
|
|
* Stopped dead at the row's own width, because there is nothing past it: the
|
|
* row is already fully off screen, and a curve with no ceiling would go on
|
|
* accumulating travel that has nowhere to show. That only bites on a flick
|
|
* that outruns the screen, which is exactly when the row is moving too fast
|
|
* for anyone to see it stop.
|
|
*/
|
|
export function swipeOffset(dx: number, width: number): number {
|
|
const limit = swipeThreshold(width);
|
|
const over = Math.abs(dx) - limit;
|
|
if (over <= 0) return dx;
|
|
return Math.sign(dx) * Math.min(width, limit + over * 0.35);
|
|
}
|
|
|
|
/** Pull-to-refresh: how far the list comes down before letting go refreshes. */
|
|
export const PULL_TRIGGER = 64;
|
|
/** Where the list rests while the refresh it asked for is running. */
|
|
export const PULL_REST = 48;
|
|
/** As far as the list will ever come down, however hard it is pulled. */
|
|
export const PULL_MAX = 110;
|
|
|
|
/**
|
|
* How far the list follows a finger that has pulled down `dy`.
|
|
*
|
|
* Under half, so the trigger sits at about 116px of travel: far enough that
|
|
* the overscroll at the top of a list -- which happens constantly, to nobody's
|
|
* intent -- does not keep firing refreshes.
|
|
*/
|
|
export function pullDistance(dy: number): number {
|
|
if (dy <= 0) return 0;
|
|
return Math.min(PULL_MAX, dy * 0.55);
|
|
}
|
|
|
|
/**
|
|
* A short tap of the vibration motor, where there is one.
|
|
*
|
|
* Confirmation that a gesture landed, for the hand rather than the eye: a
|
|
* swipe fires at the moment the finger crosses a threshold it cannot see, and
|
|
* without this the only feedback arrives after the row has already gone.
|
|
*
|
|
* iOS supports none of this and never has, so this is silently nothing there
|
|
* rather than something to apologise for. Wrapped because a vibration inside
|
|
* a cross-origin iframe throws rather than returning false.
|
|
*/
|
|
export function haptic(pattern: number | number[] = 8): void {
|
|
try {
|
|
navigator.vibrate?.(pattern);
|
|
} catch {
|
|
/* A device that will not buzz is not a failure worth reporting. */
|
|
}
|
|
}
|
|
|
|
export interface RowGesture {
|
|
/** Whether to listen at all — false on a mouse, and while a menu is open. */
|
|
enabled: boolean;
|
|
/**
|
|
* The finger held still long enough, on `target` — handed over rather than
|
|
* left to the caller's own ref, because the element a press lands on is not
|
|
* always one a ref can reach. A router's `Link` renders the anchor itself
|
|
* and forwards nothing.
|
|
*/
|
|
onLongPress?: (target: Element) => void;
|
|
/** Whether a swipe that way leads anywhere; a `false` here never starts one. */
|
|
canSwipe?: (dir: -1 | 1) => boolean;
|
|
/** The row should now sit `dx` from home. Fired continuously while dragging. */
|
|
onSwipeMove?: (dx: number, dir: -1 | 1, armed: boolean) => void;
|
|
/** Let go: `dir` is the direction to act on, or 0 to snap back untouched. */
|
|
onSwipeEnd?: (dir: -1 | 1 | 0) => void;
|
|
}
|
|
|
|
/**
|
|
* Long press and horizontal swipe over one pointer stream.
|
|
*
|
|
* One hook rather than two because they are the same gesture until they are
|
|
* not: a press that moves is no longer a press, and a swipe that does not move
|
|
* is a press. Splitting them meant both hooks watching the same events and
|
|
* disagreeing at the boundary.
|
|
*
|
|
* The element needs `touch-action: pan-y`, which is what makes this possible
|
|
* without breaking the list: the browser keeps handling vertical scrolling
|
|
* itself, at its own frame rate, and hands us the horizontal movement it now
|
|
* knows it is not going to use.
|
|
*/
|
|
export function useTouchRow({ enabled, onLongPress, canSwipe, onSwipeMove, onSwipeEnd }: RowGesture) {
|
|
const start = useRef<{ x: number; y: number; width: number; id: number; target: Element } | null>(null);
|
|
const axis = useRef<Axis>(null);
|
|
const dir = useRef<-1 | 1>(1);
|
|
const armed = useRef(false);
|
|
const timer = useRef<number | null>(null);
|
|
/*
|
|
* A gesture that did anything must not also be a tap. The row's click
|
|
* handler opens the conversation, and a swipe or a long press both end with
|
|
* the finger lifting off the row -- which is a click as far as the browser is
|
|
* concerned, arriving after every pointer event we could cancel from.
|
|
*/
|
|
const swallowClick = useRef(false);
|
|
const fromTouch = useRef(false);
|
|
|
|
const clearTimer = () => {
|
|
if (timer.current !== null) window.clearTimeout(timer.current);
|
|
timer.current = null;
|
|
};
|
|
useEffect(() => clearTimer, []);
|
|
|
|
const reset = useCallback(() => {
|
|
clearTimer();
|
|
start.current = null;
|
|
axis.current = null;
|
|
armed.current = false;
|
|
}, []);
|
|
|
|
const onPointerDown = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
fromTouch.current = e.pointerType === "touch";
|
|
if (!enabled || e.pointerType !== "touch") return;
|
|
// Read out now: `currentTarget` is only meaningful during dispatch, and
|
|
// the long-press timer runs long after this handler has returned.
|
|
const target = e.currentTarget;
|
|
start.current = { x: e.clientX, y: e.clientY, width: target.getBoundingClientRect().width, id: e.pointerId, target };
|
|
axis.current = null;
|
|
armed.current = false;
|
|
swallowClick.current = false;
|
|
if (onLongPress) {
|
|
timer.current = window.setTimeout(() => {
|
|
timer.current = null;
|
|
// Still here, still not moving: nothing has cancelled us.
|
|
if (!start.current || axis.current) return;
|
|
swallowClick.current = true;
|
|
onLongPress(start.current.target);
|
|
}, LONG_PRESS_MS);
|
|
}
|
|
},
|
|
[enabled, onLongPress],
|
|
);
|
|
|
|
const onPointerMove = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
const s = start.current;
|
|
if (!s || e.pointerId !== s.id) return;
|
|
const dx = e.clientX - s.x;
|
|
const dy = e.clientY - s.y;
|
|
|
|
if (axis.current === null) {
|
|
if (Math.abs(dx) > PRESS_SLOP || Math.abs(dy) > PRESS_SLOP) clearTimer();
|
|
const locked = lockAxis(dx, dy);
|
|
if (!locked) return;
|
|
/*
|
|
* A vertical drag is the browser's, and it has already started
|
|
* scrolling with it. Letting go of the whole gesture here -- rather
|
|
* than remembering that we lost -- matters, because the finger will go
|
|
* on to travel a long way sideways during a diagonal flick, and this
|
|
* row would otherwise catch up with it mid-scroll.
|
|
*/
|
|
if (locked === "y" || !onSwipeMove) {
|
|
reset();
|
|
return;
|
|
}
|
|
const d: -1 | 1 = dx < 0 ? -1 : 1;
|
|
if (canSwipe && !canSwipe(d)) {
|
|
reset();
|
|
return;
|
|
}
|
|
axis.current = "x";
|
|
dir.current = d;
|
|
swallowClick.current = true;
|
|
try {
|
|
// Throws if the pointer is already gone -- a flick fast enough to
|
|
// have lifted between this event being queued and being handled.
|
|
// The gesture works perfectly well without the capture.
|
|
e.currentTarget.setPointerCapture(s.id);
|
|
} catch {
|
|
/* nothing left to capture */
|
|
}
|
|
}
|
|
|
|
const d: -1 | 1 = dx < 0 ? -1 : 1;
|
|
/*
|
|
* Crossing back the other way mid-gesture. The direction is re-read
|
|
* rather than held from the lock, so a reader who overshoots, thinks
|
|
* better of it and drags back past centre gets the other action offered
|
|
* instead of the row refusing to move.
|
|
*/
|
|
if (d !== dir.current) {
|
|
if (canSwipe && !canSwipe(d)) {
|
|
onSwipeMove?.(0, dir.current, false);
|
|
return;
|
|
}
|
|
dir.current = d;
|
|
}
|
|
const offset = swipeOffset(dx, s.width);
|
|
const nowArmed = Math.abs(dx) >= swipeThreshold(s.width);
|
|
if (nowArmed !== armed.current) {
|
|
armed.current = nowArmed;
|
|
if (nowArmed) haptic();
|
|
}
|
|
onSwipeMove?.(offset, d, nowArmed);
|
|
},
|
|
[canSwipe, onSwipeMove, reset],
|
|
);
|
|
|
|
const onPointerUp = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
const s = start.current;
|
|
clearTimer();
|
|
if (!s || e.pointerId !== s.id) return;
|
|
if (axis.current === "x") onSwipeEnd?.(armed.current ? dir.current : 0);
|
|
reset();
|
|
},
|
|
[onSwipeEnd, reset],
|
|
);
|
|
|
|
const onPointerCancel = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
if (start.current && e.pointerId !== start.current.id) return;
|
|
if (axis.current === "x") onSwipeEnd?.(0);
|
|
reset();
|
|
},
|
|
[onSwipeEnd, reset],
|
|
);
|
|
|
|
const onClickCapture = useCallback((e: ReactMouseEvent) => {
|
|
if (!swallowClick.current) return;
|
|
swallowClick.current = false;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}, []);
|
|
|
|
/*
|
|
* Android fires `contextmenu` for a long press of its own, a little after
|
|
* ours, and would open the desktop right-click menu on top of whatever the
|
|
* long press just did. The desktop handler stays untouched for an actual
|
|
* right-click, which is the only thing that reaches it now.
|
|
*/
|
|
const onContextMenuCapture = useCallback(
|
|
(e: ReactMouseEvent) => {
|
|
if (!enabled || !fromTouch.current) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
},
|
|
[enabled],
|
|
);
|
|
|
|
return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClickCapture, onContextMenuCapture };
|
|
}
|
|
|
|
/**
|
|
* Pull the top of a scroller down to refresh it.
|
|
*
|
|
* Native listeners rather than React props because the move handler has to be
|
|
* able to call `preventDefault`, and React attaches its own passively. Bound
|
|
* to the scroll container itself so that everything inside it -- a virtualised
|
|
* list included -- comes down with the pull without knowing about it.
|
|
*/
|
|
export function usePullToRefresh(
|
|
el: HTMLElement | null,
|
|
onRefresh: () => Promise<void> | void,
|
|
{ enabled, onPull }: { enabled: boolean; onPull: (distance: number, armed: boolean, live: boolean) => void },
|
|
) {
|
|
const refresh = useRef(onRefresh);
|
|
refresh.current = onRefresh;
|
|
const pull = useRef(onPull);
|
|
pull.current = onPull;
|
|
|
|
useEffect(() => {
|
|
if (!el || !enabled) return;
|
|
let startY: number | null = null;
|
|
let distance = 0;
|
|
let armed = false;
|
|
let running = false;
|
|
|
|
const onStart = (e: TouchEvent) => {
|
|
// Only from a list already at the top, and only one finger: a pinch that
|
|
// happens to begin near the top is not a pull.
|
|
if (running || e.touches.length !== 1 || el.scrollTop > 0) return;
|
|
startY = e.touches[0]!.clientY;
|
|
distance = 0;
|
|
armed = false;
|
|
};
|
|
|
|
const onMove = (e: TouchEvent) => {
|
|
if (startY === null || e.touches.length !== 1) return;
|
|
const dy = e.touches[0]!.clientY - startY;
|
|
if (dy <= 0) {
|
|
// Pulled back up, or the gesture was a scroll all along.
|
|
if (distance > 0) pull.current((distance = 0), (armed = false), true);
|
|
if (el.scrollTop > 0) startY = null;
|
|
return;
|
|
}
|
|
distance = pullDistance(dy);
|
|
const nowArmed = distance >= PULL_TRIGGER;
|
|
if (nowArmed !== armed) {
|
|
armed = nowArmed;
|
|
if (nowArmed) haptic();
|
|
}
|
|
/*
|
|
* Only once the list is visibly following the finger. Calling this on
|
|
* the first pixel would cancel the tap that starts every scroll, and
|
|
* `cancelable` is false once the browser has already committed the
|
|
* gesture to scrolling -- calling it then is a console warning and
|
|
* nothing else.
|
|
*/
|
|
if (distance > 2 && e.cancelable) e.preventDefault();
|
|
pull.current(distance, armed, true);
|
|
};
|
|
|
|
const onEnd = () => {
|
|
if (startY === null) return;
|
|
startY = null;
|
|
if (!armed) {
|
|
if (distance > 0) pull.current((distance = 0), false, false);
|
|
return;
|
|
}
|
|
running = true;
|
|
armed = false;
|
|
pull.current(PULL_REST, true, false);
|
|
void Promise.resolve(refresh.current()).finally(() => {
|
|
running = false;
|
|
distance = 0;
|
|
pull.current(0, false, false);
|
|
});
|
|
};
|
|
|
|
el.addEventListener("touchstart", onStart, { passive: true });
|
|
el.addEventListener("touchmove", onMove, { passive: false });
|
|
el.addEventListener("touchend", onEnd);
|
|
el.addEventListener("touchcancel", onEnd);
|
|
return () => {
|
|
el.removeEventListener("touchstart", onStart);
|
|
el.removeEventListener("touchmove", onMove);
|
|
el.removeEventListener("touchend", onEnd);
|
|
el.removeEventListener("touchcancel", onEnd);
|
|
};
|
|
}, [el, enabled]);
|
|
}
|
|
|
|
/** How far in from the left edge a drag must start to count as going back. */
|
|
export const EDGE_ZONE = 28;
|
|
|
|
/**
|
|
* Drag in from the left edge to go back, the way every phone does it.
|
|
*
|
|
* Only from the edge. A back gesture that started anywhere would fight the
|
|
* horizontal scrolling that wide HTML mail needs, and mail is exactly the
|
|
* content nobody controls the width of.
|
|
*/
|
|
export function useEdgeBack(el: HTMLElement | null, onBack: () => void, enabled: boolean) {
|
|
const back = useRef(onBack);
|
|
back.current = onBack;
|
|
|
|
useEffect(() => {
|
|
if (!el || !enabled) return;
|
|
let startX: number | null = null;
|
|
let startY = 0;
|
|
let live = false;
|
|
|
|
const settle = (offset: number, animate: boolean) => {
|
|
el.style.transition = animate ? "transform .18s var(--ease, ease)" : "";
|
|
el.style.transform = offset ? `translateX(${offset}px)` : "";
|
|
};
|
|
|
|
const onStart = (e: TouchEvent) => {
|
|
if (e.touches.length !== 1) return;
|
|
const t = e.touches[0]!;
|
|
if (t.clientX - el.getBoundingClientRect().left > EDGE_ZONE) return;
|
|
startX = t.clientX;
|
|
startY = t.clientY;
|
|
live = false;
|
|
};
|
|
|
|
const onMove = (e: TouchEvent) => {
|
|
if (startX === null || e.touches.length !== 1) return;
|
|
const t = e.touches[0]!;
|
|
const dx = t.clientX - startX;
|
|
const dy = t.clientY - startY;
|
|
if (!live) {
|
|
if (lockAxis(dx, dy) === "y") {
|
|
startX = null;
|
|
return;
|
|
}
|
|
if (lockAxis(dx, dy) !== "x" || dx < 0) return;
|
|
live = true;
|
|
}
|
|
if (e.cancelable) e.preventDefault();
|
|
settle(Math.max(0, dx * 0.9), false);
|
|
};
|
|
|
|
const onEnd = () => {
|
|
if (startX === null) return;
|
|
const offset = parseFloat(el.style.transform.replace(/[^\d.-]/g, "")) || 0;
|
|
startX = null;
|
|
if (!live) return;
|
|
live = false;
|
|
// A third of the way across is enough: a back gesture is a flick, and
|
|
// asking for half the screen makes it feel like the app is resisting.
|
|
if (offset > el.clientWidth / 3) {
|
|
haptic();
|
|
settle(0, false);
|
|
back.current();
|
|
} else {
|
|
settle(0, true);
|
|
window.setTimeout(() => (el.style.transition = ""), 200);
|
|
}
|
|
};
|
|
|
|
el.addEventListener("touchstart", onStart, { passive: true });
|
|
el.addEventListener("touchmove", onMove, { passive: false });
|
|
el.addEventListener("touchend", onEnd);
|
|
el.addEventListener("touchcancel", onEnd);
|
|
return () => {
|
|
el.removeEventListener("touchstart", onStart);
|
|
el.removeEventListener("touchmove", onMove);
|
|
el.removeEventListener("touchend", onEnd);
|
|
el.removeEventListener("touchcancel", onEnd);
|
|
el.style.transform = "";
|
|
el.style.transition = "";
|
|
};
|
|
}, [el, enabled]);
|
|
}
|