@remit/ui 0.0.55 → 0.0.56

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.55",
3
+ "version": "0.0.56",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -11,6 +11,13 @@
11
11
  * The fix tags SwipeableRow's own axis-abort cancel so it can tell the two
12
12
  * apart; these tests exercise both paths against the real hook, not a
13
13
  * description of the fix.
14
+ *
15
+ * The second: a finger held on glass drifts, and it drifts past the distance
16
+ * at which the drag starts tracking. Cancelling the press there meant a real
17
+ * hold on a phone never entered selection mode — the row followed the drift
18
+ * and snapped back, and nothing else happened. The drift cases below run
19
+ * against the anchor (`linkComponent`) row the mailbox list actually renders,
20
+ * which is also where the native link menu has to stay suppressed.
14
21
  */
15
22
 
16
23
  import assert from "node:assert/strict";
@@ -116,6 +123,45 @@ function mount(handlers: Handlers) {
116
123
  return row;
117
124
  }
118
125
 
126
+ // The mailbox list renders the open affordance as a real anchor via
127
+ // `linkComponent`; the native long-press menu only bites on an `<a href>`.
128
+ function mountAnchor(handlers: Handlers) {
129
+ act(() => {
130
+ root.render(
131
+ createElement(SwipeableRow, {
132
+ thread,
133
+ selectionMode: false,
134
+ checked: false,
135
+ active: false,
136
+ peek: "none",
137
+ onPeek: handlers.onPeek,
138
+ onToggleCheck: () => undefined,
139
+ onLongPress: handlers.onLongPress,
140
+ onOpen: handlers.onOpen,
141
+ onAct: () => undefined,
142
+ linkComponent: ({ onOpenClick, children, ...rowProps }) =>
143
+ createElement(
144
+ "a",
145
+ { ...rowProps, id: "row", href: "/thread/1", onClick: onOpenClick },
146
+ children,
147
+ ),
148
+ }),
149
+ );
150
+ });
151
+ const row = dom.window.document.getElementById("row");
152
+ assert.ok(row, "anchor row did not mount");
153
+ return row;
154
+ }
155
+
156
+ function contextMenu(row: Element) {
157
+ const event = new dom.window.MouseEvent("contextmenu", {
158
+ bubbles: true,
159
+ cancelable: true,
160
+ });
161
+ row.dispatchEvent(event);
162
+ return event;
163
+ }
164
+
119
165
  // Each dispatch is wrapped in a synchronous act() so React commits the
120
166
  // resulting state update before the next call reads it. Without this, a
121
167
  // handler in the next dispatch can close over a stale pre-commit value (a
@@ -195,7 +241,7 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
195
241
  );
196
242
  });
197
243
 
