@remit/ui 0.0.47 → 0.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.47",
3
+ "version": "0.0.48",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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/text-selection suppression
112
- // are owned by react-aria; this component only arbitrates the swipe axis.
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. react-aria
232
- // suppresses contextmenu/text-selection but not iOS's callout it
233
- // fires no cancelable event, so CSS is the only lever.
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: "touch",
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
- contextMenuEvent.defaultPrevented,
217
+ dispatchContextMenu(row).defaultPrevented,
203
218
  true,
204
- "react-aria suppresses the link context menu that Android/Chrome fires after a touch long press",
219
+ "the link context menu Android/Chrome fires after a touch long press is suppressed",
205
220
  );
206
221
  });
207
222
 
208
- it("does not suppress contextmenu when no long press occurred", async () => {
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
- const contextMenuEvent = new dom.window.MouseEvent("contextmenu", {
213
- bubbles: true,
214
- cancelable: true,
215
- });
216
- row.dispatchEvent(contextMenuEvent);
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(contextMenuEvent.defaultPrevented, false);
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 { useLongPress as useAriaLongPress } from "react-aria";
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
- return useAriaLongPress({
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
  }