Merge pull request #199 from Coffey-Labs/feat/calendar-swipe

Swipe the calendar sideways to step a day or a month
This commit is contained in:
Coffey Labs
2026-09-01 22:27:51 -07:00
committed by GitHub
4 changed files with 231 additions and 2 deletions
+15
View File
@@ -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
+68
View File
@@ -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();
});
});
+126
View File
@@ -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]);
}
+22 -2
View File
@@ -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<HTMLDivElement | null>(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 (
<div className="cal-main">
<div className="cal-main" ref={setMainEl}>
<div className="cal-toolbar">
<button className="btn btn-sm" onClick={() => go(view, new Date())}>{translate("Today")}</button>
<button className="icon-btn sm" onClick={() => step(-1)} aria-label={translate("Previous")}><ChevronLeft size={18} /></button>