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:
+15
@@ -110,6 +110,21 @@ every drag.
|
|||||||
- **Hold a folder** in the drawer for the menu its ⋮ button opens.
|
- **Hold a folder** in the drawer for the menu its ⋮ button opens.
|
||||||
- **Pull the message list** down to refresh it.
|
- **Pull the message list** down to refresh it.
|
||||||
- **Drag in from the left edge** of a conversation to go back to the list.
|
- **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
|
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
|
with no visible control is one only the people who already know about it can
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -490,3 +490,129 @@ export function useEdgeBack(el: HTMLElement | null, onBack: () => void, enabled:
|
|||||||
};
|
};
|
||||||
}, [el, 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]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-rea
|
|||||||
import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
|
import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
|
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 { formatMonthYear, formatTime } from "@/lib/format";
|
||||||
import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
|
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 { keyboard } from "@/lib/keyboard";
|
||||||
import { EventPopover } from "./EventPopover";
|
import { EventPopover } from "./EventPopover";
|
||||||
import { EventEditor, type EditorInit } from "./EventEditor";
|
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));
|
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(
|
const openNew = useCallback(
|
||||||
(start?: Date, end?: Date, allDay = false) => {
|
(start?: Date, end?: Date, allDay = false) => {
|
||||||
const s = start ?? roundToNext(new Date(), 30);
|
const s = start ?? roundToNext(new Date(), 30);
|
||||||
@@ -147,7 +167,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cal-main">
|
<div className="cal-main" ref={setMainEl}>
|
||||||
<div className="cal-toolbar">
|
<div className="cal-toolbar">
|
||||||
<button className="btn btn-sm" onClick={() => go(view, new Date())}>{translate("Today")}</button>
|
<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>
|
<button className="icon-btn sm" onClick={() => step(-1)} aria-label={translate("Previous")}><ChevronLeft size={18} /></button>
|
||||||
|
|||||||
Reference in New Issue
Block a user