@remit/ui 0.0.47 → 0.0.49
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 +1 -1
- package/src/components/filter-rule-editor.stories.tsx +7 -5
- package/src/components/filter-rule-editor.tsx +46 -36
- package/src/components/filter-rule.render.test.ts +28 -15
- package/src/components/swipeable-row.stories.tsx +104 -0
- package/src/components/swipeable-row.tsx +5 -5
- package/src/lib/use-long-press.test.ts +81 -17
- package/src/lib/use-long-press.ts +46 -2
package/package.json
CHANGED
|
@@ -254,11 +254,13 @@ export const DegradedStandingWiden: Story = {
|
|
|
254
254
|
};
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
|
-
* Editing a persisted filter (RFC 038 D6): scope and expiry
|
|
258
|
-
*
|
|
259
|
-
*
|
|
257
|
+
* Editing a persisted filter (RFC 038 D6, reader #266): scope and expiry stay
|
|
258
|
+
* live and editable — a standing filter can move to "until a date" and back,
|
|
259
|
+
* or its date can change. The semantic anchor is the one thing fixed at
|
|
260
|
+
* creation: the widen chip renders display-only with a one-line note, and
|
|
261
|
+
* "Just once" drops out of the scope toggle since no saved filter can hold it.
|
|
260
262
|
*/
|
|
261
|
-
export const
|
|
263
|
+
export const AnchorLocked: Story = {
|
|
262
264
|
args: {
|
|
263
265
|
rule: {
|
|
264
266
|
...demoRule,
|
|
@@ -269,7 +271,7 @@ export const LifecycleLocked: Story = {
|
|
|
269
271
|
folders: demoFolders,
|
|
270
272
|
preview: READY(31),
|
|
271
273
|
semanticAvailable: false,
|
|
272
|
-
|
|
274
|
+
anchorLocked: true,
|
|
273
275
|
},
|
|
274
276
|
};
|
|
275
277
|
|
|
@@ -56,14 +56,20 @@ export interface FilterRuleEditorProps {
|
|
|
56
56
|
*/
|
|
57
57
|
clauseFields?: ClauseField[];
|
|
58
58
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
59
|
+
* Whether the semantic anchor is fixed and cannot be added, removed, or
|
|
60
|
+
* repointed (RFC 038 D6, reader #266). True for an existing, persisted
|
|
61
|
+
* filter — the update endpoint carries no anchor field at all, so
|
|
62
|
+
* repointing one would silently change what a saved filter matches with
|
|
63
|
+
* nothing visible changing, and that deserves a new filter instead. The
|
|
64
|
+
* widen chip renders display-only (no remove affordance regardless of
|
|
65
|
+
* `onRemoveWiden`) and carries a one-line note explaining why.
|
|
66
|
+
*
|
|
67
|
+
* Scope and expiry are NOT covered by this flag — they stay editable even
|
|
68
|
+
* on an existing filter (reader #266). The only other thing this locks is
|
|
69
|
+
* the "Just once" scope option, meaningless for an already-persisted
|
|
70
|
+
* filter, which is dropped from the scope toggle.
|
|
65
71
|
*/
|
|
66
|
-
|
|
72
|
+
anchorLocked?: boolean;
|
|
67
73
|
/** The inline clause form, when adding or editing a clause. */
|
|
68
74
|
clauseEdit?: ClauseEditState;
|
|
69
75
|
onStartAddClause?: () => void;
|
|
@@ -252,7 +258,7 @@ export function FilterRuleEditor({
|
|
|
252
258
|
notice,
|
|
253
259
|
semanticAvailable = false,
|
|
254
260
|
clauseFields,
|
|
255
|
-
|
|
261
|
+
anchorLocked = false,
|
|
256
262
|
clauseEdit,
|
|
257
263
|
onStartAddClause,
|
|
258
264
|
onStartEditClause,
|
|
@@ -278,6 +284,12 @@ export function FilterRuleEditor({
|
|
|
278
284
|
const join = matchJoinWord(rule.matchOperator);
|
|
279
285
|
const blockedReason = commitBlockedReason(rule, preview);
|
|
280
286
|
const needsName = rule.scope === "standing" || rule.scope === "until";
|
|
287
|
+
// A persisted filter is always Standing or Temporary — "once" was never a
|
|
288
|
+
// scope a saved row could hold, so an existing filter's editor never offers
|
|
289
|
+
// it (reader #266).
|
|
290
|
+
const availableScopeOptions = anchorLocked
|
|
291
|
+
? scopeOptions.filter((option) => option.value !== "once")
|
|
292
|
+
: scopeOptions;
|
|
281
293
|
|
|
282
294
|
return (
|
|
283
295
|
<div className="flex min-h-0 flex-col">
|
|
@@ -314,16 +326,22 @@ export function FilterRuleEditor({
|
|
|
314
326
|
{join}
|
|
315
327
|
</span>
|
|
316
328
|
)}
|
|
317
|
-
<WidenChip
|
|
329
|
+
<WidenChip
|
|
330
|
+
widen={rule.widen}
|
|
331
|
+
onRemove={anchorLocked ? undefined : onRemoveWiden}
|
|
332
|
+
/>
|
|
318
333
|
</>
|
|
319
334
|
)}
|
|
320
335
|
|
|
321
336
|
{!clauseEdit && (
|
|
322
337
|
<AddChipButton label="Add clause" onClick={onStartAddClause} />
|
|
323
338
|
)}
|
|
324
|
-
{!rule.widen &&
|
|
325
|
-
|
|
326
|
-
|
|
339
|
+
{!rule.widen &&
|
|
340
|
+
!anchorLocked &&
|
|
341
|
+
semanticAvailable &&
|
|
342
|
+
!clauseEdit && (
|
|
343
|
+
<AddChipButton label="…and similar" onClick={onAddWiden} />
|
|
344
|
+
)}
|
|
327
345
|
</div>
|
|
328
346
|
|
|
329
347
|
{clauseEdit && (
|
|
@@ -338,6 +356,13 @@ export function FilterRuleEditor({
|
|
|
338
356
|
/>
|
|
339
357
|
)}
|
|
340
358
|
|
|
359
|
+
{anchorLocked && rule.widen && (
|
|
360
|
+
<p className="text-2xs text-fg-subtle">
|
|
361
|
+
This filter's similar-mail match is fixed to the message it was
|
|
362
|
+
created from — create a new filter to change it.
|
|
363
|
+
</p>
|
|
364
|
+
)}
|
|
365
|
+
|
|
341
366
|
{showOperator && (
|
|
342
367
|
<div className="flex items-center gap-2">
|
|
343
368
|
<span className="text-xs text-fg-muted">Match</span>
|
|
@@ -374,22 +399,14 @@ export function FilterRuleEditor({
|
|
|
374
399
|
|
|
375
400
|
<section className="space-y-2">
|
|
376
401
|
<p className="text-xs font-medium text-fg-muted">How long</p>
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
name="rule-scope"
|
|
386
|
-
size="sm"
|
|
387
|
-
aria-label="Rule scope"
|
|
388
|
-
options={scopeOptions}
|
|
389
|
-
value={rule.scope}
|
|
390
|
-
onChange={(value) => onChangeScope?.(value)}
|
|
391
|
-
/>
|
|
392
|
-
)}
|
|
402
|
+
<SegmentedControl
|
|
403
|
+
name="rule-scope"
|
|
404
|
+
size="sm"
|
|
405
|
+
aria-label="Rule scope"
|
|
406
|
+
options={availableScopeOptions}
|
|
407
|
+
value={rule.scope}
|
|
408
|
+
onChange={(value) => onChangeScope?.(value)}
|
|
409
|
+
/>
|
|
393
410
|
{needsName && (
|
|
394
411
|
<Input
|
|
395
412
|
value={rule.name ?? ""}
|
|
@@ -399,7 +416,7 @@ export function FilterRuleEditor({
|
|
|
399
416
|
className="w-full"
|
|
400
417
|
/>
|
|
401
418
|
)}
|
|
402
|
-
{
|
|
419
|
+
{rule.scope === "until" && (
|
|
403
420
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
|
404
421
|
<span>Until</span>
|
|
405
422
|
<Input
|
|
@@ -411,13 +428,6 @@ export function FilterRuleEditor({
|
|
|
411
428
|
/>
|
|
412
429
|
</div>
|
|
413
430
|
)}
|
|
414
|
-
{lifecycleLocked && (
|
|
415
|
-
<p className="text-2xs text-fg-subtle">
|
|
416
|
-
{rule.widen
|
|
417
|
-
? "The scope, expiry, and similar-mail match are set when a filter is created."
|
|
418
|
-
: "The scope and expiry are set when a filter is created."}
|
|
419
|
-
</p>
|
|
420
|
-
)}
|
|
421
431
|
</section>
|
|
422
432
|
|
|
423
433
|
<div className="border-t border-line pt-3">
|
|
@@ -555,35 +555,48 @@ describe("FilterRuleEditor", () => {
|
|
|
555
555
|
assert.match(editor(), /47 messages match/);
|
|
556
556
|
});
|
|
557
557
|
|
|
558
|
-
it("
|
|
558
|
+
it("keeps scope and expiry live and editable on an anchor-locked (existing) filter (reader #266)", () => {
|
|
559
559
|
const html = editor({
|
|
560
560
|
rule: { ...demoRule, scope: "until", until: "2027-09-01" },
|
|
561
|
-
|
|
561
|
+
anchorLocked: true,
|
|
562
562
|
});
|
|
563
|
-
|
|
564
|
-
assert.
|
|
565
|
-
assert.doesNotMatch(html, /aria-label="Expiry date"/);
|
|
566
|
-
assert.match(html, /Until 2027-09-01/);
|
|
567
|
-
assert.match(html, /set when a filter is created/);
|
|
568
|
-
// The name stays editable.
|
|
563
|
+
assert.match(html, /aria-label="Rule scope"/);
|
|
564
|
+
assert.match(html, /aria-label="Expiry date"/);
|
|
569
565
|
assert.match(html, /aria-label="Rule name"/);
|
|
570
566
|
});
|
|
571
567
|
|
|
572
|
-
it("
|
|
568
|
+
it("drops the once option from the scope toggle on an anchor-locked filter", () => {
|
|
569
|
+
const locked = editor({ anchorLocked: true });
|
|
570
|
+
assert.doesNotMatch(locked, />Just once</);
|
|
571
|
+
const unlocked = editor({ anchorLocked: false });
|
|
572
|
+
assert.match(unlocked, />Just once</);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("locks the widen chip and explains why only when one is present", () => {
|
|
573
576
|
const withWiden = editor({
|
|
574
577
|
rule: { ...demoRule, widen: { anchorCount: 2 } },
|
|
575
|
-
|
|
578
|
+
anchorLocked: true,
|
|
576
579
|
});
|
|
577
|
-
assert.
|
|
580
|
+
assert.doesNotMatch(
|
|
578
581
|
withWiden,
|
|
579
|
-
/similar-mail
|
|
582
|
+
/aria-label="Remove the similar-mail widen"/,
|
|
580
583
|
);
|
|
584
|
+
assert.match(withWiden, /similar-mail match is fixed to the message/);
|
|
585
|
+
|
|
581
586
|
const literal = editor({
|
|
582
587
|
rule: { ...demoRule, widen: undefined },
|
|
583
|
-
|
|
588
|
+
anchorLocked: true,
|
|
589
|
+
});
|
|
590
|
+
assert.doesNotMatch(literal, /similar-mail match is fixed/);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("never offers to add a widen on an anchor-locked filter, even when the deployment can serve it", () => {
|
|
594
|
+
const html = editor({
|
|
595
|
+
rule: { ...demoRule, widen: undefined },
|
|
596
|
+
anchorLocked: true,
|
|
597
|
+
semanticAvailable: true,
|
|
584
598
|
});
|
|
585
|
-
assert.
|
|
586
|
-
assert.doesNotMatch(literal, /similar-mail match/);
|
|
599
|
+
assert.doesNotMatch(html, /…and similar/);
|
|
587
600
|
});
|
|
588
601
|
});
|
|
589
602
|
|
|
@@ -129,3 +129,107 @@ export const Acting: Story = {
|
|
|
129
129
|
);
|
|
130
130
|
},
|
|
131
131
|
};
|
|
132
|
+
|
|
133
|
+
function AnchorRow({
|
|
134
|
+
onLongPress,
|
|
135
|
+
selectionMode,
|
|
136
|
+
checked,
|
|
137
|
+
}: {
|
|
138
|
+
onLongPress?: () => void;
|
|
139
|
+
selectionMode?: boolean;
|
|
140
|
+
checked?: boolean;
|
|
141
|
+
}) {
|
|
142
|
+
return (
|
|
143
|
+
<PhoneFrame>
|
|
144
|
+
<SwipeableRow
|
|
145
|
+
{...baseArgs}
|
|
146
|
+
peek="none"
|
|
147
|
+
selectionMode={selectionMode ?? false}
|
|
148
|
+
checked={checked ?? false}
|
|
149
|
+
onLongPress={onLongPress ?? (() => undefined)}
|
|
150
|
+
linkComponent={({ onOpenClick, children, ...rowProps }) => (
|
|
151
|
+
<a
|
|
152
|
+
{...rowProps}
|
|
153
|
+
href="/mail/inbox?selectedMessageId=thread-1"
|
|
154
|
+
onClick={(e) => {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
onOpenClick(e);
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{children}
|
|
160
|
+
</a>
|
|
161
|
+
)}
|
|
162
|
+
/>
|
|
163
|
+
</PhoneFrame>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The long press is the way into multi-select on touch. Press and hold the row
|
|
169
|
+
* (mouse hold works too — react-aria fires the long press for both) and it
|
|
170
|
+
* flips into the selection state the `SelectionChecked` story shows: the
|
|
171
|
+
* leading avatar becomes a filled, ticked checkbox and a tap toggles the row
|
|
172
|
+
* instead of opening it.
|
|
173
|
+
*/
|
|
174
|
+
export const LongPressToSelect: Story = {
|
|
175
|
+
name: "Long press to select (interactive)",
|
|
176
|
+
render: () => {
|
|
177
|
+
const [selected, setSelected] = useState(false);
|
|
178
|
+
return (
|
|
179
|
+
<div className="space-y-2">
|
|
180
|
+
<AnchorRow
|
|
181
|
+
selectionMode={selected}
|
|
182
|
+
checked={selected}
|
|
183
|
+
onLongPress={() => setSelected((v) => !v)}
|
|
184
|
+
/>
|
|
185
|
+
<p className="text-xs text-fg-muted">
|
|
186
|
+
{selected
|
|
187
|
+
? "In selection mode — long press again to exit."
|
|
188
|
+
: "Press and hold the row."}
|
|
189
|
+
</p>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A touch long press over a link row normally raises the browser's own link
|
|
197
|
+
* context menu ("Open in new tab / Copy link address"), which collides with the
|
|
198
|
+
* long-press-to-select gesture above. `useLongPress` suppresses that menu when
|
|
199
|
+
* the press came from touch or pen, while leaving a mouse right-click's menu
|
|
200
|
+
* alone. The play step drives a synthetic touch press then a contextmenu and
|
|
201
|
+
* writes the outcome below.
|
|
202
|
+
*/
|
|
203
|
+
export const TouchContextMenuSuppressed: Story = {
|
|
204
|
+
name: "Touch context menu suppressed",
|
|
205
|
+
render: () => (
|
|
206
|
+
<div className="space-y-2">
|
|
207
|
+
<AnchorRow />
|
|
208
|
+
<p data-testid="context-menu-outcome" className="text-xs text-fg-muted">
|
|
209
|
+
Waiting for a touch press…
|
|
210
|
+
</p>
|
|
211
|
+
</div>
|
|
212
|
+
),
|
|
213
|
+
play: async ({ canvasElement }) => {
|
|
214
|
+
const anchor = canvasElement.querySelector<HTMLAnchorElement>("a[href]");
|
|
215
|
+
const outcome = canvasElement.querySelector<HTMLParagraphElement>(
|
|
216
|
+
'[data-testid="context-menu-outcome"]',
|
|
217
|
+
);
|
|
218
|
+
if (!anchor || !outcome) return;
|
|
219
|
+
anchor.dispatchEvent(
|
|
220
|
+
new PointerEvent("pointerdown", {
|
|
221
|
+
bubbles: true,
|
|
222
|
+
pointerType: "touch",
|
|
223
|
+
pointerId: 1,
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
const menu = new MouseEvent("contextmenu", {
|
|
227
|
+
bubbles: true,
|
|
228
|
+
cancelable: true,
|
|
229
|
+
});
|
|
230
|
+
anchor.dispatchEvent(menu);
|
|
231
|
+
outcome.textContent = menu.defaultPrevented
|
|
232
|
+
? "Native context menu suppressed on touch."
|
|
233
|
+
: "Native context menu allowed.";
|
|
234
|
+
},
|
|
235
|
+
};
|
|
@@ -108,8 +108,8 @@ export function SwipeableRow({
|
|
|
108
108
|
} | null>(null);
|
|
109
109
|
const [dragX, setDragX] = useState<number | null>(null);
|
|
110
110
|
|
|
111
|
-
// Long-press timing/threshold and contextmenu
|
|
112
|
-
//
|
|
111
|
+
// Long-press timing/threshold and touch contextmenu suppression are owned
|
|
112
|
+
// by useLongPress; this component only arbitrates the swipe axis.
|
|
113
113
|
const { longPressProps } = useLongPress({
|
|
114
114
|
onLongPress,
|
|
115
115
|
isDisabled: selectionMode,
|
|
@@ -228,9 +228,9 @@ export function SwipeableRow({
|
|
|
228
228
|
"relative touch-pan-y bg-surface",
|
|
229
229
|
// This row's long press enters selection mode; without these, Android
|
|
230
230
|
// Chrome opens the link context menu / starts text selection and iOS
|
|
231
|
-
// Safari fires the callout, racing the app's handler.
|
|
232
|
-
// suppresses
|
|
233
|
-
//
|
|
231
|
+
// Safari fires the callout, racing the app's handler. useLongPress
|
|
232
|
+
// suppresses the touch-fired contextmenu; the callout fires no
|
|
233
|
+
// cancelable event, so CSS is the only lever left for it.
|
|
234
234
|
"select-none [-webkit-touch-callout:none]",
|
|
235
235
|
comfortableRowClass({ active: checked || active }),
|
|
236
236
|
);
|
|
@@ -59,11 +59,11 @@ function mount(props: {
|
|
|
59
59
|
return row;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function pointerDown(row: Element) {
|
|
62
|
+
function pointerDown(row: Element, pointerType = "touch") {
|
|
63
63
|
row.dispatchEvent(
|
|
64
64
|
new dom.window.PointerEvent("pointerdown", {
|
|
65
65
|
bubbles: true,
|
|
66
|
-
pointerType
|
|
66
|
+
pointerType,
|
|
67
67
|
pointerId: 1,
|
|
68
68
|
clientX: 10,
|
|
69
69
|
clientY: 10,
|
|
@@ -71,6 +71,15 @@ function pointerDown(row: Element) {
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
function dispatchContextMenu(row: Element) {
|
|
75
|
+
const event = new dom.window.MouseEvent("contextmenu", {
|
|
76
|
+
bubbles: true,
|
|
77
|
+
cancelable: true,
|
|
78
|
+
});
|
|
79
|
+
row.dispatchEvent(event);
|
|
80
|
+
return event;
|
|
81
|
+
}
|
|
82
|
+
|
|
74
83
|
function pointerUp() {
|
|
75
84
|
dom.window.document.dispatchEvent(
|
|
76
85
|
new dom.window.PointerEvent("pointerup", {
|
|
@@ -83,6 +92,18 @@ function pointerUp() {
|
|
|
83
92
|
);
|
|
84
93
|
}
|
|
85
94
|
|
|
95
|
+
function pointerUpOn(row: Element) {
|
|
96
|
+
row.dispatchEvent(
|
|
97
|
+
new dom.window.PointerEvent("pointerup", {
|
|
98
|
+
bubbles: true,
|
|
99
|
+
pointerType: "touch",
|
|
100
|
+
pointerId: 1,
|
|
101
|
+
clientX: 10,
|
|
102
|
+
clientY: 10,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
function pointerCancel(row: Element) {
|
|
87
108
|
row.dispatchEvent(
|
|
88
109
|
new dom.window.PointerEvent("pointercancel", { bubbles: true }),
|
|
@@ -192,29 +213,72 @@ describe("useLongPress (react-aria wrapper)", () => {
|
|
|
192
213
|
"long press must have fired for this to be meaningful",
|
|
193
214
|
);
|
|
194
215
|
|
|
195
|
-
const contextMenuEvent = new dom.window.MouseEvent("contextmenu", {
|
|
196
|
-
bubbles: true,
|
|
197
|
-
cancelable: true,
|
|
198
|
-
});
|
|
199
|
-
row.dispatchEvent(contextMenuEvent);
|
|
200
|
-
|
|
201
216
|
assert.equal(
|
|
202
|
-
|
|
217
|
+
dispatchContextMenu(row).defaultPrevented,
|
|
203
218
|
true,
|
|
204
|
-
"
|
|
219
|
+
"the link context menu Android/Chrome fires after a touch long press is suppressed",
|
|
205
220
|
);
|
|
206
221
|
});
|
|
207
222
|
|
|
208
|
-
it("
|
|
223
|
+
it("suppresses the touch contextmenu even before the long-press threshold", async () => {
|
|
224
|
+
// Android Chrome can raise the link menu at its own threshold, ahead of
|
|
225
|
+
// the app's long press; keying suppression to the pointer type rather
|
|
226
|
+
// than to a fired long press covers that race.
|
|
227
|
+
const row = mount({ onLongPress: () => undefined });
|
|
228
|
+
|
|
229
|
+
pointerDown(row, "touch");
|
|
230
|
+
assert.equal(dispatchContextMenu(row).defaultPrevented, true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not suppress contextmenu from a mouse right-click", async () => {
|
|
234
|
+
// Desktop right-click must keep its native context menu; a mouse
|
|
235
|
+
// pointerdown precedes the contextmenu, so the pointer type is known.
|
|
236
|
+
const row = mount({ onLongPress: () => undefined });
|
|
237
|
+
|
|
238
|
+
pointerDown(row, "mouse");
|
|
239
|
+
assert.equal(dispatchContextMenu(row).defaultPrevented, false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("does not suppress contextmenu when no pointer interaction preceded it", async () => {
|
|
209
243
|
mount({ onLongPress: () => undefined });
|
|
210
244
|
const row = dom.window.document.getElementById("row") as Element;
|
|
211
245
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
246
|
+
assert.equal(dispatchContextMenu(row).defaultPrevented, false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("does not suppress the keyboard menu that follows a touch long press", async () => {
|
|
250
|
+
// The reported a11y regression: the touch press's pointer type must not
|
|
251
|
+
// linger and suppress the keyboard-invoked menu (Context-Menu key /
|
|
252
|
+
// Shift+F10), which fires with no preceding pointerdown.
|
|
253
|
+
const row = mount({ onLongPress: () => undefined });
|
|
254
|
+
|
|
255
|
+
pointerDown(row, "touch");
|
|
256
|
+
assert.equal(
|
|
257
|
+
dispatchContextMenu(row).defaultPrevented,
|
|
258
|
+
true,
|
|
259
|
+
"the touch long-press menu is still suppressed",
|
|
260
|
+
);
|
|
261
|
+
pointerUpOn(row);
|
|
262
|
+
await wait(THRESHOLD);
|
|
263
|
+
|
|
264
|
+
assert.equal(
|
|
265
|
+
dispatchContextMenu(row).defaultPrevented,
|
|
266
|
+
false,
|
|
267
|
+
"the later keyboard-invoked menu must not inherit the touch press's type",
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("does not suppress the keyboard menu after a touch tap that raised no menu", async () => {
|
|
272
|
+
// A tap that lifts without a menu must still disarm suppression. The wait
|
|
273
|
+
// clears react-aria's own transient post-touch contextmenu listener,
|
|
274
|
+
// which it removes shortly after pointerup — in a browser a keyboard menu
|
|
275
|
+
// arrives long after that window, so only this hook's ref decides.
|
|
276
|
+
const row = mount({ onLongPress: () => undefined });
|
|
277
|
+
|
|
278
|
+
pointerDown(row, "touch");
|
|
279
|
+
pointerUpOn(row);
|
|
280
|
+
await wait(THRESHOLD);
|
|
217
281
|
|
|
218
|
-
assert.equal(
|
|
282
|
+
assert.equal(dispatchContextMenu(row).defaultPrevented, false);
|
|
219
283
|
});
|
|
220
284
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DOMAttributes } from "@react-types/shared";
|
|
2
|
-
import {
|
|
2
|
+
import { type PointerEvent, useCallback, useRef } from "react";
|
|
3
|
+
import { mergeProps, useLongPress as useAriaLongPress } from "react-aria";
|
|
3
4
|
|
|
4
5
|
export interface UseLongPressOptions {
|
|
5
6
|
/** Called once the threshold elapses while the press stays over the target. */
|
|
@@ -31,6 +32,15 @@ export interface UseLongPressResult {
|
|
|
31
32
|
* `-webkit-touch-callout: none` in CSS at the call site, since iOS fires no
|
|
32
33
|
* cancelable event for it.
|
|
33
34
|
*
|
|
35
|
+
* The `contextmenu` suppression is keyed to the active pointer's type, tracked
|
|
36
|
+
* off `pointerdown` on the same element: a touch or pen press suppresses the
|
|
37
|
+
* menu Android Chrome and iOS Safari raise on a long press over a link, while a
|
|
38
|
+
* mouse right-click is left alone so the desktop context menu keeps working. It
|
|
39
|
+
* does not delegate this to react-aria's own suppression — that listener is
|
|
40
|
+
* transient (added on press start, scoped to the touched node, and torn down
|
|
41
|
+
* shortly after pointerup), so a press ended early by the swipe gesture's axis
|
|
42
|
+
* arbitration, or a menu raised over a descendant node, slips past it.
|
|
43
|
+
*
|
|
34
44
|
* Single source of truth for the app's long-press threshold — both mobile
|
|
35
45
|
* row consumers (the plain row and the swipeable row) go through this hook
|
|
36
46
|
* so their timing can't drift apart again.
|
|
@@ -41,10 +51,44 @@ export function useLongPress({
|
|
|
41
51
|
delayMs = 500,
|
|
42
52
|
accessibilityDescription,
|
|
43
53
|
}: UseLongPressOptions): UseLongPressResult {
|
|
44
|
-
|
|
54
|
+
const { longPressProps } = useAriaLongPress({
|
|
45
55
|
isDisabled,
|
|
46
56
|
threshold: delayMs,
|
|
47
57
|
accessibilityDescription,
|
|
48
58
|
onLongPress,
|
|
49
59
|
});
|
|
60
|
+
|
|
61
|
+
const pointerTypeRef = useRef<string>("");
|
|
62
|
+
|
|
63
|
+
const onPointerDown = useCallback((event: PointerEvent) => {
|
|
64
|
+
pointerTypeRef.current = event.pointerType;
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
// A press that lifts without raising a menu disarms suppression, so a later
|
|
68
|
+
// keyboard-invoked menu can't inherit its pointer type. Not cleared on
|
|
69
|
+
// pointercancel: on Android the browser (and react-aria's own long-press
|
|
70
|
+
// timer) can fire pointercancel before the long-press contextmenu, which
|
|
71
|
+
// would race the suppression away.
|
|
72
|
+
const onPointerUp = useCallback(() => {
|
|
73
|
+
pointerTypeRef.current = "";
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const onContextMenu = useCallback((event: { preventDefault: () => void }) => {
|
|
77
|
+
// Consume the armed pointer type. A keyboard-invoked menu (Context-Menu
|
|
78
|
+
// key / Shift+F10) fires no pointerdown, so without spending the type on
|
|
79
|
+
// use it would inherit the last touch press's and be wrongly suppressed.
|
|
80
|
+
const pointerType = pointerTypeRef.current;
|
|
81
|
+
pointerTypeRef.current = "";
|
|
82
|
+
if (pointerType === "touch" || pointerType === "pen") {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
}
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
longPressProps: mergeProps(longPressProps, {
|
|
89
|
+
onPointerDown,
|
|
90
|
+
onPointerUp,
|
|
91
|
+
onContextMenu,
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
50
94
|
}
|