@remit/ui 0.0.18 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/src/components/quarantine-fixtures.ts +3 -3
- package/src/components/quarantine-report.test.ts +2 -2
- package/src/components/quarantine-report.ts +18 -30
- package/src/components/quarantine-section.render.test.ts +1 -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.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src"
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
"@fontsource-variable/geist": "^5",
|
|
19
19
|
"@fontsource-variable/hanken-grotesk": "^5",
|
|
20
20
|
"@fontsource-variable/inter": "^5",
|
|
21
|
+
"@react-types/shared": "^3.36.0",
|
|
21
22
|
"clsx": "^2",
|
|
22
23
|
"dompurify": "^3.4.7",
|
|
23
24
|
"lucide-react": "^0.468",
|
|
25
|
+
"react-aria": "^3.50.0",
|
|
24
26
|
"react-resizable-panels": "^2.1.9",
|
|
25
27
|
"react-simple-pull-to-refresh": "^1.3.4",
|
|
26
28
|
"tailwind-merge": "^2",
|
|
@@ -33,7 +35,9 @@
|
|
|
33
35
|
},
|
|
34
36
|
"devDependencies": {
|
|
35
37
|
"@storybook/react": "^9",
|
|
38
|
+
"@types/jsdom": "^28.0.3",
|
|
36
39
|
"@types/react-dom": "^19",
|
|
40
|
+
"jsdom": "^29.1.1",
|
|
37
41
|
"react": "^19",
|
|
38
42
|
"react-dom": "^19",
|
|
39
43
|
"typescript": "*"
|
|
@@ -15,7 +15,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
|
|
|
15
15
|
mailboxRole: "inbox",
|
|
16
16
|
mailboxPath: "INBOX",
|
|
17
17
|
failureStage: "BodyParse",
|
|
18
|
-
failureCode: "
|
|
18
|
+
failureCode: "UnreadableBody",
|
|
19
19
|
failureMessage: "multipart boundary was never closed",
|
|
20
20
|
failurePartPath: undefined,
|
|
21
21
|
quarantinedAt: Date.parse("2026-07-18T09:12:00Z"),
|
|
@@ -67,7 +67,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
|
|
|
67
67
|
mailboxRole: undefined,
|
|
68
68
|
mailboxPath: "Clients/Acme Holdings",
|
|
69
69
|
failureStage: "BodyParse",
|
|
70
|
-
failureCode: "
|
|
70
|
+
failureCode: "UnreadableBody",
|
|
71
71
|
failureMessage: "stream ended before the declared body length",
|
|
72
72
|
failurePartPath: undefined,
|
|
73
73
|
quarantinedAt: Date.parse("2026-07-19T06:03:00Z"),
|
|
@@ -92,7 +92,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
|
|
|
92
92
|
mailboxRole: "inbox",
|
|
93
93
|
mailboxPath: "INBOX",
|
|
94
94
|
failureStage: "BodyParse",
|
|
95
|
-
failureCode: "
|
|
95
|
+
failureCode: "UnreadableBody",
|
|
96
96
|
failureMessage: "connection closed mid-body",
|
|
97
97
|
failurePartPath: undefined,
|
|
98
98
|
quarantinedAt: Date.parse("2026-07-19T11:27:00Z"),
|
|
@@ -20,7 +20,7 @@ describe("formatQuarantineReport", () => {
|
|
|
20
20
|
it("names the stage, the code and the build that failed", () => {
|
|
21
21
|
const report = formatQuarantineReport(entry);
|
|
22
22
|
assert.match(report, /BodyParse/);
|
|
23
|
-
assert.match(report, /
|
|
23
|
+
assert.match(report, /UnreadableBody/);
|
|
24
24
|
assert.match(report, /worker 1\.0\.0/);
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -186,7 +186,7 @@ describe("quarantineReportSections", () => {
|
|
|
186
186
|
describe("quarantineIssueTitle", () => {
|
|
187
187
|
it("carries only the closed vocabulary", () => {
|
|
188
188
|
const title = quarantineIssueTitle(entry);
|
|
189
|
-
assert.match(title, /
|
|
189
|
+
assert.match(title, /UnreadableBody/);
|
|
190
190
|
assert.match(title, /BodyParse/);
|
|
191
191
|
assert.doesNotMatch(title, /joan@/);
|
|
192
192
|
});
|
|
@@ -3,31 +3,17 @@ import type { FolderRole } from "./folder-role.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* The pipeline step that refused the message.
|
|
5
5
|
*
|
|
6
|
-
* One member,
|
|
7
|
-
* parse
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* One member, behind a real narrowing rather than a promise of one: the body
|
|
7
|
+
* parse has its own try block around the parse call and nothing else, so an
|
|
8
|
+
* error out of it can only be the message. S3, queue and database failures
|
|
9
|
+
* propagate and never become a record here — quarantining one would set a
|
|
10
|
+
* message aside for an outage, tell the user it was unreadable, and invite a
|
|
11
|
+
* public GitHub issue about it.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* S3 write `AttachmentExtract`, one layer up.
|
|
18
|
-
*
|
|
19
|
-
* There are three distinct parse sites, and a quarantine would have to
|
|
20
|
-
* attribute them differently: the fresh-fetch path, `backfillClassification`
|
|
21
|
-
* (its own parse over a different result list), and the header parse in
|
|
22
|
-
* `imapflow-connection.ts`, which is swallowed and treated as "no thread
|
|
23
|
-
* parent". Stages are added as those sites are separated — never ahead of it.
|
|
24
|
-
*
|
|
25
|
-
* The stages this replaced could not fire at all: an unparseable Date falls
|
|
26
|
-
* back to INTERNALDATE, and an unrecognized MIME type maps to a safe default
|
|
27
|
-
* rather than throwing. Unparseable addresses are dropped with a `continue`
|
|
28
|
-
* and the message is written anyway — so that defect is reached but discarded,
|
|
29
|
-
* never raised, which is a reason to have no `AddressParse` stage but not the
|
|
30
|
-
* reason that it cannot happen.
|
|
13
|
+
* A message that arrives with no ENVELOPE is deliberately not a member:
|
|
14
|
+
* nothing can tell that apart from the FETCH row glitching, and the IMAP
|
|
15
|
+
* client is far more often the cause than the message. The sync path holds its
|
|
16
|
+
* cursor and retries instead.
|
|
31
17
|
*/
|
|
32
18
|
export type QuarantineFailureStage = "BodyParse";
|
|
33
19
|
|
|
@@ -36,13 +22,15 @@ export type QuarantineFailureStage = "BodyParse";
|
|
|
36
22
|
*
|
|
37
23
|
* Closed, because `quarantineIssueTitle` interpolates it into a public issue
|
|
38
24
|
* title: a `string` here would make the one field whose publishability is
|
|
39
|
-
* asserted rather than derived into free text.
|
|
40
|
-
*
|
|
25
|
+
* asserted rather than derived into free text.
|
|
26
|
+
*
|
|
27
|
+
* Total and small. The body parser reports its refusals as free text with no
|
|
28
|
+
* stable type or code, so a member exists only where the writer can identify
|
|
29
|
+
* the defect without reading parser prose; anything it cannot name is
|
|
30
|
+
* `UnreadableBody`, and the parser's own words stay in `failureMessage`, which
|
|
31
|
+
* is shown on screen and never published.
|
|
41
32
|
*/
|
|
42
|
-
export type QuarantineFailureCode =
|
|
43
|
-
| "UnterminatedMultipartBoundary"
|
|
44
|
-
| "UnknownCharset"
|
|
45
|
-
| "TruncatedBody";
|
|
33
|
+
export type QuarantineFailureCode = "UnknownCharset" | "UnreadableBody";
|
|
46
34
|
|
|
47
35
|
/**
|
|
48
36
|
* One node of the message's MIME tree, in a pre-order walk.
|
|
@@ -61,7 +61,7 @@ describe("QuarantineBugDialog", () => {
|
|
|
61
61
|
issueUrl: ISSUE_URL,
|
|
62
62
|
}),
|
|
63
63
|
);
|
|
64
|
-
assert.match(html, /
|
|
64
|
+
assert.match(html, /UnreadableBody/);
|
|
65
65
|
assert.match(html, /attachment names, or the parser's own error text/);
|
|
66
66
|
assert.match(html, /Copy report/);
|
|
67
67
|
});
|
|
@@ -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
|
+
}
|