@remit/web-client 0.0.28 → 0.0.29

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/web-client",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -7,14 +7,15 @@ import {
7
7
  comfortableRowClass,
8
8
  compactRowClass,
9
9
  type Density,
10
+ mergeProps,
10
11
  type SenderTrustLevel,
11
12
  type ThreadRowData,
13
+ useLongPress,
12
14
  } from "@remit/ui";
13
15
  import { useQueryClient } from "@tanstack/react-query";
14
16
  import { Link } from "@tanstack/react-router";
15
17
  import { Check } from "lucide-react";
16
18
  import { type MouseEvent, memo, useCallback } from "react";
17
- import { useLongPress } from "@/hooks/useLongPress";
18
19
  import type { SelectionModifiers } from "@/hooks/useSelection";
19
20
  import { toDisplayCategory } from "@/lib/display-category";
20
21
  import { formatEmailDate } from "@/lib/format";
@@ -125,16 +126,27 @@ const MessageListItemComponent = ({
125
126
 
126
127
  // Desktop mouse selection semantics. Plain click falls through to the Link's
127
128
  // navigation; shift / cmd / ctrl click is routed to selection and the
128
- // navigation is suppressed. On mobile this is a no-op so taps still open.
129
+ // navigation is suppressed.
129
130
  //
130
131
  // A modified click must preventDefault: the router skips navigation for any
131
132
  // modified click and leaves the anchor's own default in place, which in a
132
133
  // browser means shift-click opens a new window and cmd-click a new tab.
133
134
  // Shift-click also drags a native text selection across the rows it spans,
134
135
  // so drop it — the row highlight is the selection the user asked for.
136
+ //
137
+ // On mobile, once selection mode is active (#92) a plain tap toggles the
138
+ // row instead of opening it — the same tap-to-toggle contract every
139
+ // reference mail client uses once you're mid-selection. Outside selection
140
+ // mode a short tap still falls through to the Link's native navigation.
135
141
  const handleRowClick = useCallback(
136
142
  (e: MouseEvent) => {
137
- if (!isDesktop) return;
143
+ if (!isDesktop) {
144
+ if (!isMultiSelectMode) return;
145
+ e.preventDefault();
146
+ e.stopPropagation();
147
+ onToggleCheck(messageId);
148
+ return;
149
+ }
138
150
  const modifiers = {
139
151
  shiftKey: e.shiftKey,
140
152
  metaKey: e.metaKey,
@@ -148,7 +160,7 @@ const MessageListItemComponent = ({
148
160
  window.getSelection()?.removeAllRanges();
149
161
  }
150
162
  },
151
- [isDesktop, onRowSelect, messageId],
163
+ [isDesktop, isMultiSelectMode, onToggleCheck, onRowSelect, messageId],
152
164
  );
153
165
 
154
166
  // Shift-click starts a native text selection on mousedown; suppressing it
@@ -165,9 +177,10 @@ const MessageListItemComponent = ({
165
177
  onLongPress?.(messageId);
166
178
  }, [onLongPress, messageId]);
167
179
 
168
- const longPressHandlers = useLongPress({
180
+ const { longPressProps } = useLongPress({
169
181
  onLongPress: handleLongPress,
170
182
  delayMs: 500,
183
+ accessibilityDescription: isChecked ? "Deselect message" : "Select message",
171
184
  });
172
185
 
173
186
  // Intent-based prefetch: by the time the user clicks, the body is in
@@ -185,18 +198,25 @@ const MessageListItemComponent = ({
185
198
  onFocusRow?.(messageId);
186
199
  }, [prefetchMessage, onFocusRow, messageId]);
187
200
 
188
- // Listbox semantics + roving tabindex, shared by both densities.
189
- const rowInteractionProps = {
190
- "data-message-row": true,
191
- "data-message-id": messageId,
192
- role: "option" as const,
193
- "aria-selected": isChecked,
194
- tabIndex: isTabStop ? 0 : -1,
195
- onClick: handleRowClick,
196
- onMouseDown: handleRowMouseDown,
197
- onMouseEnter: prefetchMessage,
198
- onFocus: handleRowFocus,
199
- };
201
+ // Listbox semantics + roving tabindex, shared by both densities. Merged
202
+ // (not spread) with the mobile long-press props: react-aria's pressProps
203
+ // carries its own onClick for its internal press bookkeeping, and a plain
204
+ // object spread would silently drop whichever onClick landed second
205
+ // instead of running both.
206
+ const rowInteractionProps = mergeProps(
207
+ {
208
+ "data-message-row": true,
209
+ "data-message-id": messageId,
210
+ role: "option" as const,
211
+ "aria-selected": isChecked,
212
+ tabIndex: isTabStop ? 0 : -1,
213
+ onClick: handleRowClick,
214
+ onMouseDown: handleRowMouseDown,
215
+ onMouseEnter: prefetchMessage,
216
+ onFocus: handleRowFocus,
217
+ },
218
+ isDesktop ? {} : longPressProps,
219
+ );
200
220
 
201
221
  const unread = !thread.isRead;
202
222
 
@@ -210,12 +230,16 @@ const MessageListItemComponent = ({
210
230
  selectedMessageId: thread.messageId,
211
231
  })}
212
232
  {...rowInteractionProps}
213
- {...(!isDesktop && longPressHandlers.handlers)}
214
233
  className={cn(
215
234
  compactRowClass({ active: isSelected, focused: isFocused }),
216
235
  "outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
217
236
  isChecked && "bg-accent-soft",
218
- !isDesktop && "min-h-11",
237
+ // Long-press enters selection mode; without these, Android Chrome
238
+ // opens the link context menu / starts text selection and iOS
239
+ // Safari fires the callout, racing the app's handler. react-aria
240
+ // suppresses contextmenu/text-selection but not iOS's callout —
241
+ // it fires no cancelable event, so CSS is the only lever.
242
+ !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
219
243
  )}
220
244
  >
221
245
  <CompactRowBody thread={rowData} />
@@ -235,13 +259,13 @@ const MessageListItemComponent = ({
235
259
  selectedMessageId: thread.messageId,
236
260
  })}
237
261
  {...rowInteractionProps}
238
- {...(!isDesktop && longPressHandlers.handlers)}
239
262
  className={cn(
240
263
  "group",
241
264
  comfortableRowClass({ active: isSelected, focused: isFocused }),
242
265
  "outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-inset",
243
266
  isChecked && "bg-accent-soft",
244
- !isDesktop && "min-h-11",
267
+ // See the compact-density Link above for why both are required.
268
+ !isDesktop && "min-h-11 select-none [-webkit-touch-callout:none]",
245
269
  )}
246
270
  >
247
271
  {/* Absolute unread dot — 6px gutter from the left pane hairline */}
@@ -3,8 +3,7 @@ import { beforeEach, describe, test } from "node:test";
3
3
 
4
4
  /**
5
5
  * Unit test for useVisualViewport hook logic.
6
- * Tests the core behaviour without React — mirrors the pattern used
7
- * by useLongPress.test.ts in this project.
6
+ * Tests the core behaviour without React.
8
7
  */
9
8
 
10
9
  const KEYBOARD_THRESHOLD = 150;
@@ -1,291 +0,0 @@
1
- import assert from "node:assert";
2
- import { beforeEach, describe, mock, test } from "node:test";
3
-
4
- /**
5
- * Test harness for useLongPress. Since we can't use React Testing Library,
6
- * we test the handler logic directly by simulating pointer events and timers.
7
- */
8
-
9
- interface MockPointerEvent {
10
- clientX: number;
11
- clientY: number;
12
- }
13
-
14
- const createPointerEvent = (x: number, y: number): MockPointerEvent => ({
15
- clientX: x,
16
- clientY: y,
17
- });
18
-
19
- /**
20
- * Minimal implementation of useLongPress logic for testing.
21
- * Matches the hook's behavior without React dependencies.
22
- */
23
- class LongPressSimulator {
24
- private timer: ReturnType<typeof setTimeout> | null = null;
25
- private startPos: { x: number; y: number } | null = null;
26
- private readonly MOVEMENT_THRESHOLD = 8;
27
-
28
- constructor(
29
- private readonly onLongPress: () => void,
30
- private readonly delayMs: number = 500,
31
- ) {}
32
-
33
- onPointerDown(e: MockPointerEvent): void {
34
- this.clearTimer();
35
- this.startPos = { x: e.clientX, y: e.clientY };
36
- this.timer = setTimeout(() => {
37
- this.onLongPress();
38
- this.clearTimer();
39
- }, this.delayMs);
40
- }
41
-
42
- onPointerMove(e: MockPointerEvent): void {
43
- if (!this.startPos) return;
44
-
45
- const dx = e.clientX - this.startPos.x;
46
- const dy = e.clientY - this.startPos.y;
47
- const distance = Math.sqrt(dx * dx + dy * dy);
48
-
49
- if (distance > this.MOVEMENT_THRESHOLD) {
50
- this.clearTimer();
51
- }
52
- }
53
-
54
- onPointerUp(): void {
55
- this.clearTimer();
56
- }
57
-
58
- onPointerCancel(): void {
59
- this.clearTimer();
60
- }
61
-
62
- private clearTimer(): void {
63
- if (this.timer) {
64
- clearTimeout(this.timer);
65
- this.timer = null;
66
- }
67
- this.startPos = null;
68
- }
69
-
70
- /** Test helper: check if timer is active */
71
- hasActiveTimer(): boolean {
72
- return this.timer !== null;
73
- }
74
-
75
- /** Test helper: cleanup */
76
- cleanup(): void {
77
- this.clearTimer();
78
- }
79
- }
80
-
81
- describe("useLongPress", () => {
82
- beforeEach(() => {
83
- // Reset any timers between tests
84
- mock.restoreAll();
85
- });
86
-
87
- test("fires callback after delay with no movement", async () => {
88
- const onLongPress = mock.fn();
89
- const sim = new LongPressSimulator(onLongPress, 500);
90
-
91
- sim.onPointerDown(createPointerEvent(100, 100));
92
-
93
- // Wait for the delay
94
- await new Promise((resolve) => setTimeout(resolve, 550));
95
-
96
- assert.strictEqual(onLongPress.mock.calls.length, 1);
97
- sim.cleanup();
98
- });
99
-
100
- test("does not fire if pointer moves more than 8px", async () => {
101
- const onLongPress = mock.fn();
102
- const sim = new LongPressSimulator(onLongPress, 500);
103
-
104
- sim.onPointerDown(createPointerEvent(100, 100));
105
-
106
- // Move 10 pixels (more than threshold of 8)
107
- sim.onPointerMove(createPointerEvent(110, 100));
108
-
109
- // Wait beyond the delay
110
- await new Promise((resolve) => setTimeout(resolve, 550));
111
-
112
- assert.strictEqual(
113
- onLongPress.mock.calls.length,
114
- 0,
115
- "callback should not fire after moving > threshold",
116
- );
117
- sim.cleanup();
118
- });
119
-
120
- test("does not fire on early pointerup", async () => {
121
- const onLongPress = mock.fn();
122
- const sim = new LongPressSimulator(onLongPress, 500);
123
-
124
- sim.onPointerDown(createPointerEvent(100, 100));
125
-
126
- // Release before delay completes
127
- await new Promise((resolve) => setTimeout(resolve, 100));
128
- sim.onPointerUp();
129
-
130
- // Wait beyond the original delay
131
- await new Promise((resolve) => setTimeout(resolve, 500));
132
-
133
- assert.strictEqual(
134
- onLongPress.mock.calls.length,
135
- 0,
136
- "callback should not fire after early pointerup",
137
- );
138
- sim.cleanup();
139
- });
140
-
141
- test("does not fire on pointercancel", async () => {
142
- const onLongPress = mock.fn();
143
- const sim = new LongPressSimulator(onLongPress, 500);
144
-
145
- sim.onPointerDown(createPointerEvent(100, 100));
146
-
147
- // Cancel before delay completes
148
- await new Promise((resolve) => setTimeout(resolve, 100));
149
- sim.onPointerCancel();
150
-
151
- // Wait beyond the original delay
152
- await new Promise((resolve) => setTimeout(resolve, 500));
153
-
154
- assert.strictEqual(
155
- onLongPress.mock.calls.length,
156
- 0,
157
- "callback should not fire after pointercancel",
158
- );
159
- sim.cleanup();
160
- });
161
-
162
- test("respects custom delayMs", async () => {
163
- const onLongPress = mock.fn();
164
- const customDelay = 200;
165
- const sim = new LongPressSimulator(onLongPress, customDelay);
166
-
167
- sim.onPointerDown(createPointerEvent(100, 100));
168
-
169
- // Wait less than custom delay
170
- await new Promise((resolve) => setTimeout(resolve, 150));
171
- assert.strictEqual(
172
- onLongPress.mock.calls.length,
173
- 0,
174
- "should not fire before custom delay",
175
- );
176
-
177
- // Wait past custom delay
178
- await new Promise((resolve) => setTimeout(resolve, 100));
179
- assert.strictEqual(
180
- onLongPress.mock.calls.length,
181
- 1,
182
- "should fire after custom delay",
183
- );
184
- sim.cleanup();
185
- });
186
-
187
- test("allows movement within 8px threshold", async () => {
188
- const onLongPress = mock.fn();
189
- const sim = new LongPressSimulator(onLongPress, 500);
190
-
191
- sim.onPointerDown(createPointerEvent(100, 100));
192
-
193
- // Move 7 pixels (within threshold)
194
- sim.onPointerMove(createPointerEvent(107, 100));
195
-
196
- // Wait for the delay
197
- await new Promise((resolve) => setTimeout(resolve, 550));
198
-
199
- assert.strictEqual(
200
- onLongPress.mock.calls.length,
201
- 1,
202
- "callback should fire when movement is within threshold",
203
- );
204
- sim.cleanup();
205
- });
206
-
207
- test("calculates diagonal movement correctly", async () => {
208
- const onLongPress = mock.fn();
209
- const sim = new LongPressSimulator(onLongPress, 500);
210
-
211
- sim.onPointerDown(createPointerEvent(100, 100));
212
-
213
- // Move 6px in both x and y (diagonal distance ~8.48px > 8px threshold)
214
- sim.onPointerMove(createPointerEvent(106, 106));
215
-
216
- // Wait beyond the delay
217
- await new Promise((resolve) => setTimeout(resolve, 550));
218
-
219
- assert.strictEqual(
220
- onLongPress.mock.calls.length,
221
- 0,
222
- "callback should not fire when diagonal movement exceeds threshold",
223
- );
224
- sim.cleanup();
225
- });
226
-
227
- test("ignores pointer move before pointer down", async () => {
228
- const onLongPress = mock.fn();
229
- const sim = new LongPressSimulator(onLongPress, 500);
230
-
231
- // Move without pressing down first
232
- sim.onPointerMove(createPointerEvent(200, 200));
233
-
234
- sim.onPointerDown(createPointerEvent(100, 100));
235
-
236
- // Wait for the delay
237
- await new Promise((resolve) => setTimeout(resolve, 550));
238
-
239
- assert.strictEqual(
240
- onLongPress.mock.calls.length,
241
- 1,
242
- "callback should fire - earlier move should be ignored",
243
- );
244
- sim.cleanup();
245
- });
246
-
247
- test("resets on new pointer down", async () => {
248
- const onLongPress = mock.fn();
249
- const sim = new LongPressSimulator(onLongPress, 500);
250
-
251
- // First press
252
- sim.onPointerDown(createPointerEvent(100, 100));
253
-
254
- // Wait a bit
255
- await new Promise((resolve) => setTimeout(resolve, 200));
256
-
257
- // New press at different location (should reset timer)
258
- sim.onPointerDown(createPointerEvent(200, 200));
259
-
260
- // Wait for delay from second press
261
- await new Promise((resolve) => setTimeout(resolve, 550));
262
-
263
- // Should only fire once (from second press)
264
- assert.strictEqual(
265
- onLongPress.mock.calls.length,
266
- 1,
267
- "callback should fire once from second press",
268
- );
269
- sim.cleanup();
270
- });
271
-
272
- test("exact 8px movement does not cancel", async () => {
273
- const onLongPress = mock.fn();
274
- const sim = new LongPressSimulator(onLongPress, 500);
275
-
276
- sim.onPointerDown(createPointerEvent(100, 100));
277
-
278
- // Move exactly 8 pixels
279
- sim.onPointerMove(createPointerEvent(108, 100));
280
-
281
- // Wait for the delay
282
- await new Promise((resolve) => setTimeout(resolve, 550));
283
-
284
- assert.strictEqual(
285
- onLongPress.mock.calls.length,
286
- 1,
287
- "callback should fire - movement exactly at threshold is allowed",
288
- );
289
- sim.cleanup();
290
- });
291
- });
@@ -1,87 +0,0 @@
1
- import { useCallback, useRef } from "react";
2
-
3
- interface UseLongPressOptions {
4
- onLongPress: () => void;
5
- delayMs?: number;
6
- }
7
-
8
- interface LongPressHandlers {
9
- onPointerDown: (e: PointerEvent | React.PointerEvent) => void;
10
- onPointerMove: (e: PointerEvent | React.PointerEvent) => void;
11
- onPointerUp: () => void;
12
- onPointerCancel: () => void;
13
- }
14
-
15
- const MOVEMENT_THRESHOLD = 8; // pixels
16
-
17
- /**
18
- * Hook for detecting long-press gestures on touch/pointer devices.
19
- *
20
- * @param onLongPress - Callback fired after the delay if the pointer hasn't moved
21
- * @param delayMs - How long to wait before firing (default 500ms)
22
- *
23
- * @returns Pointer event handlers to spread onto your element
24
- *
25
- * @example
26
- * const { handlers } = useLongPress({ onLongPress: () => console.log('long press!') });
27
- * return <div {...handlers}>Press and hold me</div>;
28
- */
29
- export const useLongPress = ({
30
- onLongPress,
31
- delayMs = 500,
32
- }: UseLongPressOptions): { handlers: LongPressHandlers } => {
33
- const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
- const startPosRef = useRef<{ x: number; y: number } | null>(null);
35
-
36
- const clearTimer = useCallback(() => {
37
- if (timerRef.current) {
38
- clearTimeout(timerRef.current);
39
- timerRef.current = null;
40
- }
41
- startPosRef.current = null;
42
- }, []);
43
-
44
- const onPointerDown = useCallback(
45
- (e: PointerEvent | React.PointerEvent) => {
46
- clearTimer();
47
- startPosRef.current = { x: e.clientX, y: e.clientY };
48
- timerRef.current = setTimeout(() => {
49
- onLongPress();
50
- clearTimer();
51
- }, delayMs);
52
- },
53
- [onLongPress, delayMs, clearTimer],
54
- );
55
-
56
- const onPointerMove = useCallback(
57
- (e: PointerEvent | React.PointerEvent) => {
58
- if (!startPosRef.current) return;
59
-
60
- const dx = e.clientX - startPosRef.current.x;
61
- const dy = e.clientY - startPosRef.current.y;
62
- const distance = Math.sqrt(dx * dx + dy * dy);
63
-
64
- if (distance > MOVEMENT_THRESHOLD) {
65
- clearTimer();
66
- }
67
- },
68
- [clearTimer],
69
- );
70
-
71
- const onPointerUp = useCallback(() => {
72
- clearTimer();
73
- }, [clearTimer]);
74
-
75
- const onPointerCancel = useCallback(() => {
76
- clearTimer();
77
- }, [clearTimer]);
78
-
79
- return {
80
- handlers: {
81
- onPointerDown,
82
- onPointerMove,
83
- onPointerUp,
84
- onPointerCancel,
85
- },
86
- };
87
- };