198
- it("a horizontal drag past the axis threshold cancels the long press and commits a swipe peek, not onOpen", async () => {
244
+ it("a horizontal drag past the escape distance cancels the long press and commits a swipe peek, not onOpen", async () => {
199
245
  let longPressed = 0;
200
246
  let opened = 0;
201
247
  let committed: SwipePeek | undefined;
@@ -221,7 +267,7 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
221
267
  assert.equal(committed, "leading");
222
268
  });
223
269
 
224
- it("a vertical drag past the axis threshold cancels the long press and lets scroll win (no peek, no open)", async () => {
270
+ it("a vertical drag past the escape distance cancels the long press and lets scroll win (no peek, no open)", async () => {
225
271
  let longPressed = 0;
226
272
  let opened = 0;
227
273
  let peeked = 0;
@@ -255,4 +301,84 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
255
301
 
256
302
  assert.equal(longPressed, 1);
257
303
  });
304
+
305
+ it("a hold that drifts past the axis threshold still enters selection mode", async () => {
306
+ // The reported phone bug: the row tracked the drift (the visible shiver)
307
+ // and the press died, but the drift was too short to commit a peek, so
308
+ // the whole gesture produced nothing.
309
+ let longPressed = 0;
310
+ let opened = 0;
311
+ const peeks: SwipePeek[] = [];
312
+ const row = mountAnchor({
313
+ onLongPress: () => longPressed++,
314
+ onOpen: () => opened++,
315
+ onPeek: (next) => peeks.push(next),
316
+ });
317
+
318
+ pointerDown(row, 100, 200);
319
+ pointerMove(row, 103, 201);
320
+ pointerMove(row, 106, 203);
321
+ pointerMove(row, 109, 205);
322
+ pointerMove(row, 112, 206); // dx=12 — past the axis threshold, under the escape
323
+ await wait(THRESHOLD_WAIT);
324
+ pointerUp(row);
325
+
326
+ assert.equal(longPressed, 1);
327
+ assert.equal(opened, 0);
328
+ assert.deepEqual(peeks, [], "drift must not commit or reset a peek");
329
+ });
330
+
331
+ it("a hold that drifts vertically past the axis threshold still enters selection mode", async () => {
332
+ let longPressed = 0;
333
+ const peeks: SwipePeek[] = [];
334
+ const row = mountAnchor({
335
+ onLongPress: () => longPressed++,
336
+ onOpen: () => undefined,
337
+ onPeek: (next) => peeks.push(next),
338
+ });
339
+
340
+ pointerDown(row, 100, 200);
341
+ pointerMove(row, 101, 206);
342
+ pointerMove(row, 102, 213); // dy=13 — past the axis threshold, under the escape
343
+ await wait(THRESHOLD_WAIT);
344
+ pointerUp(row);
345
+
346
+ assert.equal(longPressed, 1);
347
+ assert.deepEqual(peeks, []);
348
+ });
349
+
350
+ it("an aborted short drag opens nothing on release", async () => {
351
+ // The drag claimed the axis but never reached the escape distance, so the
352
+ // press was still live. react-aria synthesizes a click for an unresolved
353
+ // press, which on the anchor row is a navigation.
354
+ let clicks = 0;
355
+ const row = mountAnchor({
356
+ onLongPress: () => undefined,
357
+ onOpen: () => undefined,
358
+ onPeek: () => undefined,
359
+ });
360
+ row.addEventListener("click", () => clicks++);
361
+
362
+ pointerDown(row, 100, 200);
363
+ pointerMove(row, 120, 201); // dx=20 — past the axis threshold, under the escape
364
+ pointerUp(row);
365
+ await wait(200); // react-aria synthesizes its click 80ms after release
366
+
367
+ assert.equal(clicks, 0);
368
+ });
369
+
370
+ it("suppresses the native link menu raised over a drifting hold", async () => {
371
+ // Android Chrome raises the anchor's context menu at its own threshold,
372
+ // which can land mid-drift and ahead of the app's long press.
373
+ const row = mountAnchor({
374
+ onLongPress: () => undefined,
375
+ onOpen: () => undefined,
376
+ onPeek: () => undefined,
377
+ });
378
+
379
+ pointerDown(row, 100, 200);
380
+ pointerMove(row, 112, 206);
381
+
382
+ assert.equal(contextMenu(row).defaultPrevented, true);
383
+ });
258
384
  });
@@ -192,6 +192,60 @@ export const LongPressToSelect: Story = {
192
192
  },
193
193
  };
194
194
 
195
+ /**
196
+ * The same gesture with a real finger's drift. A hand holding still on glass
197
+ * wanders several pixels over the 500ms hold — further than the distance at
198
+ * which the drag starts tracking the row, which is why a hold used to nudge
199
+ * the row and then do nothing. The swipe only takes the gesture once it has
200
+ * travelled far enough to commit a peek, so the drifting hold below still ends
201
+ * in selection mode. The play step drives the press, the drift and the hold.
202
+ */
203
+ export const LongPressWithDrift: Story = {
204
+ name: "Long press with finger drift (interactive)",
205
+ render: () => {
206
+ const [selected, setSelected] = useState(false);
207
+ return (
208
+ <div className="space-y-2">
209
+ <AnchorRow
210
+ selectionMode={selected}
211
+ checked={selected}
212
+ onLongPress={() => setSelected(true)}
213
+ />
214
+ <p data-testid="drift-outcome" className="text-xs text-fg-muted">
215
+ {selected
216
+ ? "Drifting hold entered selection mode."
217
+ : "Waiting for the drifting hold…"}
218
+ </p>
219
+ </div>
220
+ );
221
+ },
222
+ play: async ({ canvasElement }) => {
223
+ const anchor = canvasElement.querySelector<HTMLAnchorElement>("a[href]");
224
+ if (!anchor) return;
225
+ const touch = (type: string, clientX: number, clientY: number) =>
226
+ anchor.dispatchEvent(
227
+ new PointerEvent(type, {
228
+ bubbles: true,
229
+ pointerType: "touch",
230
+ pointerId: 1,
231
+ clientX,
232
+ clientY,
233
+ }),
234
+ );
235
+ touch("pointerdown", 100, 200);
236
+ for (const [x, y] of [
237
+ [103, 201],
238
+ [106, 203],
239
+ [109, 205],
240
+ [112, 206],
241
+ ]) {
242
+ touch("pointermove", x, y);
243
+ }
244
+ await new Promise((resolve) => setTimeout(resolve, 700));
245
+ touch("pointerup", 112, 206);
246
+ },
247
+ };
248
+
195
249
  /**
196
250
  * A touch long press over a link row normally raises the browser's own link
197
251
  * context menu ("Open in new tab / Copy link address"), which collides with the
@@ -16,20 +16,36 @@ export type SwipePeek = "none" | "leading" | "trailing";
16
16
  /** Width a peeked row settles at to reveal its action; also the drag distance
17
17
  * past which a release commits the peek rather than snapping back. */
18
18
  const SWIPE_ACTION_WIDTH = 72;
19
- /** Movement (px) before a pointer drag claims the horizontal (swipe) axis; below
20
- * this a press is still a tap / long-press and vertical scroll wins. */
19
+ /** Movement (px) before a pointer drag claims an axis; below this a press is
20
+ * still a tap / long-press and vertical scroll wins. Claiming an axis only
21
+ * decides what the row tracks — it does not decide the long press. */
21
22
  const SWIPE_AXIS_THRESHOLD = 10;
23
+ /**
24
+ * Movement (px) along the claimed axis before a drag takes the gesture away
25
+ * from a still-pending long press.
26
+ *
27
+ * A finger resting on glass for the 500ms hold drifts, and it drifts further
28
+ * than the axis threshold — that threshold is calibrated for a drag starting
29
+ * to track, which has to feel immediate. Killing the press at that distance
30
+ * meant a real hold read as a swipe: the row followed the drift, the press
31
+ * died, and on release the drift was too short to commit a peek, so the
32
+ * gesture produced nothing at all.
33
+ *
34
+ * The escape distance is the commit distance, so the swipe only takes the
35
+ * gesture once it has moved far enough to actually produce a peek. Below it a
36
+ * release snaps back, which is to say the swipe was never worth the press.
37
+ */
38
+ const LONG_PRESS_ESCAPE = SWIPE_ACTION_WIDTH / 2;
22
39
 
23
40
  /**
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.
41
+ * Tags a pointercancel dispatched by this component's own arbitration (see
42
+ * `cancelLongPress` below) so `onPointerCancel` can tell it apart from one
43
+ * react-aria dispatches itself when its own long press fires, or a genuine
44
+ * browser-triggered cancel. Both of those end the gesture and must reset it
45
+ * silently; a tagged one arrives *because* `onPointerMove`/`onPointerUp` is
46
+ * already handling that gesture inline, so `onPointerCancel` must ignore it —
47
+ * otherwise it drops the drag and the release reads as a tap, firing a
48
+ * spurious onOpen/onToggleCheck.
33
49
  */
34
50
  const AXIS_CANCEL = "__swipeableRowAxisCancel";
35
51
 
@@ -104,7 +120,8 @@ export function SwipeableRow({
104
120
  startX: number;
105
121
  startY: number;
106
122
  axis: "none" | "horizontal" | "vertical";
107
- moved: boolean;
123
+ /** The drag has passed LONG_PRESS_ESCAPE and already cancelled the press. */
124
+ escaped: boolean;
108
125
  } | null>(null);
109
126
  const [dragX, setDragX] = useState<number | null>(null);
110
127
 
@@ -131,7 +148,7 @@ export function SwipeableRow({
131
148
  startX: e.clientX,
132
149
  startY: e.clientY,
133
150
  axis: "none",
134
- moved: false,
151
+ escaped: false,
135
152
  };
136
153
  };
137
154
 
@@ -143,19 +160,25 @@ export function SwipeableRow({
143
160
  const dy = e.clientY - g.startY;
144
161
  if (g.axis === "none") {
145
162
  if (Math.abs(dy) > SWIPE_AXIS_THRESHOLD && Math.abs(dy) > Math.abs(dx)) {
146
- // vertical scroll wins: abandon the swipe + long-press, let the list scroll
163
+ // vertical scroll wins: stop tracking the swipe, let the list scroll
147
164
  g.axis = "vertical";
148
- cancelLongPress(e);
149
- gesture.current = null;
150
- return;
151
- }
152
- if (Math.abs(dx) > SWIPE_AXIS_THRESHOLD) {
165
+ } else if (
166
+ Math.abs(dx) > SWIPE_AXIS_THRESHOLD &&
167
+ Math.abs(dx) > Math.abs(dy)
168
+ ) {
153
169
  g.axis = "horizontal";
154
- g.moved = true;
155
- cancelLongPress(e);
156
170
  e.currentTarget.setPointerCapture(e.pointerId);
157
171
  }
158
172
  }
173
+ if (g.axis === "none") return;
174
+ // The drag only takes the gesture from a pending long press once it has
175
+ // travelled the commit distance along the axis it claimed (see
176
+ // LONG_PRESS_ESCAPE). Latched so the cancel is dispatched once.
177
+ const travel = g.axis === "horizontal" ? Math.abs(dx) : Math.abs(dy);
178
+ if (!g.escaped && travel > LONG_PRESS_ESCAPE) {
179
+ g.escaped = true;
180
+ cancelLongPress(e);
181
+ }
159
182
  if (g.axis !== "horizontal") return;
160
183
  const base = peekOffset(peek);
161
184
  const next = Math.max(
@@ -165,18 +188,25 @@ export function SwipeableRow({
165
188
  setDragX(next);
166
189
  };
167
190
 
168
- const onPointerUp = () => {
191
+ const onPointerUp = (e: React.PointerEvent) => {
169
192
  const g = gesture.current;
170
193
  gesture.current = null;
171
194
  const offset = dragX;
172
195
  setDragX(null);
173
- // g is null when the gesture was already consumed (long-press fired, or
174
- // a vertical scroll took over) — those handle themselves, so do nothing.
196
+ // g is null when the gesture was already consumed (the long press fired,
197
+ // or a real cancel arrived) — those handle themselves, so do nothing.
175
198
  if (!g) return;
176
- if (g.axis === "horizontal" && offset !== null) {
177
- onPeek(commitPeek(offset));
199
+ // A drag that claimed an axis but never escaped left react-aria's press
200
+ // live, and an unresolved press synthesizes a click on release — which on
201
+ // the anchor row means navigating. The drag was neither a swipe nor a
202
+ // tap, so end the press with it.
203
+ if (g.axis !== "none" && !g.escaped) cancelLongPress(e);
204
+ if (g.axis === "horizontal") {
205
+ if (offset !== null) onPeek(commitPeek(offset));
178
206
  return;
179
207
  }
208
+ // The list scrolled under the finger; the release is not a tap.
209
+ if (g.axis === "vertical") return;
180
210
  // a tap: no axis claimed
181
211
  if (selectionMode) {
182
212
  onToggleCheck();
@@ -192,11 +222,10 @@ export function SwipeableRow({
192
222
  };
193
223
 
194
224
  // 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.
225
+ // or a real browser-triggered interruption (the browser taking over the
226
+ // pan, say) — always resets silently, never as a tap-to-open/toggle/peek
227
+ // -commit. This component's own cancel (tagged, see AXIS_CANCEL) is a no-op
228
+ // here: the handler that dispatched it owns that gesture's state.
200
229
  const onPointerCancel = (e: React.PointerEvent) => {
201
230
  const tagged = (e.nativeEvent as unknown as Record<string, boolean>)[
202
231
  AXIS_CANCEL