diff --git a/FEATURES.md b/FEATURES.md index 79d19db..f388498 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -110,6 +110,21 @@ every drag. - **Hold a folder** in the drawer for the menu its ⋮ button opens. - **Pull the message list** down to refresh it. - **Drag in from the left edge** of a conversation to go back to the list. +- **Swipe the calendar sideways** in day or month view to step to the next + period or back — dragging left pulls the next one in from the right, the way + paper and every phone do it. Week and agenda scroll through a range rather + than turning to the next one, so a sideways flick would not obviously mean + anything there and does nothing. A drag that begins on an event is left + alone, which keeps dragging an event to move it available to be built later + without having to be untangled from this first. + + It asks for a longer drag than a row swipe does, and not because the + consequence is bigger — stepping back undoes it, while a swiped row has + already been archived. It is because this gesture has no way to change its + mind: a row slides open as it goes, so the strip underneath names what will + happen and letting go early calls it off, and a toast offers Undo afterwards. + Stepping the calendar shows nothing on the way and offers nothing after, so + the distance is the only chance to not mean it. The toolbar's refresh button and the thread's back arrow both stay. A gesture with no visible control is one only the people who already know about it can diff --git a/web/src/lib/__tests__/swipeNav.test.ts b/web/src/lib/__tests__/swipeNav.test.ts new file mode 100644 index 0000000..03041bc --- /dev/null +++ b/web/src/lib/__tests__/swipeNav.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch"; + +describe("navSwipeThreshold", () => { + it("asks for more travel than a row swipe does, at every width", () => { + // Not because the consequence is bigger -- stepping back undoes it -- but + // because this gesture reveals nothing on the way and offers no Undo + // after, so the distance is the only chance to not mean it. + for (const width of [320, 360, 414, 768, 1024]) { + expect(navSwipeThreshold(width)).toBeGreaterThan(swipeThreshold(width)); + } + }); + + it("is a share of the width, bounded at both ends", () => { + expect(navSwipeThreshold(360)).toBe(108); + expect(navSwipeThreshold(200)).toBe(80); // floor + expect(navSwipeThreshold(1000)).toBe(180); // ceiling + }); +}); + +describe("swipeNavDirection", () => { + const W = 400; // threshold is 120 at this width + + it("goes forward when the finger drags left, the way pages turn", () => { + expect(swipeNavDirection(-200, W)).toBe(1); + }); + + it("goes back when the finger drags right", () => { + expect(swipeNavDirection(200, W)).toBe(-1); + }); + + it("does nothing short of the threshold, in either direction", () => { + expect(swipeNavDirection(-60, W)).toBe(0); + expect(swipeNavDirection(60, W)).toBe(0); + expect(swipeNavDirection(0, W)).toBe(0); + }); + + it("fires exactly at the threshold and not a pixel before", () => { + const at = navSwipeThreshold(W); + expect(swipeNavDirection(-at, W)).toBe(1); + expect(swipeNavDirection(-(at - 1), W)).toBe(0); + expect(swipeNavDirection(at, W)).toBe(-1); + expect(swipeNavDirection(at - 1, W)).toBe(0); + }); + + it("scales with the width, so a tablet asks for more than a phone", () => { + // The same 120px drag commits on a narrow screen and does not on a wide one. + expect(swipeNavDirection(-120, 360)).toBe(1); + expect(swipeNavDirection(-120, 1024)).toBe(0); + }); +}); + +describe("the axis lock this shares with the row swipe", () => { + it("keeps a mostly-vertical drag as a scroll, which is what the day grid needs", () => { + // The day view scrolls through the hours; a scroll misread as a swipe + // throws the reader into another day. + expect(lockAxis(20, 30)).toBe("y"); + expect(lockAxis(30, 25)).toBe("y"); + }); + + it("commits to sideways only when it is clearly sideways", () => { + expect(lockAxis(40, 10)).toBe("x"); + }); + + it("is undecided until the drag has moved at all", () => { + expect(lockAxis(2, 2)).toBeNull(); + }); +}); diff --git a/web/src/lib/touch.ts b/web/src/lib/touch.ts index 14df607..a8f175a 100644 --- a/web/src/lib/touch.ts +++ b/web/src/lib/touch.ts @@ -490,3 +490,129 @@ export function useEdgeBack(el: HTMLElement | null, onBack: () => void, enabled: }; }, [el, enabled]); } + +/** + * How far a horizontal drag must travel before it moves the calendar to + * another day or month. + * + * Further than a row swipe, and not because the consequence is bigger -- + * stepping a calendar is undone by stepping back, while a swiped row has + * already been archived. It is because this gesture has no way to change its + * mind. A row slides open as it goes, so the strip underneath names what is + * about to happen and letting go early calls it off, and a toast offers Undo + * afterwards. Stepping the calendar shows nothing on the way and offers + * nothing after, so the distance is the only chance to not mean it. + */ +export function navSwipeThreshold(width: number): number { + return Math.max(80, Math.min(180, width * 0.3)); +} + +/** + * Which way a finished drag sends the view: -1 back, +1 forward, 0 nowhere. + * + * Dragging left pulls the next period in from the right, which is how paper, + * phones and every other calendar behave. (It would need mirroring for a + * right-to-left interface; there is not one yet, and the day there is, this is + * one of the places that has to know.) + */ +export function swipeNavDirection(dx: number, width: number): -1 | 0 | 1 { + const threshold = navSwipeThreshold(width); + if (dx <= -threshold) return 1; + if (dx >= threshold) return -1; + return 0; +} + +/** + * Swipe sideways across a calendar to step it a period at a time. + * + * Three things it deliberately does not do: + * + * - **No visual drag.** The row swipe slides the row open because the strip + * underneath has to name which of six actions is about to happen. Stepping + * a calendar has two outcomes and the direction of the finger already says + * which, so there is nothing to reveal -- and translating the grid would + * break the sticky day header, since a transform makes a containing block. + * The threshold is reported by the vibration motor instead, which is what + * the haptics are for: a swipe fires as the finger passes a line it cannot + * see. + * - **It does not start on an event.** A drag beginning on an event chip is + * left alone, so that moving an event by dragging it stays available to be + * built without having to be untangled from this first. Which gesture is + * meant is decidable at the moment the finger lands, and that is the only + * moment it can be decided cleanly. + * - **It does not start on the toolbar.** Buttons live there. + * + * The axis lock is the shared one, so it keeps the same bias towards the + * vertical: the day grid scrolls through the hours, and a scroll misread as a + * swipe throws the reader into another day. + */ +export function useSwipeNav( + el: HTMLElement | null, + opts: { onStep: (n: -1 | 1) => void; enabled: boolean; ignore?: string }, +) { + const step = useRef(opts.onStep); + step.current = opts.onStep; + const { enabled, ignore } = opts; + + useEffect(() => { + if (!el || !enabled) return; + let startX: number | null = null; + let startY = 0; + let axis: Axis = null; + let fired = false; + + const onStart = (e: TouchEvent) => { + if (e.touches.length !== 1) return; + const t = e.touches[0]!; + if (ignore && (t.target as Element | null)?.closest?.(ignore)) return; + startX = t.clientX; + startY = t.clientY; + axis = null; + fired = 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 (!axis) { + axis = lockAxis(dx, dy); + // Committed to scrolling: stay out of the way for the rest of the drag. + if (axis === "y") startX = null; + return; + } + if (axis !== "x") return; + // Once sideways, the browser must not also scroll. + if (e.cancelable) e.preventDefault(); + if (!fired && swipeNavDirection(dx, el.clientWidth || window.innerWidth) !== 0) { + fired = true; + haptic(); + } + }; + + const onEnd = (e: TouchEvent) => { + if (startX === null) return; + const t = e.changedTouches[0]; + const dx = t ? t.clientX - startX : 0; + const wasX = axis === "x"; + startX = null; + axis = null; + fired = false; + if (!wasX) return; + const dir = swipeNavDirection(dx, el.clientWidth || window.innerWidth); + if (dir !== 0) step.current(dir); + }; + + 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, ignore]); +} diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index dcc323e..c4abcb8 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -4,9 +4,10 @@ import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-rea import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar"; import { useSettings } from "@/store/settings"; import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates"; +import { useSwipeNav } from "@/lib/touch"; import { formatMonthYear, formatTime } from "@/lib/format"; import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime"; -import { Empty, useIsMobile } from "@/ui/misc"; +import { Empty, useIsMobile, useIsTouch } from "@/ui/misc"; import { keyboard } from "@/lib/keyboard"; import { EventPopover } from "./EventPopover"; import { EventEditor, type EditorInit } from "./EventEditor"; @@ -90,6 +91,25 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?: else go(view, addDays(anchor, 30 * n)); }; + /* + * Swipe sideways to step the calendar, on a touchscreen only and only in the + * two views where a period is a page: day and month. Week and agenda scroll + * through a range rather than turning to the next one, so there is nothing a + * sideways flick would obviously mean. + * + * The buttons in the toolbar stay, and so does n/p. A gesture with no + * visible control is one only the people who already know about it can use. + */ + const [mainEl, setMainEl] = useState(null); + const isTouch = useIsTouch(); + useSwipeNav(mainEl, { + enabled: isTouch && (effectiveView === "day" || effectiveView === "month"), + onStep: (n) => step(n), + // Buttons live in the toolbar; an event is where a future drag-to-move + // gesture has to start, so this one keeps out of both. + ignore: ".cal-toolbar, .ev-chip, .ev-block, .agenda-ev", + }); + const openNew = useCallback( (start?: Date, end?: Date, allDay = false) => { const s = start ?? roundToNext(new Date(), 30); @@ -147,7 +167,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?: }; return ( -
+