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:
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { 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>
|
||||
|
||||
Reference in New Issue
Block a user