@remit/ui 0.0.19 → 0.0.20

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/ui",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -18,9 +18,11 @@
18
18
  "@fontsource-variable/geist": "^5",
19
19
  "@fontsource-variable/hanken-grotesk": "^5",
20
20
  "@fontsource-variable/inter": "^5",
21
+ "@react-types/shared": "^3.36.0",
21
22
  "clsx": "^2",
22
23
  "dompurify": "^3.4.7",
23
24
  "lucide-react": "^0.468",
25
+ "react-aria": "^3.50.0",
24
26
  "react-resizable-panels": "^2.1.9",
25
27
  "react-simple-pull-to-refresh": "^1.3.4",
26
28
  "tailwind-merge": "^2",
@@ -33,7 +35,9 @@
33
35
  },
34
36
  "devDependencies": {
35
37
  "@storybook/react": "^9",
38
+ "@types/jsdom": "^28.0.3",
36
39
  "@types/react-dom": "^19",
40
+ "jsdom": "^29.1.1",
37
41
  "react": "^19",
38
42
  "react-dom": "^19",
39
43
  "typescript": "*"
@@ -0,0 +1,258 @@
1
+ /**
2
+ * SwipeableRow — jsdom gesture tests against the real react-aria long-press
3
+ * wiring interacting with axis arbitration.
4
+ *
5
+ * The one this guards against: react-aria's `useLongPress` dispatches its
6
+ * own synthetic `pointercancel` right before calling `onLongPress` (to
7
+ * preempt other pointer consumers). SwipeableRow's `onPointerCancel` was
8
+ * originally aliased straight to `onPointerUp`, whose "no axis claimed"
9
+ * branch reads as a tap and calls `onOpen`/`onToggleCheck` — so a clean long
10
+ * press would fire onLongPress AND a spurious onOpen in the same gesture.
11
+ * The fix tags SwipeableRow's own axis-abort cancel so it can tell the two
12
+ * apart; these tests exercise both paths against the real hook, not a
13
+ * description of the fix.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
18
+ import type { JSDOM } from "jsdom";
19
+ import { act, createElement } from "react";
20
+ import { createRoot, type Root } from "react-dom/client";
21
+ import type { ThreadRowData } from "./app-shell-types.js";
22
+ import { SwipeableRow, type SwipePeek } from "./swipeable-row.js";
23
+
24
+ const THRESHOLD_WAIT = 560; // default react-aria threshold (500ms) + margin
25
+
26
+ const thread: ThreadRowData = {
27
+ id: "thread-1",
28
+ accountId: "account-1",
29
+ fromName: "Alex Rivera",
30
+ fromEmail: "alex@example.com",
31
+ subject: "Q3 planning notes",
32
+ snippet: "Notes from the planning session.",
33
+ timeLabel: "9:42",
34
+ isRead: false,
35
+ };
36
+
37
+ let dom: JSDOM;
38
+ let container: HTMLElement;
39
+ let root: Root;
40
+
41
+ before(async () => {
42
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
43
+ dom = new JSDOMCtor(
44
+ "<!doctype html><html><body><div id=root></div></body></html>",
45
+ { url: "http://localhost/", pretendToBeVisual: true },
46
+ );
47
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
48
+ globalThis.document = dom.window.document;
49
+ globalThis.HTMLElement = dom.window.HTMLElement;
50
+ globalThis.Element = dom.window.Element;
51
+ globalThis.SVGElement = dom.window.SVGElement;
52
+ globalThis.PointerEvent = dom.window.PointerEvent;
53
+ // jsdom does not implement the pointer-capture methods at all (not even
54
+ // as no-ops) — SwipeableRow calls setPointerCapture once it claims the
55
+ // horizontal axis, so an unpolyfilled call throws mid-gesture.
56
+ dom.window.Element.prototype.setPointerCapture = () => undefined;
57
+ dom.window.Element.prototype.releasePointerCapture = () => undefined;
58
+ dom.window.Element.prototype.hasPointerCapture = () => false;
59
+ Object.defineProperty(globalThis, "navigator", {
60
+ value: dom.window.navigator,
61
+ configurable: true,
62
+ });
63
+ (
64
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
65
+ ).IS_REACT_ACT_ENVIRONMENT = true;
66
+ });
67
+
68
+ after(() => {
69
+ dom.window.close();
70
+ });
71
+
72
+ beforeEach(() => {
73
+ container = dom.window.document.getElementById(
74
+ "root",
75
+ ) as unknown as HTMLElement;
76
+ container.innerHTML = "";
77
+ root = createRoot(container);
78
+ });
79
+
80
+ afterEach(() => {
81
+ act(() => {
82
+ root.unmount();
83
+ });
84
+ });
85
+
86
+ interface Handlers {
87
+ onLongPress: () => void;
88
+ onOpen: () => void;
89
+ onPeek: (next: SwipePeek) => void;
90
+ }
91
+
92
+ function mount(handlers: Handlers) {
93
+ act(() => {
94
+ root.render(
95
+ createElement(SwipeableRow, {
96
+ thread,
97
+ selectionMode: false,
98
+ checked: false,
99
+ active: false,
100
+ peek: "none",
101
+ onPeek: handlers.onPeek,
102
+ onToggleCheck: () => undefined,
103
+ onLongPress: handlers.onLongPress,
104
+ onOpen: handlers.onOpen,
105
+ onAct: () => undefined,
106
+ }),
107
+ );
108
+ });
109
+ // The open affordance is the one button with no aria-label — the
110
+ // leading/trailing action buttons ("Mark as read" etc.) only render once
111
+ // peeked, but querying by absence of aria-label is stable at rest too.
112
+ const row = [...dom.window.document.querySelectorAll("button")].find(
113
+ (b) => !b.hasAttribute("aria-label"),
114
+ );
115
+ assert.ok(row, "open-affordance button did not mount");
116
+ return row;
117
+ }
118
+
119
+ // Each dispatch is wrapped in a synchronous act() so React commits the
120
+ // resulting state update before the next call reads it. Without this, a
121
+ // handler in the next dispatch can close over a stale pre-commit value (a
122
+ // real gap we hit developing this test: an unwrapped dispatch sequence read
123
+ // a stale `null` dragX in onPointerUp and mistook the just-completed swipe
124
+ // commit for a bare tap, firing a spurious onOpen).
125
+ function pointerDown(row: Element, x = 10, y = 10) {
126
+ act(() => {
127
+ row.dispatchEvent(
128
+ new dom.window.PointerEvent("pointerdown", {
129
+ bubbles: true,
130
+ pointerType: "touch",
131
+ pointerId: 1,
132
+ clientX: x,
133
+ clientY: y,
134
+ }),
135
+ );
136
+ });
137
+ }
138
+
139
+ function pointerMove(row: Element, x: number, y: number) {
140
+ act(() => {
141
+ row.dispatchEvent(
142
+ new dom.window.PointerEvent("pointermove", {
143
+ bubbles: true,
144
+ pointerType: "touch",
145
+ pointerId: 1,
146
+ clientX: x,
147
+ clientY: y,
148
+ }),
149
+ );
150
+ });
151
+ }
152
+
153
+ function pointerUp(row: Element) {
154
+ // Dispatched on the row, not document: SwipeableRow's own onPointerUp is a
155
+ // React prop on the row element, delegated via React's root-container
156
+ // listener — an event whose target is `document` (an ancestor of the
157
+ // root, not a descendant) never bubbles into that delegated listener.
158
+ // A real browser routes pointerup to the pointer-capturing element
159
+ // regardless of finger position once setPointerCapture has been called
160
+ // (the horizontal-swipe case here), so this also matches real behavior.
161
+ act(() => {
162
+ row.dispatchEvent(
163
+ new dom.window.PointerEvent("pointerup", {
164
+ bubbles: true,
165
+ pointerType: "touch",
166
+ pointerId: 1,
167
+ }),
168
+ );
169
+ });
170
+ }
171
+
172
+ function wait(ms: number) {
173
+ return act(() => new Promise((resolve) => setTimeout(resolve, ms)));
174
+ }
175
+
176
+ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)", () => {
177
+ it("fires onLongPress on an unmoved press, with no spurious onOpen", async () => {
178
+ let longPressed = 0;
179
+ let opened = 0;
180
+ const row = mount({
181
+ onLongPress: () => longPressed++,
182
+ onOpen: () => opened++,
183
+ onPeek: () => undefined,
184
+ });
185
+
186
+ pointerDown(row);
187
+ await wait(THRESHOLD_WAIT);
188
+ pointerUp(row);
189
+
190
+ assert.equal(longPressed, 1);
191
+ assert.equal(
192
+ opened,
193
+ 0,
194
+ "react-aria's own pointercancel (dispatched right before onLongPress) must not be read as a tap-to-open",
195
+ );
196
+ });
197
+
198
+ it("a horizontal drag past the axis threshold cancels the long press and commits a swipe peek, not onOpen", async () => {
199
+ let longPressed = 0;
200
+ let opened = 0;
201
+ let committed: SwipePeek | undefined;
202
+ const row = mount({
203
+ onLongPress: () => longPressed++,
204
+ onOpen: () => opened++,
205
+ onPeek: (next) => {
206
+ committed = next;
207
+ },
208
+ });
209
+
210
+ pointerDown(row);
211
+ pointerMove(row, 50, 10); // dx=40, past SWIPE_AXIS_THRESHOLD(10) and >= half SWIPE_ACTION_WIDTH(36)
212
+ pointerUp(row);
213
+ await wait(THRESHOLD_WAIT);
214
+
215
+ assert.equal(
216
+ longPressed,
217
+ 0,
218
+ "long press must be cancelled once the horizontal axis is claimed",
219
+ );
220
+ assert.equal(opened, 0);
221
+ assert.equal(committed, "leading");
222
+ });
223
+
224
+ it("a vertical drag past the axis threshold cancels the long press and lets scroll win (no peek, no open)", async () => {
225
+ let longPressed = 0;
226
+ let opened = 0;
227
+ let peeked = 0;
228
+ const row = mount({
229
+ onLongPress: () => longPressed++,
230
+ onOpen: () => opened++,
231
+ onPeek: () => peeked++,
232
+ });
233
+
234
+ pointerDown(row);
235
+ pointerMove(row, 10, 50); // dy=40, past SWIPE_AXIS_THRESHOLD(10), vertical wins
236
+ pointerUp(row);
237
+ await wait(THRESHOLD_WAIT);
238
+
239
+ assert.equal(longPressed, 0);
240
+ assert.equal(opened, 0);
241
+ assert.equal(peeked, 0, "vertical scroll must not commit or reset a peek");
242
+ });
243
+
244
+ it("a small move within the axis threshold still allows the long press to fire", async () => {
245
+ let longPressed = 0;
246
+ const row = mount({
247
+ onLongPress: () => longPressed++,
248
+ onOpen: () => undefined,
249
+ onPeek: () => undefined,
250
+ });
251
+
252
+ pointerDown(row);
253
+ pointerMove(row, 13, 12); // dx=3, dy=2 — within SWIPE_AXIS_THRESHOLD(10)
254
+ await wait(THRESHOLD_WAIT);
255
+
256
+ assert.equal(longPressed, 1);
257
+ });
258
+ });
@@ -1,6 +1,9 @@
1
+ import type { DOMAttributes } from "@react-types/shared";
1
2
  import { Check, Mail, MailOpen, Trash2 } from "lucide-react";
2
3
  import { useRef, useState } from "react";
4
+ import { mergeProps } from "react-aria";
3
5
  import { cn } from "../lib/cn.js";
6
+ import { useLongPress } from "../lib/use-long-press.js";
4
7
  import type { ThreadRowData } from "./app-shell-types.js";
5
8
  import { Avatar } from "./avatar.js";
6
9
  import {
@@ -17,6 +20,19 @@ const SWIPE_ACTION_WIDTH = 72;
17
20
  * this a press is still a tap / long-press and vertical scroll wins. */
18
21
  const SWIPE_AXIS_THRESHOLD = 10;
19
22
 
23
+ /**
24
+ * Tags a pointercancel dispatched by this component's own axis arbitration
25
+ * (see `cancelLongPress` below) so `onPointerCancel` can tell it apart from
26
+ * one react-aria dispatches itself when its own long press fires, or a
27
+ * genuine browser-triggered cancel. Both of those arrive with no axis
28
+ * claimed yet and must reset gesture state silently; a tagged one arrives
29
+ * *because* `onPointerMove` just claimed an axis and is already handling
30
+ * gesture state inline, so `onPointerCancel` must ignore it — otherwise it
31
+ * re-reads a gesture with no axis claimed and mistakes the abort for a tap,
32
+ * firing a spurious onOpen/onToggleCheck.
33
+ */
34
+ const AXIS_CANCEL = "__swipeableRowAxisCancel";
35
+
20
36
  function peekOffset(peek: SwipePeek): number {
21
37
  if (peek === "leading") return SWIPE_ACTION_WIDTH;
22
38
  if (peek === "trailing") return -SWIPE_ACTION_WIDTH;
@@ -34,19 +50,17 @@ export function commitPeek(offset: number): SwipePeek {
34
50
 
35
51
  /**
36
52
  * Props the row's interactive (open) element must receive — the swipe gesture
37
- * handlers, the transform/transition style, the row body, plus an onClick that
38
- * suppresses navigation when the row is peeked. A consumer passes `linkComponent`
39
- * to render these on a real anchor (e.g. a router Link) so the open affordance is
40
- * a true `<a href>` keeping open-in-new-tab, middle-click, deep-link and a11y
41
- * instead of the default JS-only `<button onOpen>`.
53
+ * handlers (merged with react-aria's long-press props: pointer handlers,
54
+ * `aria-describedby`, and friends), the transform/transition style, the row
55
+ * body, plus an onClick that suppresses navigation when the row is peeked. A
56
+ * consumer passes `linkComponent` to render these on a real anchor (e.g. a
57
+ * router Link) so the open affordance is a true `<a href>` — keeping
58
+ * open-in-new-tab, middle-click, deep-link and a11y — instead of the default
59
+ * JS-only `<button onOpen>`.
42
60
  */
43
- export interface SwipeableRowOpenProps {
61
+ export interface SwipeableRowOpenProps extends DOMAttributes {
44
62
  className: string;
45
63
  style: React.CSSProperties;
46
- onPointerDown: (e: React.PointerEvent) => void;
47
- onPointerMove: (e: React.PointerEvent) => void;
48
- onPointerUp: () => void;
49
- onPointerCancel: () => void;
50
64
  /** Wire to the anchor's onClick. When the row is peeked it calls
51
65
  * preventDefault so a tap closes the peek instead of navigating; otherwise
52
66
  * it is a no-op and the anchor's native navigation proceeds. */
@@ -86,9 +100,6 @@ export function SwipeableRow({
86
100
  * deep-link/middle-click work); onOpen is not called for the tap. */
87
101
  linkComponent?: (props: SwipeableRowOpenProps) => React.ReactNode;
88
102
  }) {
89
- const pressTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
90
- undefined,
91
- );
92
103
  const gesture = useRef<{
93
104
  startX: number;
94
105
  startY: number;
@@ -97,7 +108,23 @@ export function SwipeableRow({
97
108
  } | null>(null);
98
109
  const [dragX, setDragX] = useState<number | null>(null);
99
110
 
100
- const cancelLongPress = () => clearTimeout(pressTimer.current);
111
+ // Long-press timing/threshold and contextmenu/text-selection suppression
112
+ // are owned by react-aria; this component only arbitrates the swipe axis.
113
+ const { longPressProps } = useLongPress({
114
+ onLongPress,
115
+ isDisabled: selectionMode,
116
+ accessibilityDescription: "Select message",
117
+ });
118
+
119
+ // react-aria's usePress has no imperative "cancel" — a synthetic
120
+ // pointercancel is the mechanism it uses itself to abort other pointer
121
+ // consumers when its own long press fires. Reused here in reverse, tagged
122
+ // so onPointerCancel below can recognize it as ours (see AXIS_CANCEL).
123
+ const cancelLongPress = (e: React.PointerEvent) => {
124
+ const event = new PointerEvent("pointercancel", { bubbles: true });
125
+ (event as PointerEvent & Record<string, boolean>)[AXIS_CANCEL] = true;
126
+ e.currentTarget.dispatchEvent(event);
127
+ };
101
128
 
102
129
  const onPointerDown = (e: React.PointerEvent) => {
103
130
  gesture.current = {
@@ -106,13 +133,6 @@ export function SwipeableRow({
106
133
  axis: "none",
107
134
  moved: false,
108
135
  };
109
- // selection mode is tap-to-toggle only — no long-press, no swipe drag
110
- if (selectionMode) return;
111
- pressTimer.current = setTimeout(() => {
112
- gesture.current = null;
113
- setDragX(null);
114
- onLongPress();
115
- }, 500);
116
136
  };
117
137
 
118
138
  const onPointerMove = (e: React.PointerEvent) => {
@@ -125,14 +145,14 @@ export function SwipeableRow({
125
145
  if (Math.abs(dy) > SWIPE_AXIS_THRESHOLD && Math.abs(dy) > Math.abs(dx)) {
126
146
  // vertical scroll wins: abandon the swipe + long-press, let the list scroll
127
147
  g.axis = "vertical";
128
- cancelLongPress();
148
+ cancelLongPress(e);
129
149
  gesture.current = null;
130
150
  return;
131
151
  }
132
152
  if (Math.abs(dx) > SWIPE_AXIS_THRESHOLD) {
133
153
  g.axis = "horizontal";
134
154
  g.moved = true;
135
- cancelLongPress();
155
+ cancelLongPress(e);
136
156
  e.currentTarget.setPointerCapture(e.pointerId);
137
157
  }
138
158
  }
@@ -146,7 +166,6 @@ export function SwipeableRow({
146
166
  };
147
167
 
148
168
  const onPointerUp = () => {
149
- cancelLongPress();
150
169
  const g = gesture.current;
151
170
  gesture.current = null;
152
171
  const offset = dragX;
@@ -172,6 +191,28 @@ export function SwipeableRow({
172
191
  onOpen();
173
192
  };
174
193
 
194
+ // A genuine cancel — react-aria's own dispatch when its long press fires,
195
+ // or a real browser-triggered interruption — always resets silently, never
196
+ // as a tap-to-open/toggle/peek-commit. An axis-claim cancel (tagged, see
197
+ // AXIS_CANCEL) is a no-op here: onPointerMove already handled that gesture
198
+ // inline, either continuing to track it (horizontal) or nulling it itself
199
+ // (vertical) — see the comment there.
200
+ const onPointerCancel = (e: React.PointerEvent) => {
201
+ const tagged = (e.nativeEvent as unknown as Record<string, boolean>)[
202
+ AXIS_CANCEL
203
+ ];
204
+ if (tagged) return;
205
+ gesture.current = null;
206
+ setDragX(null);
207
+ };
208
+
209
+ const gestureProps = mergeProps(longPressProps, {
210
+ onPointerDown,
211
+ onPointerMove,
212
+ onPointerUp,
213
+ onPointerCancel,
214
+ });
215
+
175
216
  // A tap on a peeked anchor must close the peek, not navigate; suppress the
176
217
  // native click in that case. onPointerUp already snapped it closed.
177
218
  const onOpenClick = (e: { preventDefault: () => void }) => {
@@ -185,6 +226,12 @@ export function SwipeableRow({
185
226
  const interactiveClassName = cn(
186
227
  // opaque bg so the row occludes the action behind it until peeked
187
228
  "relative touch-pan-y bg-surface",
229
+ // This row's long press enters selection mode; without these, Android
230
+ // Chrome opens the link context menu / starts text selection and iOS
231
+ // Safari fires the callout, racing the app's handler. react-aria
232
+ // suppresses contextmenu/text-selection but not iOS's callout — it
233
+ // fires no cancelable event, so CSS is the only lever.
234
+ "select-none [-webkit-touch-callout:none]",
188
235
  comfortableRowClass({ active: checked || active }),
189
236
  );
190
237
  const interactiveStyle: React.CSSProperties = {
@@ -278,12 +325,9 @@ export function SwipeableRow({
278
325
  keep the button so a checkbox tap can't open a thread. */}
279
326
  {linkComponent && !selectionMode ? (
280
327
  linkComponent({
328
+ ...gestureProps,
281
329
  className: interactiveClassName,
282
330
  style: interactiveStyle,
283
- onPointerDown,
284
- onPointerMove,
285
- onPointerUp,
286
- onPointerCancel: onPointerUp,
287
331
  onOpenClick,
288
332
  children: body,
289
333
  })
@@ -293,10 +337,7 @@ export function SwipeableRow({
293
337
  type="button"
294
338
  role={selectionMode ? "checkbox" : undefined}
295
339
  aria-checked={selectionMode ? checked : undefined}
296
- onPointerDown={onPointerDown}
297
- onPointerMove={onPointerMove}
298
- onPointerUp={onPointerUp}
299
- onPointerCancel={onPointerUp}
340
+ {...gestureProps}
300
341
  className={interactiveClassName}
301
342
  style={interactiveStyle}
302
343
  >
package/src/index.ts CHANGED
@@ -1,3 +1,8 @@
1
+ // Re-exported so a consumer can compose useLongPress's longPressProps with its
2
+ // own DOM props (its pressProps include an onClick react-aria uses for its
3
+ // own bookkeeping; a plain object spread silently drops one side's handler
4
+ // instead of chaining them) without importing react-aria directly.
5
+ export { mergeProps } from "react-aria";
1
6
  export {
2
7
  AddressDisplay,
3
8
  type AddressDisplayProps,
@@ -446,3 +451,8 @@ export {
446
451
  sanitizeInlineStyle,
447
452
  sanitizeStyleElementCss,
448
453
  } from "./lib/email-sanitizer.js";
454
+ export {
455
+ type UseLongPressOptions,
456
+ type UseLongPressResult,
457
+ useLongPress,
458
+ } from "./lib/use-long-press.js";
@@ -0,0 +1,220 @@
1
+ /**
2
+ * use-long-press — exercises the real hook (react-aria's `useLongPress`)
3
+ * against a jsdom-mounted element, not a reimplementation of its logic. The
4
+ * hook this replaces (`packages/web-client/src/hooks/useLongPress.ts`) had a
5
+ * decoy test that reimplemented the timer/threshold logic locally and so
6
+ * gave zero regression coverage on the actual hook; these tests dispatch
7
+ * real PointerEvents at a real mounted node and assert on the callback and
8
+ * the DOM side effects react-aria owns (contextmenu suppression).
9
+ *
10
+ * jsdom is a devDependency scoped to this one test — react-aria's
11
+ * pointerdown → threshold timer → onLongPress path, its global
12
+ * pointerup/pointercancel listeners, and its contextmenu suppression all
13
+ * need a real `document`/`window`/`PointerEvent`, which `renderToString`
14
+ * (the pattern used elsewhere in this repo for presentational components)
15
+ * cannot exercise.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
20
+ import type { JSDOM } from "jsdom";
21
+ import { act, createElement } from "react";
22
+ import { createRoot, type Root } from "react-dom/client";
23
+ import { useLongPress } from "./use-long-press.js";
24
+
25
+ const THRESHOLD = 40;
26
+
27
+ let dom: JSDOM;
28
+ let container: HTMLElement;
29
+ let root: Root;
30
+
31
+ function Row(props: {
32
+ onLongPress: () => void;
33
+ isDisabled?: boolean;
34
+ accessibilityDescription?: string;
35
+ }) {
36
+ const { longPressProps } = useLongPress({
37
+ onLongPress: props.onLongPress,
38
+ isDisabled: props.isDisabled,
39
+ delayMs: THRESHOLD,
40
+ accessibilityDescription: props.accessibilityDescription,
41
+ });
42
+ return createElement(
43
+ "a",
44
+ { id: "row", href: "/thread/1", ...longPressProps },
45
+ "row",
46
+ );
47
+ }
48
+
49
+ function mount(props: {
50
+ onLongPress: () => void;
51
+ isDisabled?: boolean;
52
+ accessibilityDescription?: string;
53
+ }) {
54
+ act(() => {
55
+ root.render(createElement(Row, props));
56
+ });
57
+ const row = dom.window.document.getElementById("row");
58
+ assert.ok(row, "row did not mount");
59
+ return row;
60
+ }
61
+
62
+ function pointerDown(row: Element) {
63
+ row.dispatchEvent(
64
+ new dom.window.PointerEvent("pointerdown", {
65
+ bubbles: true,
66
+ pointerType: "touch",
67
+ pointerId: 1,
68
+ clientX: 10,
69
+ clientY: 10,
70
+ }),
71
+ );
72
+ }
73
+
74
+ function pointerUp() {
75
+ dom.window.document.dispatchEvent(
76
+ new dom.window.PointerEvent("pointerup", {
77
+ bubbles: true,
78
+ pointerType: "touch",
79
+ pointerId: 1,
80
+ clientX: 10,
81
+ clientY: 10,
82
+ }),
83
+ );
84
+ }
85
+
86
+ function pointerCancel(row: Element) {
87
+ row.dispatchEvent(
88
+ new dom.window.PointerEvent("pointercancel", { bubbles: true }),
89
+ );
90
+ }
91
+
92
+ function wait(ms: number) {
93
+ return act(() => new Promise((resolve) => setTimeout(resolve, ms)));
94
+ }
95
+
96
+ before(async () => {
97
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
98
+ dom = new JSDOMCtor(
99
+ "<!doctype html><html><body><div id=root></div></body></html>",
100
+ { url: "http://localhost/", pretendToBeVisual: true },
101
+ );
102
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
103
+ globalThis.document = dom.window.document;
104
+ globalThis.HTMLElement = dom.window.HTMLElement;
105
+ globalThis.Element = dom.window.Element;
106
+ globalThis.SVGElement = dom.window.SVGElement;
107
+ globalThis.PointerEvent = dom.window.PointerEvent;
108
+ Object.defineProperty(globalThis, "navigator", {
109
+ value: dom.window.navigator,
110
+ configurable: true,
111
+ });
112
+ (
113
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
114
+ ).IS_REACT_ACT_ENVIRONMENT = true;
115
+ });
116
+
117
+ after(() => {
118
+ dom.window.close();
119
+ });
120
+
121
+ beforeEach(() => {
122
+ container = dom.window.document.getElementById(
123
+ "root",
124
+ ) as unknown as HTMLElement;
125
+ container.innerHTML = "";
126
+ root = createRoot(container);
127
+ });
128
+
129
+ afterEach(() => {
130
+ act(() => {
131
+ root.unmount();
132
+ });
133
+ });
134
+
135
+ describe("useLongPress (react-aria wrapper)", () => {
136
+ it("fires onLongPress after the threshold with no interruption", async () => {
137
+ let fired = 0;
138
+ const row = mount({ onLongPress: () => fired++ });
139
+
140
+ pointerDown(row);
141
+ await wait(THRESHOLD + 40);
142
+
143
+ assert.equal(fired, 1);
144
+ });
145
+
146
+ it("does not fire when released before the threshold", async () => {
147
+ let fired = 0;
148
+ const row = mount({ onLongPress: () => fired++ });
149
+
150
+ pointerDown(row);
151
+ await wait(THRESHOLD / 2);
152
+ pointerUp();
153
+ await wait(THRESHOLD + 40);
154
+
155
+ assert.equal(fired, 0);
156
+ });
157
+
158
+ it("does not fire when cancelled via a pointercancel before the threshold", async () => {
159
+ // This is the mechanism SwipeableRow's axis arbitration relies on: it
160
+ // dispatches a synthetic pointercancel to abort a pending long press
161
+ // once a horizontal or vertical drag claims the gesture.
162
+ let fired = 0;
163
+ const row = mount({ onLongPress: () => fired++ });
164
+
165
+ pointerDown(row);
166
+ await wait(THRESHOLD / 2);
167
+ pointerCancel(row);
168
+ await wait(THRESHOLD + 40);
169
+
170
+ assert.equal(fired, 0);
171
+ });
172
+
173
+ it("does not fire while isDisabled", async () => {
174
+ let fired = 0;
175
+ const row = mount({ onLongPress: () => fired++, isDisabled: true });
176
+
177
+ pointerDown(row);
178
+ await wait(THRESHOLD + 40);
179
+
180
+ assert.equal(fired, 0);
181
+ });
182
+
183
+ it("suppresses the native contextmenu that follows a touch long press", async () => {
184
+ let fired = 0;
185
+ const row = mount({ onLongPress: () => fired++ });
186
+
187
+ pointerDown(row);
188
+ await wait(THRESHOLD + 40);
189
+ assert.equal(
190
+ fired,
191
+ 1,
192
+ "long press must have fired for this to be meaningful",
193
+ );
194
+
195
+ const contextMenuEvent = new dom.window.MouseEvent("contextmenu", {
196
+ bubbles: true,
197
+ cancelable: true,
198
+ });
199
+ row.dispatchEvent(contextMenuEvent);
200
+
201
+ assert.equal(
202
+ contextMenuEvent.defaultPrevented,
203
+ true,
204
+ "react-aria suppresses the link context menu that Android/Chrome fires after a touch long press",
205
+ );
206
+ });
207
+
208
+ it("does not suppress contextmenu when no long press occurred", async () => {
209
+ mount({ onLongPress: () => undefined });
210
+ const row = dom.window.document.getElementById("row") as Element;
211
+
212
+ const contextMenuEvent = new dom.window.MouseEvent("contextmenu", {
213
+ bubbles: true,
214
+ cancelable: true,
215
+ });
216
+ row.dispatchEvent(contextMenuEvent);
217
+
218
+ assert.equal(contextMenuEvent.defaultPrevented, false);
219
+ });
220
+ });
@@ -0,0 +1,50 @@
1
+ import type { DOMAttributes } from "@react-types/shared";
2
+ import { useLongPress as useAriaLongPress } from "react-aria";
3
+
4
+ export interface UseLongPressOptions {
5
+ /** Called once the threshold elapses while the press stays over the target. */
6
+ onLongPress: () => void;
7
+ /** Long press is a no-op while true (e.g. a row already in selection mode). */
8
+ isDisabled?: boolean;
9
+ /** @default 500 */
10
+ delayMs?: number;
11
+ /**
12
+ * Announced to assistive technology as the long-press action, e.g.
13
+ * "Select message". TalkBack/VoiceOver have no gesture equivalent for a
14
+ * timed hold, so this description — not the gesture itself — is what
15
+ * makes the action discoverable to a screen reader user.
16
+ */
17
+ accessibilityDescription?: string;
18
+ }
19
+
20
+ export interface UseLongPressResult {
21
+ /** Spread onto the pressable element (anchor, button, or row container). */
22
+ longPressProps: DOMAttributes;
23
+ }
24
+
25
+ /**
26
+ * Long-press detection backed by react-aria's `useLongPress`. Owns
27
+ * `contextmenu` suppression and iOS text-selection suppression, and treats
28
+ * `<a href>` targets specially so link navigation and middle-click survive
29
+ * outside the press. It does not, and cannot, suppress iOS's native callout
30
+ * (share sheet) on an anchor — that still requires
31
+ * `-webkit-touch-callout: none` in CSS at the call site, since iOS fires no
32
+ * cancelable event for it.
33
+ *
34
+ * Single source of truth for the app's long-press threshold — both mobile
35
+ * row consumers (the plain row and the swipeable row) go through this hook
36
+ * so their timing can't drift apart again.
37
+ */
38
+ export function useLongPress({
39
+ onLongPress,
40
+ isDisabled,
41
+ delayMs = 500,
42
+ accessibilityDescription,
43
+ }: UseLongPressOptions): UseLongPressResult {
44
+ return useAriaLongPress({
45
+ isDisabled,
46
+ threshold: delayMs,
47
+ accessibilityDescription,
48
+ onLongPress,
49
+ });
50
+ }