@remit/ui 0.0.19 → 0.0.21

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.21",
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": "*"
@@ -42,8 +42,7 @@ describe("MobileSearchView search scope", () => {
42
42
  const html = renderToString(
43
43
  createElement(MobileSearchView, {
44
44
  ...base,
45
- scope: { kind: "global" as const },
46
- onScopeToSpam: noop,
45
+ scope: { kind: "global" as const, onScopeToSpam: noop },
47
46
  }),
48
47
  );
49
48
  assert.doesNotMatch(html, /unknown-vendor/);
@@ -56,7 +55,6 @@ describe("MobileSearchView search scope", () => {
56
55
  createElement(MobileSearchView, {
57
56
  ...base,
58
57
  scope: { kind: "folder" as const, role: "inbox" as const },
59
- onScopeToSpam: noop,
60
58
  }),
61
59
  );
62
60
  assert.doesNotMatch(html, /unknown-vendor/);
@@ -64,15 +62,14 @@ describe("MobileSearchView search scope", () => {
64
62
  assert.doesNotMatch(html, /Archive/);
65
63
  });
66
64
 
67
- it("passes a caller-supplied spam total through", () => {
65
+ it("counts the spam it held out, on the phone tier too", () => {
68
66
  const html = renderToString(
69
67
  createElement(MobileSearchView, {
70
68
  ...base,
71
- scope: { kind: "global" as const },
72
- spamMatchCount: 42,
73
- onScopeToSpam: noop,
69
+ scope: { kind: "global" as const, onScopeToSpam: noop },
74
70
  }),
75
71
  );
76
- assert.match(html, />42</);
72
+ assert.match(html, /from Spam/);
73
+ assert.match(html, /Go to Spam/);
77
74
  });
78
75
  });
@@ -156,8 +156,6 @@ function Harness({
156
156
  sections,
157
157
  preset,
158
158
  scope,
159
- spamMatchCount,
160
- onScopeToSpam,
161
159
  }: {
162
160
  initialValue?: string;
163
161
  initialChips?: SearchChip[];
@@ -165,8 +163,6 @@ function Harness({
165
163
  sections?: SearchResultSection[];
166
164
  preset: Preset;
167
165
  scope?: SearchScope;
168
- spamMatchCount?: number;
169
- onScopeToSpam?: () => void;
170
166
  }) {
171
167
  const [value, setValue] = useState(initialValue);
172
168
  const [chips, setChips] = useState<SearchChip[]>(initialChips);
@@ -240,8 +236,6 @@ function Harness({
240
236
  loading={loading}
241
237
  onSelectResult={setOpened}
242
238
  scope={scope}
243
- spamMatchCount={spamMatchCount}
244
- onScopeToSpam={onScopeToSpam}
245
239
  />
246
240
  );
247
241
  }
@@ -343,8 +337,7 @@ export const GlobalAcrossFolders: Story = {
343
337
  <Harness
344
338
  initialValue="invoice"
345
339
  sections={acrossFoldersSections}
346
- scope={{ kind: "global" }}
347
- onScopeToSpam={() => {}}
340
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
348
341
  preset="brief"
349
342
  />
350
343
  ),
@@ -362,7 +355,6 @@ export const ScopedToInbox: Story = {
362
355
  initialChips={[{ id: "in:inbox", label: "in:inbox" }]}
363
356
  sections={acrossFoldersSections}
364
357
  scope={{ kind: "folder", role: "inbox" }}
365
- onScopeToSpam={() => {}}
366
358
  preset="inbox"
367
359
  />
368
360
  ),
@@ -377,8 +369,25 @@ export const GlobalOnlySpamMatches: Story = {
377
369
  <Harness
378
370
  initialValue="invoice"
379
371
  sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
380
- scope={{ kind: "global" }}
381
- onScopeToSpam={() => {}}
372
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
373
+ preset="brief"
374
+ />
375
+ ),
376
+ };
377
+
378
+ /**
379
+ * The same rows under a starred search. Starring spans folders, so the rows
380
+ * keep their provenance labels, and the spam among them stays in the list — the
381
+ * user starred it themselves, so there is nothing to hold back and no offer to
382
+ * make.
383
+ */
384
+ export const StarredCollection: Story = {
385
+ render: () => (
386
+ <Harness
387
+ initialValue="invoice"
388
+ initialChips={[{ id: "is:starred", label: "is:starred" }]}
389
+ sections={acrossFoldersSections}
390
+ scope={{ kind: "collection" }}
382
391
  preset="brief"
383
392
  />
384
393
  ),
@@ -46,10 +46,6 @@ export interface MobileSearchViewProps {
46
46
  onRemoveChip?: (id: string) => void;
47
47
  /** What the search covers; see `SearchResultsProps`. Defaults to global. */
48
48
  scope?: SearchScope;
49
- /** Total spam matches found; see `SearchResultsProps`. */
50
- spamMatchCount?: number;
51
- /** Scope the search to Spam; see `SearchResultsProps`. */
52
- onScopeToSpam?: () => void;
53
49
  }
54
50
 
55
51
  /**
@@ -81,8 +77,6 @@ export function MobileSearchView({
81
77
  chips,
82
78
  onRemoveChip,
83
79
  scope,
84
- spamMatchCount,
85
- onScopeToSpam,
86
80
  }: MobileSearchViewProps) {
87
81
  const body = (
88
82
  <SearchResults
@@ -94,8 +88,6 @@ export function MobileSearchView({
94
88
  onSelectResult={onSelectResult}
95
89
  tokens={tokens}
96
90
  scope={scope}
97
- spamMatchCount={spamMatchCount}
98
- onScopeToSpam={onScopeToSpam}
99
91
  />
100
92
  );
101
93
 
@@ -114,8 +114,7 @@ describe("SearchResults spam handling", () => {
114
114
  createElement(SearchResults, {
115
115
  value: "invoice",
116
116
  sections: mixed,
117
- scope: { kind: "global" },
118
- onScopeToSpam: noop,
117
+ scope: { kind: "global", onScopeToSpam: noop },
119
118
  }),
120
119
  );
121
120
  assert.doesNotMatch(html, /unknown-vendor/);
@@ -130,8 +129,7 @@ describe("SearchResults spam handling", () => {
130
129
  sections: [
131
130
  { id: "results", label: "Results", results: [archivedResult] },
132
131
  ],
133
- scope: { kind: "global" },
134
- onScopeToSpam: noop,
132
+ scope: { kind: "global", onScopeToSpam: noop },
135
133
  }),
136
134
  );
137
135
  assert.doesNotMatch(html, /from Spam/);
@@ -142,8 +140,7 @@ describe("SearchResults spam handling", () => {
142
140
  createElement(SearchResults, {
143
141
  value: "invoice",
144
142
  sections: [{ id: "results", label: "Results", results: [spamResult] }],
145
- scope: { kind: "global" },
146
- onScopeToSpam: noop,
143
+ scope: { kind: "global", onScopeToSpam: noop },
147
144
  }),
148
145
  );
149
146
  assert.match(html, /No matches for/);
@@ -156,7 +153,6 @@ describe("SearchResults spam handling", () => {
156
153
  value: "invoice",
157
154
  sections: mixed,
158
155
  scope: { kind: "folder", role: "inbox" },
159
- onScopeToSpam: noop,
160
156
  }),
161
157
  );
162
158
  assert.doesNotMatch(html, /unknown-vendor/);
@@ -170,7 +166,6 @@ describe("SearchResults spam handling", () => {
170
166
  value: "invoice",
171
167
  sections: mixed,
172
168
  scope: { kind: "folder", role: "junk" },
173
- onScopeToSpam: noop,
174
169
  }),
175
170
  );
176
171
  assert.match(html, /unknown-vendor/);
@@ -187,19 +182,6 @@ describe("SearchResults spam handling", () => {
187
182
  );
188
183
  assert.doesNotMatch(html, /from Spam/);
189
184
  });
190
-
191
- it("prefers a caller-supplied total over the rows held out of this page", () => {
192
- const html = renderToString(
193
- createElement(SearchResults, {
194
- value: "invoice",
195
- sections: mixed,
196
- scope: { kind: "global" },
197
- spamMatchCount: 42,
198
- onScopeToSpam: noop,
199
- }),
200
- );
201
- assert.match(html, />42</);
202
- });
203
185
  });
204
186
 
205
187
  describe("SearchResults provenance labels", () => {
@@ -268,3 +250,86 @@ describe("SearchResults provenance labels", () => {
268
250
  assert.doesNotMatch(html, /Important/);
269
251
  });
270
252
  });
253
+
254
+ describe("SearchResults scoped to a collection", () => {
255
+ const spamRow: SearchResult = {
256
+ id: "s1",
257
+ sender: "billing@unknown-vendor.test",
258
+ subject: "URGENT invoice attached",
259
+ snippet: "Wire the amount below.",
260
+ date: "Feb 11",
261
+ folder: { role: "junk" },
262
+ };
263
+ const archivedRow: SearchResult = {
264
+ ...result,
265
+ id: "a1",
266
+ folder: { role: "archive" },
267
+ };
268
+ const collectionSections: SearchResultSection[] = [
269
+ { id: "results", label: "Results", results: [archivedRow, spamRow] },
270
+ ];
271
+
272
+ it("keeps a starred spam match in the list", () => {
273
+ const html = renderToString(
274
+ createElement(SearchResults, {
275
+ value: "invoice",
276
+ sections: collectionSections,
277
+ scope: { kind: "collection" },
278
+ }),
279
+ );
280
+ assert.match(html, /billing@unknown-vendor\.test/);
281
+ assert.doesNotMatch(html, /from Spam/);
282
+ });
283
+
284
+ it("still names the folder each row came from", () => {
285
+ const html = renderToString(
286
+ createElement(SearchResults, {
287
+ value: "invoice",
288
+ sections: collectionSections,
289
+ scope: { kind: "collection" },
290
+ }),
291
+ );
292
+ assert.match(html, /Archive/);
293
+ assert.match(html, /Spam/);
294
+ });
295
+
296
+ it("holds the same spam row out when the search is scoped to a folder", () => {
297
+ const html = renderToString(
298
+ createElement(SearchResults, {
299
+ value: "invoice",
300
+ sections: collectionSections,
301
+ scope: { kind: "folder", role: "inbox" },
302
+ }),
303
+ );
304
+ assert.doesNotMatch(html, /billing@unknown-vendor\.test/);
305
+ });
306
+ });
307
+
308
+ describe("SearchResults spam count", () => {
309
+ const spamIn = (id: string): SearchResult => ({
310
+ id,
311
+ sender: "spammer",
312
+ subject: "Offer",
313
+ snippet: "",
314
+ date: "",
315
+ folder: { role: "junk" },
316
+ });
317
+
318
+ it("counts every held-out row, not just one account's", () => {
319
+ const html = renderToString(
320
+ createElement(SearchResults, {
321
+ value: "invoice",
322
+ sections: [
323
+ {
324
+ id: "results",
325
+ label: "Results",
326
+ results: [result, spamIn("a1"), spamIn("b1"), spamIn("b2")],
327
+ },
328
+ ],
329
+ scope: { kind: "global", onScopeToSpam: noop },
330
+ }),
331
+ );
332
+ assert.match(html, />3</);
333
+ assert.match(html, /results from Spam/);
334
+ });
335
+ });
@@ -190,8 +190,7 @@ export const GlobalAcrossFolders: Story = {
190
190
  render: () => (
191
191
  <Harness
192
192
  value="invoice"
193
- scope={{ kind: "global" }}
194
- onScopeToSpam={() => {}}
193
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
195
194
  sections={[
196
195
  { id: "top", label: "Top matches", results: globalTopMatches },
197
196
  { id: "related", label: "Related", results: related },
@@ -209,8 +208,7 @@ export const GlobalWithoutSpamMatches: Story = {
209
208
  render: () => (
210
209
  <Harness
211
210
  value="invoice"
212
- scope={{ kind: "global" }}
213
- onScopeToSpam={() => {}}
211
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
214
212
  sections={[
215
213
  {
216
214
  id: "top",
@@ -234,8 +232,7 @@ export const GlobalAccountWithoutSpamFolder: Story = {
234
232
  render: () => (
235
233
  <Harness
236
234
  value="invoice"
237
- scope={{ kind: "global" }}
238
- onScopeToSpam={() => {}}
235
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
239
236
  sections={[
240
237
  { id: "top", label: "Top matches", results: crossFolderMatches },
241
238
  ]}
@@ -252,8 +249,7 @@ export const GlobalOnlySpamMatches: Story = {
252
249
  render: () => (
253
250
  <Harness
254
251
  value="invoice"
255
- scope={{ kind: "global" }}
256
- onScopeToSpam={() => {}}
252
+ scope={{ kind: "global", onScopeToSpam: () => {} }}
257
253
  sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
258
254
  />
259
255
  ),
@@ -273,7 +269,6 @@ export const ScopedToInbox: Story = {
273
269
  <Harness
274
270
  value="invoice"
275
271
  scope={{ kind: "folder", role: "inbox" }}
276
- onScopeToSpam={() => {}}
277
272
  sections={[
278
273
  { id: "top", label: "Top matches", results: globalTopMatches },
279
274
  { id: "related", label: "Related", results: related },
@@ -293,7 +288,6 @@ export const ScopedToSpam: Story = {
293
288
  <Harness
294
289
  value="invoice"
295
290
  scope={{ kind: "folder", role: "junk" }}
296
- onScopeToSpam={() => {}}
297
291
  sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
298
292
  />
299
293
  ),
@@ -343,3 +337,23 @@ export const WithFilterTokens: Story = {
343
337
  />
344
338
  ),
345
339
  };
340
+
341
+ /**
342
+ * Starred search (`is:starred` in the bar), given the same rows as the global
343
+ * story. Starring is not a folder: the rows come from all over, so each keeps
344
+ * its provenance label, and the two Spam matches stay in the list rather than
345
+ * being held out. The user starred them; holding their own mail back from them
346
+ * would be the surprising behaviour, and there is no offer because nothing was
347
+ * taken away.
348
+ */
349
+ export const StarredCollection: Story = {
350
+ render: () => (
351
+ <Harness
352
+ value="invoice"
353
+ scope={{ kind: "collection" }}
354
+ sections={[
355
+ { id: "top", label: "Top matches", results: globalTopMatches },
356
+ ]}
357
+ />
358
+ ),
359
+ };
@@ -18,12 +18,22 @@ export interface SearchResultSection {
18
18
  }
19
19
 
20
20
  /**
21
- * What the search currently covers. `global` is the unscoped search the daily
22
- * brief runs — every account, every folder, no chip in the bar. `folder` is a
23
- * search the sidebar narrowed to one place, which the bar shows as a chip.
21
+ * What the search currently covers.
22
+ *
23
+ * - `global` the unscoped search the daily brief runs: every account, every
24
+ * folder, no chip in the bar. It is the only scope that holds spam out, so it
25
+ * is the only one that carries a way back to it: without `onScopeToSpam` there
26
+ * is nowhere to send the user and no offer is made.
27
+ * - `folder` — narrowed to one place by the sidebar, which the bar shows as a
28
+ * chip.
29
+ * - `collection` — narrowed by something that is not a folder, `is:starred`
30
+ * being the one that exists. It still spans folders, so rows carry their
31
+ * provenance; and because the narrowing is the user's own (they starred the
32
+ * mail), spam is not held back from them.
24
33
  */
25
34
  export type SearchScope =
26
- | { kind: "global" }
35
+ | { kind: "global"; onScopeToSpam?: () => void }
36
+ | { kind: "collection" }
27
37
  | { kind: "folder"; role?: FolderRole };
28
38
 
29
39
  const GLOBAL_SCOPE: SearchScope = { kind: "global" };
@@ -74,17 +84,6 @@ export interface SearchResultsProps {
74
84
  tokens?: { label: string; onRemove: () => void }[];
75
85
  /** What the search covers. Defaults to the unscoped, global search. */
76
86
  scope?: SearchScope;
77
- /**
78
- * Total spam matches the search found, for when the app knows more than the
79
- * rows on this page. Defaults to the number of spam rows held out of
80
- * {@link sections}.
81
- */
82
- spamMatchCount?: number;
83
- /**
84
- * Scope the search to Spam. Without it there is no offer, so an app that has
85
- * nowhere to navigate simply never makes one.
86
- */
87
- onScopeToSpam?: () => void;
88
87
  }
89
88
 
90
89
  /**
@@ -177,12 +176,15 @@ function CollapsibleResultSection({
177
176
  *
178
177
  * - **Global** — spam rows are held out of the sections and offered instead, as
179
178
  * a count with a way into a Spam-scoped search.
180
- * - **Scoped to anything else** — spam rows are held out and nothing is offered.
181
- * A scoped search shows its own scope and no more.
179
+ * - **Scoped to a folder** — spam rows are held out and nothing is offered. A
180
+ * scoped search shows its own scope and no more.
182
181
  * - **Scoped to Spam** — ordinary rows, rendered normally, no offer.
182
+ * - **A collection** (`is:starred`) — ordinary rows, spam included. The user
183
+ * picked this mail out by hand; there is nothing to protect them from.
183
184
  *
184
- * Provenance labels follow the same logic: only a global search names the
185
- * folder each row came from, because a scoped one would repeat itself.
185
+ * Provenance labels follow whether the search reaches more than one folder: a
186
+ * global or collection search names the folder each row came from, a
187
+ * folder-scoped one would only repeat its own chip.
186
188
  */
187
189
  export function SearchResults({
188
190
  value,
@@ -193,8 +195,6 @@ export function SearchResults({
193
195
  onSelectResult,
194
196
  tokens,
195
197
  scope = GLOBAL_SCOPE,
196
- spamMatchCount,
197
- onScopeToSpam,
198
198
  }: SearchResultsProps) {
199
199
  const hasQuery = value.trim().length > 0;
200
200
 
@@ -249,11 +249,17 @@ export function SearchResults({
249
249
  );
250
250
  }
251
251
 
252
- const spamScoped = isSpamScope(scope);
253
252
  const isGlobal = scope.kind === "global";
253
+ // Only a folder search holds spam back. Scoped to Spam there is nothing to
254
+ // hold back, and a collection is the user's own selection of mail rather than
255
+ // a place, so a starred spam message stays where the user put it.
256
+ const spamInline = isSpamScope(scope) || scope.kind === "collection";
257
+ // A search that reaches more than one folder names the folder each row came
258
+ // from; one confined to a single folder would repeat its own chip.
259
+ const spansFolders = isGlobal || scope.kind === "collection";
254
260
 
255
261
  const partitioned = (sections ?? []).map((section) => {
256
- if (spamScoped) return { section, spam: [] as SearchResult[] };
262
+ if (spamInline) return { section, spam: [] as SearchResult[] };
257
263
  const { kept, spam } = partitionSpamResults(section.results);
258
264
  return { section: { ...section, results: kept }, spam };
259
265
  });
@@ -263,12 +269,15 @@ export function SearchResults({
263
269
  (total, entry) => total + entry.spam.length,
264
270
  0,
265
271
  );
266
- const spamCount = spamMatchCount ?? heldOutSpamCount;
267
- const spamOffer = isGlobal &&
268
- spamCount > 0 &&
269
- onScopeToSpam !== undefined && (
270
- <SpamResultsOffer count={spamCount} onScopeToSpam={onScopeToSpam} />
271
- );
272
+ const spamOffer =
273
+ scope.kind === "global" &&
274
+ heldOutSpamCount > 0 &&
275
+ scope.onScopeToSpam !== undefined ? (
276
+ <SpamResultsOffer
277
+ count={heldOutSpamCount}
278
+ onScopeToSpam={scope.onScopeToSpam}
279
+ />
280
+ ) : undefined;
272
281
 
273
282
  const hasResults = visibleSections.some(
274
283
  (section) => section.results.length > 0,
@@ -302,7 +311,7 @@ export function SearchResults({
302
311
  key={section.id}
303
312
  section={section}
304
313
  query={value}
305
- showFolder={isGlobal}
314
+ showFolder={spansFolders}
306
315
  onSelectResult={onSelectResult}
307
316
  />
308
317
  ))}
@@ -13,7 +13,7 @@ describe("SpamResultsOffer", () => {
13
13
  );
14
14
  assert.match(html, />3</);
15
15
  assert.match(html, /results from Spam/);
16
- assert.match(html, /View them/);
16
+ assert.match(html, /Go to Spam/);
17
17
  });
18
18
 
19
19
  it("reads in the singular for one match", () => {
@@ -20,6 +20,12 @@ const plural = (n: number): string => (n === 1 ? "result" : "results");
20
20
  * the whole point of the folder is that its contents are unwanted until asked
21
21
  * for. Taking the offer scopes the search to Spam.
22
22
  *
23
+ * The count is every match held out; the action opens Spam. It deliberately
24
+ * does not read "view them", because scope is a single mailbox and a user with
25
+ * several accounts has several Spam folders — the count would then name more
26
+ * mail than the click delivers. Saying where the button goes is true whatever
27
+ * the account setup.
28
+ *
23
29
  * Quiet on purpose: this is an offer, not a warning. Presentational — the
24
30
  * caller owns what "scope to spam" does.
25
31
  */
@@ -44,7 +50,7 @@ export function SpamResultsOffer({
44
50
  onClick={onScopeToSpam}
45
51
  className="shrink-0 text-accent"
46
52
  >
47
- View them
53
+ Go to Spam
48
54
  </Button>
49
55
  </div>
50
56
  </Banner>
@@ -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
+ }