@remit/web-client 0.0.319 → 0.0.321

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.319",
3
+ "version": "0.0.321",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -115,6 +115,8 @@ function Shell({
115
115
  onChangeView={() => undefined}
116
116
  onToday={() => undefined}
117
117
  onStep={() => undefined}
118
+ dayHref={(day) => `#${day}`}
119
+ onZoomDay={() => undefined}
118
120
  onChangeDensity={setDensity}
119
121
  onSelectEvent={() => undefined}
120
122
  onPickSlot={() => undefined}
@@ -5,7 +5,7 @@ import { CalendarViewPlaceholder } from "./CalendarViewPlaceholder";
5
5
  /**
6
6
  * A zoom the ladder offers and does not draw yet.
7
7
  *
8
- * The route is addressable at all five zooms, so two of them land on this. What
8
+ * The route is addressable at all five zooms, so one of them lands on this. What
9
9
  * it has to do is say which of the two things it is: a view that rendered
10
10
  * nothing would be indistinguishable from a month with nothing booked in it,
11
11
  * and the reader would plan around an empty screen.
@@ -14,9 +14,9 @@ const meta: Meta<typeof CalendarViewPlaceholder> = {
14
14
  title: "Playground/Shipped/Calendar/Not built yet",
15
15
  component: CalendarViewPlaceholder,
16
16
  parameters: { layout: "fullscreen" },
17
- render: (args) => (
17
+ render: () => (
18
18
  <div className="h-dvh bg-canvas">
19
- <CalendarViewPlaceholder {...args} />
19
+ <CalendarViewPlaceholder />
20
20
  </div>
21
21
  ),
22
22
  };
@@ -24,9 +24,8 @@ export default meta;
24
24
 
25
25
  type Story = StoryObj<typeof CalendarViewPlaceholder>;
26
26
 
27
- /** It names the zoom it is waiting on, and the three that work today. */
27
+ /** It names the zoom it is waiting on, and the four that work today. */
28
28
  export const Year: Story = {
29
- args: { view: "year" },
30
29
  play: async ({ canvasElement }) => {
31
30
  const canvas = within(canvasElement);
32
31
  await expect(
@@ -36,19 +35,7 @@ export const Year: Story = {
36
35
  );
37
36
  await expect(canvas.getByText("Not built yet")).toBeVisible();
38
37
  await expect(
39
- canvas.getByText(/Week, Day and Agenda work now\./),
38
+ canvas.getByText(/Month, Week, Day and Agenda work now\./),
40
39
  ).toBeVisible();
41
40
  },
42
41
  };
43
-
44
- export const Month: Story = {
45
- args: { view: "month" },
46
- play: async ({ canvasElement }) => {
47
- const canvas = within(canvasElement);
48
- await expect(
49
- canvas.getByTestId("calendar-placeholder-month"),
50
- ).toHaveTextContent(
51
- "The month grid arrives with the rest of the zoom ladder.",
52
- );
53
- },
54
- };
@@ -1,34 +1,15 @@
1
- import type { CalendarViewId } from "@remit/ui";
2
1
  import { CalendarRange } from "lucide-react";
3
2
 
4
- /**
5
- * A zoom level that is named but not drawn yet.
6
- *
7
- * The ladder is five steps and two of them arrive later, so the route stays
8
- * addressable and says what it is waiting on. A view that silently rendered
9
- * nothing would be indistinguishable from a week with nothing in it.
10
- */
11
- const WAITING_ON: Record<string, string> = {
12
- year: "The year grid arrives with the rest of the zoom ladder.",
13
- month: "The month grid arrives with the rest of the zoom ladder.",
14
- };
15
-
16
- export interface CalendarViewPlaceholderProps {
17
- view: CalendarViewId;
18
- }
19
-
20
- export function CalendarViewPlaceholder({
21
- view,
22
- }: CalendarViewPlaceholderProps) {
3
+ export function CalendarViewPlaceholder() {
23
4
  return (
24
5
  <div
25
6
  className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center"
26
- data-testid={`calendar-placeholder-${view}`}
7
+ data-testid="calendar-placeholder-year"
27
8
  >
28
9
  <CalendarRange className="size-8 text-fg-subtle" aria-hidden="true" />
29
10
  <p className="text-sm font-medium text-fg">Not built yet</p>
30
11
  <p className="max-w-xs text-sm text-fg-muted">
31
- {WAITING_ON[view] ?? "This view arrives in a later stage."} Week, Day
12
+ The year grid arrives with the rest of the zoom ladder. Month, Week, Day
32
13
  and Agenda work now.
33
14
  </p>
34
15
  </div>
@@ -8,6 +8,8 @@ import {
8
8
  } from "@remit/ui";
9
9
  import type { Meta, StoryObj } from "@storybook/react-vite";
10
10
  import { useState } from "react";
11
+ import { expect, userEvent, waitFor, within } from "storybook/test";
12
+ import { stepCalendarDate } from "@/lib/calendar-route";
11
13
  import { CalendarWaiting } from "./CalendarWaiting";
12
14
  import { CalendarWorkspace } from "./CalendarWorkspace";
13
15
 
@@ -206,12 +208,13 @@ function Workspace({
206
208
  waiting?: "none" | "some" | "refused";
207
209
  }) {
208
210
  const [view, setView] = useState<CalendarViewId>(initialView);
211
+ const [date, setDate] = useState(DATE);
209
212
  const [density, setDensity] = useState<Density>("comfortable");
210
213
  const [selected, setSelected] = useState("");
211
214
  return (
212
215
  <CalendarWorkspace
213
216
  view={view}
214
- date={DATE}
217
+ date={date}
215
218
  events={events}
216
219
  colorByCalendarId={colorByCalendarId}
217
220
  // The strip loads its own days and is storied on its own; here it
@@ -235,7 +238,14 @@ function Workspace({
235
238
  now={NOW}
236
239
  onChangeView={setView}
237
240
  onToday={() => undefined}
238
- onStep={() => undefined}
241
+ onStep={(direction) =>
242
+ setDate((current) => stepCalendarDate(current, view, direction))
243
+ }
244
+ dayHref={(day) => `/calendar/day/${day}`}
245
+ onZoomDay={(day) => {
246
+ setView("day");
247
+ setDate(day);
248
+ }}
239
249
  onChangeDensity={setDensity}
240
250
  onSelectEvent={setSelected}
241
251
  onPickSlot={() => undefined}
@@ -263,6 +273,33 @@ export const Week: Story = {};
263
273
 
264
274
  export const Day: Story = { args: { initialView: "day" } };
265
275
 
276
+ export const Month: Story = {
277
+ args: { initialView: "month" },
278
+ play: async ({ canvasElement }) => {
279
+ const canvas = within(canvasElement);
280
+ await waitFor(() =>
281
+ expect(canvas.getAllByText("Roadmap review").length).toBeGreaterThan(0),
282
+ );
283
+ await userEvent.click(canvas.getByRole("button", { name: "Next" }));
284
+ await waitFor(() =>
285
+ expect(canvas.getByRole("heading", { level: 2 })).toHaveTextContent(
286
+ /July 2026/,
287
+ ),
288
+ );
289
+ const day = await canvas.findByRole("link", {
290
+ name: /15 July 2026$/,
291
+ });
292
+ await expect(day).toHaveAttribute("href", "/calendar/day/2026-07-15");
293
+ await userEvent.click(day);
294
+ await waitFor(() =>
295
+ expect(canvas.getByRole("radio", { name: "Day" })).toBeChecked(),
296
+ );
297
+ await expect(canvas.getByRole("heading", { level: 2 })).toHaveTextContent(
298
+ /15 July 2026|July 15, 2026/,
299
+ );
300
+ },
301
+ };
302
+
266
303
  /** A phone: two rows of thumb-sized controls, and no year on the ladder. */
267
304
  export const Phone: Story = {
268
305
  args: { touch: true },
@@ -288,13 +325,11 @@ export const CouldNotLoad: Story = {
288
325
  };
289
326
 
290
327
  /**
291
- * The three zooms that are named but not drawn yet. Each says what it is
292
- * waiting on, so the route is addressable without pretending to be finished.
328
+ * The zoom that is named but not drawn yet. It says what it is waiting on, so
329
+ * the route is addressable without pretending to be finished.
293
330
  */
294
331
  export const YearNotBuiltYet: Story = { args: { initialView: "year" } };
295
332
 
296
- export const MonthNotBuiltYet: Story = { args: { initialView: "month" } };
297
-
298
333
  /**
299
334
  * Invitations the mail is still asking about, beside the week they would land
300
335
  * in. One at a time, as a deck; nothing here is on the calendar until added.
@@ -76,6 +76,8 @@ export interface CalendarWorkspaceProps {
76
76
  onSelectEvent: (eventId: string) => void;
77
77
  onPickSlot: (pick: CalendarSlotPick) => void;
78
78
  onStep: (direction: -1 | 1) => void;
79
+ dayHref: (date: string) => string;
80
+ onZoomDay: (date: string) => void;
79
81
  /**
80
82
  * A phone: the toolbar splits into two rows of thumb-sized controls, because
81
83
  * one row of them is wider than the screen and pushes the grid off it.
@@ -137,6 +139,8 @@ export function CalendarWorkspace({
137
139
  onSelectEvent,
138
140
  onPickSlot,
139
141
  onStep,
142
+ dayHref,
143
+ onZoomDay,
140
144
  touch = false,
141
145
  }: CalendarWorkspaceProps) {
142
146
  // Held against the address it was measured for, so leaving a view does not
@@ -237,12 +241,14 @@ export function CalendarWorkspace({
237
241
  now={now}
238
242
  onSelectEvent={onSelectEvent}
239
243
  onPickSlot={onPickSlot}
244
+ dayHref={dayHref}
245
+ onZoomDay={onZoomDay}
240
246
  onRangeChange={(measuredTitle) =>
241
247
  setMeasured({ key: addressKey, title: measuredTitle })
242
248
  }
243
249
  />
244
250
  ) : (
245
- <CalendarViewPlaceholder view={view} />
251
+ <CalendarViewPlaceholder />
246
252
  )}
247
253
  </div>
248
254
  )}
@@ -70,6 +70,8 @@ const workspaceProps = (
70
70
  onChangeView: noop,
71
71
  onToday: noop,
72
72
  onStep: noop,
73
+ dayHref: (day) => `/calendar/day/${day}`,
74
+ onZoomDay: noop,
73
75
  onChangeDensity: noop,
74
76
  onSelectEvent: noop,
75
77
  onPickSlot: noop,
@@ -103,8 +105,8 @@ const render = (
103
105
  renderQuietly(createElement(CalendarWorkspace, workspaceProps(view, events)));
104
106
 
105
107
  describe("the zoom the address names", () => {
106
- it("draws the grid for the week and the day", () => {
107
- for (const view of ["week", "day"] as const) {
108
+ it("draws the grid for the month, the week and the day", () => {
109
+ for (const view of ["month", "week", "day"] as const) {
108
110
  const html = render(view);
109
111
  assert.ok(
110
112
  html.includes("Roadmap review"),
@@ -122,15 +124,10 @@ describe("the zoom the address names", () => {
122
124
  });
123
125
 
124
126
  it("says so for a zoom that is not drawn yet", () => {
125
- for (const view of ["year", "month"] as const) {
126
- const html = render(view);
127
- assert.ok(
128
- html.includes(`calendar-placeholder-${view}`),
129
- `${view} rendered no placeholder`,
130
- );
131
- assert.ok(html.includes("Not built yet"));
132
- assert.ok(html.includes("Week, Day and Agenda work now."));
133
- }
127
+ const html = render("year");
128
+ assert.ok(html.includes("calendar-placeholder-year"));
129
+ assert.ok(html.includes("Not built yet"));
130
+ assert.ok(html.includes("Month, Week, Day and Agenda work now."));
134
131
  });
135
132
  });
136
133
 
@@ -143,7 +140,7 @@ describe("the toolbar", () => {
143
140
  });
144
141
 
145
142
  it("offers back, forward and today at a zoom that draws no grid", () => {
146
- const html = render("month");
143
+ const html = render("year");
147
144
  assert.ok(html.includes('aria-label="Previous"'));
148
145
  assert.ok(html.includes('aria-label="Next"'));
149
146
  assert.ok(html.includes(">Today<"));
@@ -10,7 +10,6 @@ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
10
10
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
11
11
  import { useMarkAsRead } from "@/hooks/useMarkAsRead";
12
12
  import { useIsDesktop } from "@/hooks/useMediaQuery";
13
- import { useSwipeNavigation } from "@/hooks/useSwipeNavigation";
14
13
  import { useToggleStar } from "@/hooks/useToggleStar";
15
14
  import { type ReplyMode, useOpenReply, useReplySurface } from "@/routing";
16
15
  import { AuthenticityBanner } from "./AuthenticityBanner";
@@ -98,10 +97,6 @@ export const ConversationView = ({
98
97
  listOnScreen = false,
99
98
  }: ConversationViewProps) => {
100
99
  const isDesktop = useIsDesktop();
101
- const { handlers: swipeHandlers } = useSwipeNavigation({
102
- onSwipeLeft: onSwipeNext,
103
- onSwipeRight: onSwipePrevious,
104
- });
105
100
  const { accountId: mailboxAccountId } = useMailboxAccount(mailboxId);
106
101
  const {
107
102
  data: messagesResponse,
@@ -482,8 +477,7 @@ export const ConversationView = ({
482
477
  // Mobile: the kit MobileReadingPane owns the chrome — a top app bar with
483
478
  // back, the email subject and the intelligence toggle (the host owns the
484
479
  // drawer). Each expanded card owns its per-message action bar; there is no
485
- // thread-level reply footer. The phishing warning leads the scroll content;
486
- // horizontal swipe between messages is wired through touchHandlers (#693).
480
+ // thread-level reply footer. The phishing warning leads the scroll content.
487
481
  if (!isDesktop) {
488
482
  return (
489
483
  <MobileReadingPane
@@ -491,7 +485,8 @@ export const ConversationView = ({
491
485
  onBack={onBack ?? (() => undefined)}
492
486
  intelligenceOpen={mobileIntelligenceOpen}
493
487
  onToggleIntelligence={onOpenIntelligence}
494
- touchHandlers={swipeHandlers}
488
+ onSwipeNext={onSwipeNext}
489
+ onSwipePrevious={onSwipePrevious}
495
490
  >
496
491
  {authenticity?.dkimMismatch && (
497
492
  <AuthenticityBanner
@@ -188,10 +188,8 @@ const MessageRowComponent = ({
188
188
  }, [prefetchMessage, onFocusRow, messageId]);
189
189
 
190
190
  // Listbox semantics + roving tabindex, shared by both densities. Merged (not
191
- // spread) with the mobile long-press props: react-aria's pressProps carries
192
- // its own onClick for its internal press bookkeeping, and a plain object
193
- // spread would silently drop whichever onClick landed second instead of
194
- // running both.
191
+ // spread) with the mobile long-press props, so the gesture's pointer
192
+ // handlers chain with the row's own instead of replacing them.
195
193
  const interactionProps = mergeProps(
196
194
  {
197
195
  "data-list-row": "",
@@ -117,6 +117,7 @@ const press = (
117
117
  const event = new PointerEvent("pointerdown", {
118
118
  bubbles: true,
119
119
  cancelable: true,
120
+ buttons: 1,
120
121
  pointerId: 1,
121
122
  clientX: 10,
122
123
  clientY: 10,
@@ -47,14 +47,27 @@ describe("the window a view covers", () => {
47
47
  assert.equal(day(window.to), "2026-06-11");
48
48
  });
49
49
 
50
- it("takes the calendar month, ending on the first of the next", () => {
51
- const window = calendarWindow("month", "2026-06-10");
52
- assert.equal(day(window.from), "2026-06-01");
53
- assert.equal(day(window.to), "2026-07-01");
50
+ it("takes every week the month touches, so the grid's edge days are read", () => {
51
+ const window = calendarWindow("month", "2026-07-10");
52
+ assert.equal(day(window.from), "2026-06-29");
53
+ assert.equal(day(window.to), "2026-08-03");
54
+ });
55
+
56
+ it("takes exactly the month when it starts on a Monday and ends on a Sunday", () => {
57
+ const window = calendarWindow("month", "2027-02-14");
58
+ assert.equal(day(window.from), "2027-02-01");
59
+ assert.equal(day(window.to), "2027-03-01");
60
+ });
61
+
62
+ it("gives every day of one month the same window", () => {
63
+ const first = calendarWindow("month", "2026-06-01");
64
+ for (const date of ["2026-06-15", "2026-06-30"]) {
65
+ assert.deepEqual(calendarWindow("month", date), first);
66
+ }
54
67
  });
55
68
 
56
69
  it("rolls a December month into the next year", () => {
57
- assert.equal(day(calendarWindow("month", "2026-12-31").to), "2027-01-01");
70
+ assert.equal(day(calendarWindow("month", "2026-12-31").to), "2027-01-04");
58
71
  });
59
72
 
60
73
  it("takes the calendar year", () => {
@@ -154,7 +154,7 @@ function bounds(view: CalendarViewId, date: string): [string, string] {
154
154
  const first = `${year}-${pad(month)}-01`;
155
155
  const next =
156
156
  month === 12 ? `${year + 1}-01-01` : `${year}-${pad(month + 1)}-01`;
157
- return [first, next];
157
+ return [weekStart(first), addDays(weekStart(addDays(next, -1)), 7)];
158
158
  }
159
159
  if (view === "day") return [date, addDays(date, 1)];
160
160
  const start = weekStart(date);
@@ -85,14 +85,14 @@ describe("canonicalCalendarParams", () => {
85
85
  });
86
86
 
87
87
  describe("calendarViewMountsGrid", () => {
88
- it("draws the week and the day", () => {
88
+ it("draws the month, the week and the day", () => {
89
+ assert.equal(calendarViewMountsGrid("month"), true);
89
90
  assert.equal(calendarViewMountsGrid("week"), true);
90
91
  assert.equal(calendarViewMountsGrid("day"), true);
91
92
  });
92
93
 
93
- it("leaves the agenda to the strip and the other two to a later stage", () => {
94
+ it("leaves the agenda to the strip and the year to a later stage", () => {
94
95
  assert.equal(calendarViewMountsGrid("year"), false);
95
- assert.equal(calendarViewMountsGrid("month"), false);
96
96
  assert.equal(calendarViewMountsGrid("agenda"), false);
97
97
  });
98
98
  });
@@ -32,11 +32,11 @@ const calendarViewSchema = z.enum(calendarViews);
32
32
  export const DEFAULT_CALENDAR_VIEW: CalendarViewId = "week";
33
33
 
34
34
  /**
35
- * The views the grid draws. Year and month are named surfaces that arrive in
36
- * later stages, and they say so on screen rather than matching a route that
37
- * renders nothing.
35
+ * The views the grid draws. The year is a named surface that arrives in a later
36
+ * stage, and it says so on screen rather than matching a route that renders
37
+ * nothing.
38
38
  */
39
- const GRID_VIEWS = new Set<CalendarViewId>(["week", "day"]);
39
+ const GRID_VIEWS = new Set<CalendarViewId>(["month", "week", "day"]);
40
40
 
41
41
  export const calendarViewMountsGrid = (view: CalendarViewId): boolean =>
42
42
  GRID_VIEWS.has(view);
@@ -5,6 +5,7 @@ const touch = (target: HTMLElement, type: string) => {
5
5
  target.dispatchEvent(
6
6
  new PointerEvent(type, {
7
7
  bubbles: true,
8
+ buttons: type === "pointerup" ? 0 : 1,
8
9
  pointerType: "touch",
9
10
  pointerId: 1,
10
11
  isPrimary: true,
@@ -93,6 +93,8 @@ function CalendarView({ isPhone }: { isPhone: boolean }) {
93
93
  openEvent,
94
94
  openComposer,
95
95
  changeSuggestionFirst,
96
+ zoomToDay,
97
+ dayHref,
96
98
  } = useCalendarNavigation();
97
99
  const openedEvent = useOpenCalendarEvent();
98
100
  const isWriting = useIsWritingEvent();
@@ -147,6 +149,8 @@ function CalendarView({ isPhone }: { isPhone: boolean }) {
147
149
  onChangeView={goToView}
148
150
  onToday={goToToday}
149
151
  onStep={step}
152
+ dayHref={dayHref}
153
+ onZoomDay={zoomToDay}
150
154
  onChangeDensity={changeDensity}
151
155
  onSelectEvent={selectEvent}
152
156
  onPickSlot={pickSlot}
@@ -1,5 +1,10 @@
1
1
  import type { CalendarViewId } from "@remit/ui";
2
- import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
2
+ import {
3
+ useNavigate,
4
+ useParams,
5
+ useRouter,
6
+ useSearch,
7
+ } from "@tanstack/react-router";
3
8
  import { useCallback, useMemo } from "react";
4
9
  import {
5
10
  canonicalCalendarParams,
@@ -101,6 +106,7 @@ export interface CalendarNavigation {
101
106
  setAnchorDate: (date: string) => void;
102
107
  /** Out of the list and into the grid for one day. */
103
108
  zoomToDay: (date: string) => void;
109
+ dayHref: (date: string) => string;
104
110
  /**
105
111
  * One occurrence names the series it belongs to and the instance under it;
106
112
  * a resource that does not recur names only itself.
@@ -121,6 +127,7 @@ export interface CalendarNavigation {
121
127
  */
122
128
  export function useCalendarNavigation(): CalendarNavigation {
123
129
  const navigate = useNavigate();
130
+ const router = useRouter();
124
131
  const retainPanels = useRetainOpenPanels();
125
132
  const { view, date } = useCalendarAddress();
126
133
 
@@ -136,6 +143,17 @@ export function useCalendarNavigation(): CalendarNavigation {
136
143
  [navigate, retainPanels],
137
144
  );
138
145
 
146
+ const dayHref = useCallback(
147
+ (next: string) =>
148
+ router.buildLocation({
149
+ to: VIEW_ROUTE,
150
+ params: { view: "day", date: next },
151
+ search: true,
152
+ hash: retainPanels,
153
+ }).href,
154
+ [router, retainPanels],
155
+ );
156
+
139
157
  return useMemo(
140
158
  () => ({
141
159
  goToView: (next: CalendarViewId) => goTo({ view: next, date }),
@@ -150,7 +168,8 @@ export function useCalendarNavigation(): CalendarNavigation {
150
168
  hash: retainPanels,
151
169
  replace: true,
152
170
  }),
153
- zoomToDay: (next: string) => goTo({ view: "day", date: next }),
171
+ zoomToDay: (next: string) => navigate({ href: dayHref(next) }),
172
+ dayHref,
154
173
  openEvent: (calendarObjectId: string, recurrenceId?: string) =>
155
174
  recurrenceId === undefined || recurrenceId === ""
156
175
  ? navigate({
@@ -181,7 +200,7 @@ export function useCalendarNavigation(): CalendarNavigation {
181
200
  hash: retainPanels,
182
201
  }),
183
202
  }),
184
- [goTo, navigate, retainPanels, view, date],
203
+ [goTo, navigate, dayHref, retainPanels, view, date],
185
204
  );
186
205
  }
187
206
 
@@ -1,35 +0,0 @@
1
- import assert from "node:assert";
2
- import { describe, test } from "node:test";
3
- import { resolveSwipe } from "./useSwipeNavigation.js";
4
-
5
- const THRESHOLD = 60;
6
-
7
- describe("resolveSwipe", () => {
8
- test("classifies a left swipe (next) when dragging right→left", () => {
9
- assert.strictEqual(resolveSwipe(-80, 0, THRESHOLD).direction, "left");
10
- });
11
-
12
- test("classifies a right swipe (previous) when dragging left→right", () => {
13
- assert.strictEqual(resolveSwipe(80, 0, THRESHOLD).direction, "right");
14
- });
15
-
16
- test("ignores a gesture shorter than the threshold", () => {
17
- assert.strictEqual(resolveSwipe(-40, 0, THRESHOLD).direction, null);
18
- assert.strictEqual(resolveSwipe(59, 0, THRESHOLD).direction, null);
19
- });
20
-
21
- test("ignores a mostly-vertical gesture so it never hijacks scroll", () => {
22
- // Long horizontal travel but even longer vertical travel: this is a scroll.
23
- assert.strictEqual(resolveSwipe(70, 200, THRESHOLD).direction, null);
24
- assert.strictEqual(resolveSwipe(-70, -200, THRESHOLD).direction, null);
25
- });
26
-
27
- test("accepts a horizontal swipe with mild vertical drift", () => {
28
- assert.strictEqual(resolveSwipe(-100, 20, THRESHOLD).direction, "left");
29
- });
30
-
31
- test("rejects a diagonal gesture that is not horizontally dominant", () => {
32
- // dx 70, dy 60: not 1.5x dominant, so treated as ambiguous → no swipe.
33
- assert.strictEqual(resolveSwipe(70, 60, THRESHOLD).direction, null);
34
- });
35
- });
@@ -1,99 +0,0 @@
1
- import { useCallback, useRef } from "react";
2
-
3
- export type SwipeDirection = "left" | "right";
4
-
5
- interface UseSwipeNavigationOptions {
6
- /**
7
- * Swipe left (content advances, finger moves right→left): go to the NEXT
8
- * item — the platform convention for paging through content (photos, mail).
9
- */
10
- onSwipeLeft?: () => void;
11
- /** Swipe right: go to the PREVIOUS item. */
12
- onSwipeRight?: () => void;
13
- /** Minimum horizontal travel (px) before a swipe registers. */
14
- thresholdPx?: number;
15
- }
16
-
17
- interface SwipeHandlers {
18
- onPointerDown: (e: React.PointerEvent) => void;
19
- onPointerMove: (e: React.PointerEvent) => void;
20
- onPointerUp: (e: React.PointerEvent) => void;
21
- onPointerCancel: () => void;
22
- }
23
-
24
- const DEFAULT_THRESHOLD = 60;
25
- /**
26
- * A gesture only counts as a horizontal swipe when its horizontal travel
27
- * dominates the vertical. Anything more vertical than this is the user
28
- * scrolling the message body, and we leave it alone.
29
- */
30
- const HORIZONTAL_DOMINANCE = 1.5;
31
-
32
- export interface SwipeResolution {
33
- direction: SwipeDirection | null;
34
- }
35
-
36
- /**
37
- * Pure swipe classifier. Returns the swipe direction, or `null` when the
38
- * gesture is too short or too vertical to count as a horizontal swipe (so it
39
- * never hijacks vertical scrolling).
40
- */
41
- export const resolveSwipe = (
42
- dx: number,
43
- dy: number,
44
- thresholdPx: number,
45
- ): SwipeResolution => {
46
- const absX = Math.abs(dx);
47
- const absY = Math.abs(dy);
48
- if (absX < thresholdPx) return { direction: null };
49
- if (absX < absY * HORIZONTAL_DOMINANCE) return { direction: null };
50
- return { direction: dx < 0 ? "left" : "right" };
51
- };
52
-
53
- /**
54
- * Detects horizontal swipe gestures from pointer events and routes them to
55
- * next/previous callbacks. Only reacts to touch/pen pointers so mouse drags on
56
- * desktop never trigger navigation. Vertical scroll is left untouched: a
57
- * gesture must travel mostly sideways to register.
58
- */
59
- export const useSwipeNavigation = ({
60
- onSwipeLeft,
61
- onSwipeRight,
62
- thresholdPx = DEFAULT_THRESHOLD,
63
- }: UseSwipeNavigationOptions): { handlers: SwipeHandlers } => {
64
- const startRef = useRef<{ x: number; y: number } | null>(null);
65
-
66
- const onPointerDown = useCallback((e: React.PointerEvent) => {
67
- if (e.pointerType === "mouse") {
68
- startRef.current = null;
69
- return;
70
- }
71
- startRef.current = { x: e.clientX, y: e.clientY };
72
- }, []);
73
-
74
- const onPointerMove = useCallback(() => {}, []);
75
-
76
- const onPointerUp = useCallback(
77
- (e: React.PointerEvent) => {
78
- const start = startRef.current;
79
- startRef.current = null;
80
- if (!start) return;
81
- const { direction } = resolveSwipe(
82
- e.clientX - start.x,
83
- e.clientY - start.y,
84
- thresholdPx,
85
- );
86
- if (direction === "left") onSwipeLeft?.();
87
- if (direction === "right") onSwipeRight?.();
88
- },
89
- [onSwipeLeft, onSwipeRight, thresholdPx],
90
- );
91
-
92
- const onPointerCancel = useCallback(() => {
93
- startRef.current = null;
94
- }, []);
95
-
96
- return {
97
- handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },
98
- };
99
- };