@remit/web-client 0.0.28 → 0.0.30

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.28",
3
+ "version": "0.0.30",
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": {
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
+ import { AuthTokenError } from "./better-auth-config";
3
4
  import { type AuthProvider, noneAuthProvider } from "./provider";
4
5
 
5
6
  type RequestFn = (req: Request) => Promise<Request>;
@@ -47,7 +48,28 @@ describe("installAuthInterceptor", () => {
47
48
  assert.equal(out.headers.get("Authorization"), "Bearer ID-TOKEN-123");
48
49
  });
49
50
 
50
- test("omits Authorization header when the provider returns no token", async () => {
51
+ test("abandons the request when the token cannot be minted — never sends it unauthenticated", async () => {
52
+ const mod = await loadInterceptor();
53
+ mod.installAuthInterceptor(
54
+ providerWithToken(async () => {
55
+ throw new AuthTokenError("Could not mint a session token: 429", 429);
56
+ }),
57
+ );
58
+ const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
59
+ assert.ok(fn);
60
+ const req = new Request("https://api.example.com/thing");
61
+ await assert.rejects(
62
+ () => fn(req),
63
+ (error: unknown) => {
64
+ assert.ok(error instanceof AuthTokenError);
65
+ assert.equal(error.status, 429);
66
+ return true;
67
+ },
68
+ );
69
+ assert.equal(req.headers.get("Authorization"), null);
70
+ });
71
+
72
+ test("omits Authorization header when the deployment presents no identity", async () => {
51
73
  const mod = await loadInterceptor();
52
74
  mod.installAuthInterceptor(providerWithToken(async () => null));
53
75
  const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
@@ -7,6 +7,11 @@ let installed = false;
7
7
  * Attach the composed provider's bearer token to every API request. Called once
8
8
  * from `mountApp` with the app's chosen provider, so the token source is the
9
9
  * same `AuthProvider` the shell and screens read — no separate seam.
10
+ *
11
+ * A provider that cannot produce a token throws, and that throw is left to
12
+ * propagate: the request is abandoned rather than sent without an Authorization
13
+ * header. Only the deployments that present no identity at all resolve to null,
14
+ * and for those an unauthenticated request is the intended one.
10
15
  */
11
16
  export const installAuthInterceptor = (authProvider: AuthProvider): void => {
12
17
  if (installed) return;
@@ -51,11 +51,32 @@ const decodeExp = (token: string): number => {
51
51
  }
52
52
  };
53
53
 
54
- const requestToken = async (): Promise<string | null> => {
54
+ /**
55
+ * A session exists but no bearer token could be produced for it. Distinct from
56
+ * "signed out": there is nothing to fall back to, so the request that needed the
57
+ * token must not be sent. Carries the mint's HTTP status where there was one, so
58
+ * the shared classifier reads it like any other API failure.
59
+ */
60
+ export class AuthTokenError extends Error {
61
+ readonly status: number | undefined;
62
+
63
+ constructor(message: string, status?: number) {
64
+ super(message);
65
+ this.name = "AuthTokenError";
66
+ this.status = status;
67
+ }
68
+ }
69
+
70
+ const requestToken = async (): Promise<string> => {
55
71
  const res = await taggedFetch("/api/auth/token", {
56
72
  credentials: "include",
57
73
  });
58
- if (!res.ok) return null;
74
+ if (!res.ok) {
75
+ throw new AuthTokenError(
76
+ `Could not mint a session token: ${res.status} ${res.statusText}`,
77
+ res.status,
78
+ );
79
+ }
59
80
  const body: unknown = await res.json();
60
81
  if (
61
82
  body &&
@@ -65,7 +86,29 @@ const requestToken = async (): Promise<string | null> => {
65
86
  ) {
66
87
  return (body as { token: string }).token;
67
88
  }
68
- return null;
89
+ throw new AuthTokenError("The token endpoint returned no token");
90
+ };
91
+
92
+ let inFlight: Promise<string> | null = null;
93
+
94
+ /**
95
+ * One mint at a time. A cold load renders many screens at once and each of their
96
+ * requests needs the same token; without this every one of them mints its own,
97
+ * and the burst is large enough to spend the auth tier's rate-limit budget. The
98
+ * slot is released once the mint settles, so a failure is not replayed to later
99
+ * callers — they mint again.
100
+ */
101
+ const mint = (): Promise<string> => {
102
+ if (inFlight) return inFlight;
103
+ inFlight = requestToken()
104
+ .then((token) => {
105
+ cached = { value: token, expiresAt: decodeExp(token) };
106
+ return token;
107
+ })
108
+ .finally(() => {
109
+ inFlight = null;
110
+ });
111
+ return inFlight;
69
112
  };
70
113
 
71
114
  /**
@@ -73,14 +116,16 @@ const requestToken = async (): Promise<string | null> => {
73
116
  * when the cached token is missing or within the skew window of expiry. The API
74
117
  * interceptor attaches it as a Bearer token; the backend verifies it against the
75
118
  * JWKS.
119
+ *
120
+ * Never resolves to null: a failed mint throws. Returning null would put the
121
+ * request on the wire without an Authorization header, and the backend answers
122
+ * that with a 401 that names a missing token rather than the mint that failed.
76
123
  */
77
- export const fetchBetterAuthToken = async (): Promise<string | null> => {
124
+ export const fetchBetterAuthToken = async (): Promise<string> => {
78
125
  if (cached && cached.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds()) {
79
126
  return cached.value;
80
127
  }
81
- const token = await requestToken();
82
- cached = token ? { value: token, expiresAt: decodeExp(token) } : null;
83
- return token;
128
+ return mint();
84
129
  };
85
130
 
86
131
  export const resetBetterAuthTokenCache = (): void => {
@@ -0,0 +1,152 @@
1
+ import assert from "node:assert";
2
+ import { afterEach, describe, test } from "node:test";
3
+ import {
4
+ AuthTokenError,
5
+ fetchBetterAuthToken,
6
+ resetBetterAuthTokenCache,
7
+ } from "./better-auth-config";
8
+
9
+ const base64url = (value: string): string =>
10
+ Buffer.from(value, "utf8")
11
+ .toString("base64")
12
+ .replace(/\+/g, "-")
13
+ .replace(/\//g, "_")
14
+ .replace(/=+$/, "");
15
+
16
+ /** A token shaped like the one better-auth mints, valid for an hour. */
17
+ const jwt = (label: string): string =>
18
+ `header.${base64url(
19
+ JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, label }),
20
+ )}.signature`;
21
+
22
+ const realFetch = globalThis.fetch;
23
+
24
+ interface Stub {
25
+ calls: number;
26
+ release: () => void;
27
+ }
28
+
29
+ /** Stand in for the token endpoint, holding every request open until released. */
30
+ const stubTokenEndpoint = (respond: (call: number) => Response): Stub => {
31
+ let open: () => void = () => {};
32
+ const gate = new Promise<void>((resolve) => {
33
+ open = resolve;
34
+ });
35
+ const stub: Stub = { calls: 0, release: () => open() };
36
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
37
+ assert.match(String(input), /\/api\/auth\/token$/);
38
+ stub.calls += 1;
39
+ const call = stub.calls;
40
+ await gate;
41
+ return respond(call);
42
+ }) as typeof fetch;
43
+ return stub;
44
+ };
45
+
46
+ const tokenResponse = (token: string): Response =>
47
+ new Response(JSON.stringify({ token }), {
48
+ status: 200,
49
+ headers: { "Content-Type": "application/json" },
50
+ });
51
+
52
+ afterEach(() => {
53
+ globalThis.fetch = realFetch;
54
+ resetBetterAuthTokenCache();
55
+ });
56
+
57
+ describe("fetchBetterAuthToken", () => {
58
+ test("concurrent callers share one mint instead of each firing their own", async () => {
59
+ const stub = stubTokenEndpoint((call) => tokenResponse(jwt(`t${call}`)));
60
+
61
+ const pending = Array.from({ length: 12 }, () => fetchBetterAuthToken());
62
+ stub.release();
63
+ const tokens = await Promise.all(pending);
64
+
65
+ assert.equal(stub.calls, 1);
66
+ assert.equal(new Set(tokens).size, 1);
67
+ });
68
+
69
+ test("a token minted once is reused from cache, without a second request", async () => {
70
+ const stub = stubTokenEndpoint(() => tokenResponse(jwt("cached")));
71
+ stub.release();
72
+
73
+ const first = await fetchBetterAuthToken();
74
+ const second = await fetchBetterAuthToken();
75
+
76
+ assert.equal(stub.calls, 1);
77
+ assert.equal(second, first);
78
+ });
79
+
80
+ test("a rejected mint throws with its status rather than resolving to null", async () => {
81
+ const stub = stubTokenEndpoint(
82
+ () => new Response("", { status: 429, statusText: "Too Many Requests" }),
83
+ );
84
+ stub.release();
85
+
86
+ await assert.rejects(
87
+ () => fetchBetterAuthToken(),
88
+ (error: unknown) => {
89
+ assert.ok(error instanceof AuthTokenError);
90
+ assert.equal(error.status, 429);
91
+ return true;
92
+ },
93
+ );
94
+ });
95
+
96
+ test("a response carrying no token throws rather than resolving to null", async () => {
97
+ const stub = stubTokenEndpoint(
98
+ () =>
99
+ new Response(JSON.stringify({}), {
100
+ status: 200,
101
+ headers: { "Content-Type": "application/json" },
102
+ }),
103
+ );
104
+ stub.release();
105
+
106
+ await assert.rejects(() => fetchBetterAuthToken(), AuthTokenError);
107
+ });
108
+
109
+ test("every caller sharing a failed mint sees the failure", async () => {
110
+ const stub = stubTokenEndpoint(
111
+ () => new Response("", { status: 429, statusText: "Too Many Requests" }),
112
+ );
113
+
114
+ const pending = Array.from({ length: 5 }, () =>
115
+ fetchBetterAuthToken().then(
116
+ () => "resolved",
117
+ (error: unknown) =>
118
+ error instanceof AuthTokenError ? "threw" : "other",
119
+ ),
120
+ );
121
+ stub.release();
122
+ const outcomes = await Promise.all(pending);
123
+
124
+ assert.equal(stub.calls, 1);
125
+ assert.deepEqual(new Set(outcomes), new Set(["threw"]));
126
+ });
127
+
128
+ test("a failed mint is not replayed — the next caller mints again", async () => {
129
+ const failing = stubTokenEndpoint(() => new Response("", { status: 500 }));
130
+ failing.release();
131
+ await assert.rejects(() => fetchBetterAuthToken());
132
+
133
+ const succeeding = stubTokenEndpoint(() =>
134
+ tokenResponse(jwt("after-failure")),
135
+ );
136
+ succeeding.release();
137
+
138
+ assert.ok(await fetchBetterAuthToken());
139
+ assert.equal(succeeding.calls, 1);
140
+ });
141
+
142
+ test("a network failure propagates instead of yielding a tokenless request", async () => {
143
+ globalThis.fetch = (async () => {
144
+ throw new TypeError("Failed to fetch");
145
+ }) as typeof fetch;
146
+
147
+ await assert.rejects(
148
+ () => fetchBetterAuthToken(),
149
+ /could not be completed|Failed to fetch/i,
150
+ );
151
+ });
152
+ });
@@ -22,9 +22,14 @@ export interface AuthProvider {
22
22
  /** Called once at boot, before render, to initialize the identity SDK. */
23
23
  configure(): void;
24
24
  /**
25
- * The current session's bearer token, or `null` when signed out. The API
26
- * interceptor and the raw content fetch read it through here, so the identity
27
- * SDK stays inside the provider.
25
+ * The current session's bearer token. The API interceptor and the raw content
26
+ * fetch read it through here, so the identity SDK stays inside the provider.
27
+ *
28
+ * `null` means this deployment has no identity to present — no identity system
29
+ * is composed, or none is configured — and the request is expected to travel
30
+ * unauthenticated. It never means the token could not be produced: a provider
31
+ * that holds a session and fails to mint a token throws, because a request
32
+ * that cannot be authenticated must not be sent.
28
33
  */
29
34
  getToken(): Promise<string | null>;
30
35
  /** Drop any cached token so the next `getToken` re-mints. */
@@ -7,14 +7,15 @@ import {
7
7
  comfortableRowClass,
8
8
  compactRowClass,
9
9
  type Density,
10
+ mergeProps,
10
11
  type SenderTrustLevel,
11
12
  type ThreadRowData,
13
+ useLongPress,
12
14
  } from "@remit/ui";
13
15
  import { useQueryClient } from "@tanstack/react-query";
14
16
  import { Link } from "@tanstack/react-router";
15
17
  import { Check } from "lucide-react";
16
18
  import { type MouseEvent, memo, useCallback } from "react";
17
- import { useLongPress } from "@/hooks/useLongPress";
18
19
  import type { SelectionModifiers } from "@/hooks/useSelection";
19
20
  import { toDisplayCategory } from "@/lib/display-category";
20
21
  import { formatEmailDate } from "@/lib/format";
@@ -125,16 +126,27 @@ const MessageListItemComponent = ({
125
126
 
126
127
  // Desktop mouse selection semantics. Plain click falls through to the Link's
127
128
  // navigation; shift / cmd / ctrl click is routed to selection and the
128
- // navigation is suppressed. On mobile this is a no-op so taps still open.
129
+ // navigation is suppressed.
129
130
  //
130
131
  // A modified click must preventDefault: the router skips navigation for any
131
132
  // modified click and leaves the anchor's own default in place, which in a
132
133
  // browser means shift-click opens a new window and cmd-click a new tab.
133
134
  // Shift-click also drags a native text selection across the rows it spans,
134
135
  // so drop it — the row highlight is the selection the user asked for.
136
+ //
137
+ // On mobile, once selection mode is active (#92) a plain tap toggles the
138
+ // row instead of opening it — the same tap-to-toggle contract every
139
+ // reference mail client uses once you're mid-selection. Outside selection
140
+ // mode a short tap still falls through to the Link's native navigation.
135
141
  const handleRowClick = useCallback(
136
142
  (e: MouseEvent) => {
137
- if (!isDesktop) return;
143
+ if (!isDesktop) {
144
+ if (!isMultiSelectMode) return;
145
+ e.preventDefault();
146
+ e.stopPropagation();
147
+ onToggleCheck(messageId);
148
+ return;
149
+ }
138
150
  const modifiers = {
139
151
  shiftKey: e.shiftKey,
140
152
  metaKey: e.metaKey,
@@ -148,7 +160,7 @@ const MessageListItemComponent = ({
148
160
  window.getSelection()?.removeAllRanges();
149
161
  }
150
162
  },
151
- [isDesktop, onRowSelect, messageId],
163
+ [isDesktop, isMultiSelectMode, onToggleCheck, onRowSelect, messageId],
152
164
  );
153
165
 
154
166
  // Shift-click starts a native text selection on mousedown; suppressing it
@@ -165,9 +177,10 @@ const MessageListItemComponent = ({
165
177
  onLongPress?.(messageId);
166
178
  }, [onLongPress, messageId]);
167
179
 
168
- const longPressHandlers = useLongPress({
180
+ const { longPressProps } = useLongPress({
169
181
  onLongPress: handleLongPress,
170
182
  delayMs: 500,
183
+ accessibilityDescription: isChecked ? "Deselect message" : "Select message",
171
184
  });
172
185
 
173
186
  // Intent-based prefetch: by the time the user clicks, the body is in
@@ -185,18 +198,25 @@ const MessageListItemComponent = ({
185
198
  onFocusRow?.(messageId);
186
199
  }, [prefetchMessage, onFocusRow, messageId]);
187
200
 
188
- // Listbox semantics + roving tabindex, shared by both densities.
189
- const rowInteractionProps = {
190
- "data-message-row": true,
191
- "data-message-id": messageId,
192
- role: "option" as const,
193
- "aria-selected": isChecked,
194
- tabIndex: isTabStop ? 0 : -1,
195
- onClick: handleRowClick,
196
- onMouseDown: handleRowMouseDown,
197
- onMouseEnter: prefetchMessage,
198
- onFocus: handleRowFocus,
199
- };
201
+ // Listbox semantics + roving tabindex, shared by both densities. Merged
202
+ // (not spread) with the mobile long-press props: react-aria's pressProps
203
+ // carries its own onClick for its internal press bookkeeping, and a plain
204
+ // object spread would silently drop whichever onClick landed second
205
+ // instead of running both.
206
+ const rowInteractionProps = mergeProps(
207
+ {
208
+ "data-message-row": true,
209
+ "data-message-id": messageId,
210
+ role: "option" as const,
211
+ "aria-selected": isChecked,
212
+ tabIndex: isTabStop ? 0 : -1,
213
+ onClick: handleRowClick,
214
+ onMouseDown: handleRowMouseDown,
215
+ onMouseEnter: prefetchMessage,
216
+ onFocus: handleRowFocus,
217
+ },
218
+ isDesktop ? {} : longPressProps,
219
+ );
200
220
 
201
221
  const unread = !thread.isRead;
202
222
 
@@ -210,12 +230,16 @@ const MessageListItemComponent = ({
210
230
  selectedMessageId: thread.messageId,
211
231
  })}
212
232
  {...rowInteractionProps}
213
- {...(!isDesktop && longPressHandlers.handlers)}
214
233
  className={cn(
215
234
  compactRowClass({ active: isSelected, focused: isFocused }),
216
235
  "outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
217
236
  isChecked && "bg-accent-soft",
218
- !isDesktop && "min-h-11",
237
+ // Long-press enters selection mode; without these, Android Chrome
238
+ // opens the link context menu / starts text selection and iOS
239
+ // Safari fires the callout, racing the app's handler. react-aria
240
+ // suppresses contextmenu/text-selection but not iOS's callout —
241
+ // it fires no cancelable event, so CSS is the only lever.
242
+ !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
219
243
  )}
220
244
  >
221
245
  <CompactRowBody thread={rowData} />
@@ -235,13 +259,13 @@ const MessageListItemComponent = ({
235
259
  selectedMessageId: thread.messageId,
236
260
  })}
237
261
  {...rowInteractionProps}
238
- {...(!isDesktop && longPressHandlers.handlers)}
239
262
  className={cn(
240
263
  "group",
241
264
  comfortableRowClass({ active: isSelected, focused: isFocused }),
242
265
  "outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
243
266
  isChecked && "bg-accent-soft",
244
- !isDesktop && "min-h-11",
267
+ // See the compact-density Link above for why both are required.
268
+ !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
245
269
  )}
246
270
  >
247
271
  {/* Absolute unread dot — 6px gutter from the left pane hairline */}
@@ -3,8 +3,7 @@ import { beforeEach, describe, test } from "node:test";
3
3
 
4
4
  /**
5
5
  * Unit test for useVisualViewport hook logic.
6
- * Tests the core behaviour without React — mirrors the pattern used
7
- * by useLongPress.test.ts in this project.
6
+ * Tests the core behaviour without React.
8
7
  */
9
8
 
10
9
  const KEYBOARD_THRESHOLD = 150;
@@ -1,291 +0,0 @@
1
- import assert from "node:assert";
2
- import { beforeEach, describe, mock, test } from "node:test";
3
-
4
- /**
5
- * Test harness for useLongPress. Since we can't use React Testing Library,
6
- * we test the handler logic directly by simulating pointer events and timers.
7
- */
8
-
9
- interface MockPointerEvent {
10
- clientX: number;
11
- clientY: number;
12
- }
13
-
14
- const createPointerEvent = (x: number, y: number): MockPointerEvent => ({
15
- clientX: x,
16
- clientY: y,
17
- });
18
-
19
- /**
20
- * Minimal implementation of useLongPress logic for testing.
21
- * Matches the hook's behavior without React dependencies.
22
- */
23
- class LongPressSimulator {
24
- private timer: ReturnType<typeof setTimeout> | null = null;
25
- private startPos: { x: number; y: number } | null = null;
26
- private readonly MOVEMENT_THRESHOLD = 8;
27
-
28
- constructor(
29
- private readonly onLongPress: () => void,
30
- private readonly delayMs: number = 500,
31
- ) {}
32
-
33
- onPointerDown(e: MockPointerEvent): void {
34
- this.clearTimer();
35
- this.startPos = { x: e.clientX, y: e.clientY };
36
- this.timer = setTimeout(() => {
37
- this.onLongPress();
38
- this.clearTimer();
39
- }, this.delayMs);
40
- }
41
-
42
- onPointerMove(e: MockPointerEvent): void {
43
- if (!this.startPos) return;
44
-
45
- const dx = e.clientX - this.startPos.x;
46
- const dy = e.clientY - this.startPos.y;
47
- const distance = Math.sqrt(dx * dx + dy * dy);
48
-
49
- if (distance > this.MOVEMENT_THRESHOLD) {
50
- this.clearTimer();
51
- }
52
- }
53
-
54
- onPointerUp(): void {
55
- this.clearTimer();
56
- }
57
-
58
- onPointerCancel(): void {
59
- this.clearTimer();
60
- }
61
-
62
- private clearTimer(): void {
63
- if (this.timer) {
64
- clearTimeout(this.timer);
65
- this.timer = null;
66
- }
67
- this.startPos = null;
68
- }
69
-
70
- /** Test helper: check if timer is active */
71
- hasActiveTimer(): boolean {
72
- return this.timer !== null;
73
- }
74
-
75
- /** Test helper: cleanup */
76
- cleanup(): void {
77
- this.clearTimer();
78
- }
79
- }
80
-
81
- describe("useLongPress", () => {
82
- beforeEach(() => {
83
- // Reset any timers between tests
84
- mock.restoreAll();
85
- });
86
-
87
- test("fires callback after delay with no movement", async () => {
88
- const onLongPress = mock.fn();
89
- const sim = new LongPressSimulator(onLongPress, 500);
90
-
91
- sim.onPointerDown(createPointerEvent(100, 100));
92
-
93
- // Wait for the delay
94
- await new Promise((resolve) => setTimeout(resolve, 550));
95
-
96
- assert.strictEqual(onLongPress.mock.calls.length, 1);
97
- sim.cleanup();
98
- });
99
-
100
- test("does not fire if pointer moves more than 8px", async () => {
101
- const onLongPress = mock.fn();
102
- const sim = new LongPressSimulator(onLongPress, 500);
103
-
104
- sim.onPointerDown(createPointerEvent(100, 100));
105
-
106
- // Move 10 pixels (more than threshold of 8)
107
- sim.onPointerMove(createPointerEvent(110, 100));
108
-
109
- // Wait beyond the delay
110
- await new Promise((resolve) => setTimeout(resolve, 550));
111
-
112
- assert.strictEqual(
113
- onLongPress.mock.calls.length,
114
- 0,
115
- "callback should not fire after moving > threshold",
116
- );
117
- sim.cleanup();
118
- });
119
-
120
- test("does not fire on early pointerup", async () => {
121
- const onLongPress = mock.fn();
122
- const sim = new LongPressSimulator(onLongPress, 500);
123
-
124
- sim.onPointerDown(createPointerEvent(100, 100));
125
-
126
- // Release before delay completes
127
- await new Promise((resolve) => setTimeout(resolve, 100));
128
- sim.onPointerUp();
129
-
130
- // Wait beyond the original delay
131
- await new Promise((resolve) => setTimeout(resolve, 500));
132
-
133
- assert.strictEqual(
134
- onLongPress.mock.calls.length,
135
- 0,
136
- "callback should not fire after early pointerup",
137
- );
138
- sim.cleanup();
139
- });
140
-
141
- test("does not fire on pointercancel", async () => {
142
- const onLongPress = mock.fn();
143
- const sim = new LongPressSimulator(onLongPress, 500);
144
-
145
- sim.onPointerDown(createPointerEvent(100, 100));
146
-
147
- // Cancel before delay completes
148
- await new Promise((resolve) => setTimeout(resolve, 100));
149
- sim.onPointerCancel();
150
-
151
- // Wait beyond the original delay
152
- await new Promise((resolve) => setTimeout(resolve, 500));
153
-
154
- assert.strictEqual(
155
- onLongPress.mock.calls.length,
156
- 0,
157
- "callback should not fire after pointercancel",
158
- );
159
- sim.cleanup();
160
- });
161
-
162
- test("respects custom delayMs", async () => {
163
- const onLongPress = mock.fn();
164
- const customDelay = 200;
165
- const sim = new LongPressSimulator(onLongPress, customDelay);
166
-
167
- sim.onPointerDown(createPointerEvent(100, 100));
168
-
169
- // Wait less than custom delay
170
- await new Promise((resolve) => setTimeout(resolve, 150));
171
- assert.strictEqual(
172
- onLongPress.mock.calls.length,
173
- 0,
174
- "should not fire before custom delay",
175
- );
176
-
177
- // Wait past custom delay
178
- await new Promise((resolve) => setTimeout(resolve, 100));
179
- assert.strictEqual(
180
- onLongPress.mock.calls.length,
181
- 1,
182
- "should fire after custom delay",
183
- );
184
- sim.cleanup();
185
- });
186
-
187
- test("allows movement within 8px threshold", async () => {
188
- const onLongPress = mock.fn();
189
- const sim = new LongPressSimulator(onLongPress, 500);
190
-
191
- sim.onPointerDown(createPointerEvent(100, 100));
192
-
193
- // Move 7 pixels (within threshold)
194
- sim.onPointerMove(createPointerEvent(107, 100));
195
-
196
- // Wait for the delay
197
- await new Promise((resolve) => setTimeout(resolve, 550));
198
-
199
- assert.strictEqual(
200
- onLongPress.mock.calls.length,
201
- 1,
202
- "callback should fire when movement is within threshold",
203
- );
204
- sim.cleanup();
205
- });
206
-
207
- test("calculates diagonal movement correctly", async () => {
208
- const onLongPress = mock.fn();
209
- const sim = new LongPressSimulator(onLongPress, 500);
210
-
211
- sim.onPointerDown(createPointerEvent(100, 100));
212
-
213
- // Move 6px in both x and y (diagonal distance ~8.48px > 8px threshold)
214
- sim.onPointerMove(createPointerEvent(106, 106));
215
-
216
- // Wait beyond the delay
217
- await new Promise((resolve) => setTimeout(resolve, 550));
218
-
219
- assert.strictEqual(
220
- onLongPress.mock.calls.length,
221
- 0,
222
- "callback should not fire when diagonal movement exceeds threshold",
223
- );
224
- sim.cleanup();
225
- });
226
-
227
- test("ignores pointer move before pointer down", async () => {
228
- const onLongPress = mock.fn();
229
- const sim = new LongPressSimulator(onLongPress, 500);
230
-
231
- // Move without pressing down first
232
- sim.onPointerMove(createPointerEvent(200, 200));
233
-
234
- sim.onPointerDown(createPointerEvent(100, 100));
235
-
236
- // Wait for the delay
237
- await new Promise((resolve) => setTimeout(resolve, 550));
238
-
239
- assert.strictEqual(
240
- onLongPress.mock.calls.length,
241
- 1,
242
- "callback should fire - earlier move should be ignored",
243
- );
244
- sim.cleanup();
245
- });
246
-
247
- test("resets on new pointer down", async () => {
248
- const onLongPress = mock.fn();
249
- const sim = new LongPressSimulator(onLongPress, 500);
250
-
251
- // First press
252
- sim.onPointerDown(createPointerEvent(100, 100));
253
-
254
- // Wait a bit
255
- await new Promise((resolve) => setTimeout(resolve, 200));
256
-
257
- // New press at different location (should reset timer)
258
- sim.onPointerDown(createPointerEvent(200, 200));
259
-
260
- // Wait for delay from second press
261
- await new Promise((resolve) => setTimeout(resolve, 550));
262
-
263
- // Should only fire once (from second press)
264
- assert.strictEqual(
265
- onLongPress.mock.calls.length,
266
- 1,
267
- "callback should fire once from second press",
268
- );
269
- sim.cleanup();
270
- });
271
-
272
- test("exact 8px movement does not cancel", async () => {
273
- const onLongPress = mock.fn();
274
- const sim = new LongPressSimulator(onLongPress, 500);
275
-
276
- sim.onPointerDown(createPointerEvent(100, 100));
277
-
278
- // Move exactly 8 pixels
279
- sim.onPointerMove(createPointerEvent(108, 100));
280
-
281
- // Wait for the delay
282
- await new Promise((resolve) => setTimeout(resolve, 550));
283
-
284
- assert.strictEqual(
285
- onLongPress.mock.calls.length,
286
- 1,
287
- "callback should fire - movement exactly at threshold is allowed",
288
- );
289
- sim.cleanup();
290
- });
291
- });
@@ -1,87 +0,0 @@
1
- import { useCallback, useRef } from "react";
2
-
3
- interface UseLongPressOptions {
4
- onLongPress: () => void;
5
- delayMs?: number;
6
- }
7
-
8
- interface LongPressHandlers {
9
- onPointerDown: (e: PointerEvent | React.PointerEvent) => void;
10
- onPointerMove: (e: PointerEvent | React.PointerEvent) => void;
11
- onPointerUp: () => void;
12
- onPointerCancel: () => void;
13
- }
14
-
15
- const MOVEMENT_THRESHOLD = 8; // pixels
16
-
17
- /**
18
- * Hook for detecting long-press gestures on touch/pointer devices.
19
- *
20
- * @param onLongPress - Callback fired after the delay if the pointer hasn't moved
21
- * @param delayMs - How long to wait before firing (default 500ms)
22
- *
23
- * @returns Pointer event handlers to spread onto your element
24
- *
25
- * @example
26
- * const { handlers } = useLongPress({ onLongPress: () => console.log('long press!') });
27
- * return <div {...handlers}>Press and hold me</div>;
28
- */
29
- export const useLongPress = ({
30
- onLongPress,
31
- delayMs = 500,
32
- }: UseLongPressOptions): { handlers: LongPressHandlers } => {
33
- const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
- const startPosRef = useRef<{ x: number; y: number } | null>(null);
35
-
36
- const clearTimer = useCallback(() => {
37
- if (timerRef.current) {
38
- clearTimeout(timerRef.current);
39
- timerRef.current = null;
40
- }
41
- startPosRef.current = null;
42
- }, []);
43
-
44
- const onPointerDown = useCallback(
45
- (e: PointerEvent | React.PointerEvent) => {
46
- clearTimer();
47
- startPosRef.current = { x: e.clientX, y: e.clientY };
48
- timerRef.current = setTimeout(() => {
49
- onLongPress();
50
- clearTimer();
51
- }, delayMs);
52
- },
53
- [onLongPress, delayMs, clearTimer],
54
- );
55
-
56
- const onPointerMove = useCallback(
57
- (e: PointerEvent | React.PointerEvent) => {
58
- if (!startPosRef.current) return;
59
-
60
- const dx = e.clientX - startPosRef.current.x;
61
- const dy = e.clientY - startPosRef.current.y;
62
- const distance = Math.sqrt(dx * dx + dy * dy);
63
-
64
- if (distance > MOVEMENT_THRESHOLD) {
65
- clearTimer();
66
- }
67
- },
68
- [clearTimer],
69
- );
70
-
71
- const onPointerUp = useCallback(() => {
72
- clearTimer();
73
- }, [clearTimer]);
74
-
75
- const onPointerCancel = useCallback(() => {
76
- clearTimer();
77
- }, [clearTimer]);
78
-
79
- return {
80
- handlers: {
81
- onPointerDown,
82
- onPointerMove,
83
- onPointerUp,
84
- onPointerCancel,
85
- },
86
- };
87
- };