Give the mail list the gestures a phone already has

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.
This commit is contained in:
2026-08-31 06:52:25 -07:00
parent 0fb504748d
commit b2769b9011
12 changed files with 1235 additions and 86 deletions
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { AXIS_SLOP, PULL_MAX, PULL_TRIGGER, lockAxis, pullDistance, swipeOffset, swipeThreshold } from "../touch";
/**
* The arithmetic behind the touch gestures, checked without a touchscreen.
*
* These are the numbers that decide whether a finger meant to scroll the list
* or to act on a message, and getting them wrong is not a crash — it is an app
* that deletes mail when someone tried to scroll past it. Worth pinning down.
*/
describe("lockAxis", () => {
it("stays undecided until the finger has committed", () => {
expect(lockAxis(0, 0)).toBe(null);
expect(lockAxis(AXIS_SLOP - 1, AXIS_SLOP - 1)).toBe(null);
});
it("reads a clearly sideways drag as a swipe", () => {
expect(lockAxis(40, 4)).toBe("x");
expect(lockAxis(-40, 4)).toBe("x");
});
it("gives a diagonal to the scroller, not the swipe", () => {
// 45 degrees is more sideways than not, and is still a scroll: someone
// flicking down a list does not travel straight down the glass.
expect(lockAxis(30, 30)).toBe("y");
expect(lockAxis(30, 25)).toBe("y");
});
it("counts distance on either axis towards committing", () => {
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
});
});
describe("swipeThreshold", () => {
it("scales with the row but never off either end", () => {
expect(swipeThreshold(300)).toBeCloseTo(84); // a phone: a share of the row
expect(swipeThreshold(160)).toBe(56); // a narrow row: a fixed floor
expect(swipeThreshold(2000)).toBe(96); // a tablet: not the whole reach
});
});
describe("swipeOffset", () => {
const width = 360;
const limit = swipeThreshold(width);
it("follows the finger exactly until the action would fire", () => {
expect(swipeOffset(20, width)).toBe(20);
expect(swipeOffset(-20, width)).toBe(-20);
expect(swipeOffset(limit, width)).toBe(limit);
});
it("resists past the threshold, in both directions", () => {
const over = swipeOffset(limit + 100, width);
expect(over).toBeGreaterThan(limit);
expect(over).toBeLessThan(limit + 100);
expect(swipeOffset(-(limit + 100), width)).toBeCloseTo(-over);
});
it("never travels further than the row is wide", () => {
// Past the row's own width there is nothing left to reveal, so a hard
// flick stops there rather than accumulating travel with nowhere to show.
expect(Math.abs(swipeOffset(2000, width))).toBe(width);
expect(Math.abs(swipeOffset(-2000, width))).toBe(width);
});
});
describe("pullDistance", () => {
it("ignores an upward drag", () => {
expect(pullDistance(0)).toBe(0);
expect(pullDistance(-50)).toBe(0);
});
it("asks for a deliberate pull, not the overscroll at the top of a list", () => {
expect(pullDistance(40)).toBeLessThan(PULL_TRIGGER);
expect(pullDistance(60)).toBeLessThan(PULL_TRIGGER);
expect(pullDistance(140)).toBeGreaterThanOrEqual(PULL_TRIGGER);
});
it("stops coming down however hard it is pulled", () => {
expect(pullDistance(400)).toBe(PULL_MAX);
expect(pullDistance(4000)).toBe(PULL_MAX);
});
});