@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 +5 -1
- package/src/components/mobile-search-view.render.test.ts +5 -8
- package/src/components/mobile-search-view.stories.tsx +20 -11
- package/src/components/mobile-search-view.tsx +0 -8
- package/src/components/search-results.render.test.ts +86 -21
- package/src/components/search-results.stories.tsx +24 -10
- package/src/components/search-results.tsx +39 -30
- package/src/components/spam-results-offer.render.test.ts +1 -1
- package/src/components/spam-results-offer.tsx +7 -1
- package/src/components/swipeable-row.gesture.test.ts +258 -0
- package/src/components/swipeable-row.tsx +73 -32
- package/src/index.ts +10 -0
- package/src/lib/use-long-press.test.ts +220 -0
- package/src/lib/use-long-press.ts +50 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/ui",
|
|
3
|
-
"version": "0.0.
|
|
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("
|
|
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,
|
|
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
|
-
|
|
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.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
181
|
-
*
|
|
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
|
|
185
|
-
* folder each row came from,
|
|
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 (
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
onScopeToSpam !== undefined
|
|
270
|
-
<SpamResultsOffer
|
|
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={
|
|
314
|
+
showFolder={spansFolders}
|
|
306
315
|
onSelectResult={onSelectResult}
|
|
307
316
|
/>
|
|
308
317
|
))}
|
|
@@ -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
|
-
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|