@remit/ui 0.0.76 → 0.0.78

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.76",
3
+ "version": "0.0.78",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -254,15 +254,15 @@ export const Escalated: Story = {
254
254
  };
255
255
 
256
256
  /**
257
- * While a search result set is still paging, the exact count isn't known
258
- * yet — a running total instead of a static "Counting…", the verbs hidden
259
- * (nothing to act on with an unknown total), and an explicit Stop.
257
+ * While the server is answering how many messages the search matches, the total
258
+ * isn't on screen yet — the verbs are hidden (nothing to act on without it) and
259
+ * Stop drops the escalation.
260
260
  */
261
261
  export const Counting: Story = {
262
262
  args: {
263
263
  count: 0,
264
264
  isCounting: true,
265
- statusLabel: "Counting… 1,900 so far",
265
+ statusLabel: "Counting matches…",
266
266
  selectAll: {
267
267
  checked: true,
268
268
  indeterminate: false,
@@ -276,20 +276,6 @@ export const Counting: Story = {
276
276
  },
277
277
  };
278
278
 
279
- /** Past ~10s the counting state says so, rather than looking stuck. */
280
- export const CountingLargeResultSet: Story = {
281
- args: {
282
- count: 0,
283
- isCounting: true,
284
- statusLabel: "Counting… 12,400 so far. This is a big result set.",
285
- notice: {
286
- tone: "info",
287
- text: "",
288
- action: { label: "Stop", onClick: () => undefined },
289
- },
290
- },
291
- };
292
-
293
279
  /**
294
280
  * A bulk delete in progress reports a running total via `statusLabel` and a
295
281
  * determinate `ProgressBar`; the delete button shows its busy spinner (never
@@ -101,7 +101,7 @@ export interface SelectionTopBarProps {
101
101
  * Overrides the default "{count} messages selected" text. Required once the
102
102
  * count's scope is anything other than "every loaded row is selected" —
103
103
  * an escalated selection ("All 3,412 matching \"npm\" selected"), a
104
- * counting state ("Counting… 1,900 so far"), or bulk-delete progress
104
+ * counting state ("Counting matches…"), or bulk-delete progress
105
105
  * ("Deleting 1,200 of 3,412…"). When `selectAll.checked` is true and this
106
106
  * is omitted, the default text names the loaded-scope itself ("All 47
107
107
  * loaded selected") rather than a bare count — a ticked select-all box
@@ -15,9 +15,7 @@
15
15
  * The second: a finger held on glass drifts, and it drifts past the distance
16
16
  * at which the drag starts tracking. Cancelling the press there meant a real
17
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.
18
+ * and snapped back, and nothing else happened.
21
19
  */
22
20
 
23
21
  import assert from "node:assert/strict";
@@ -123,36 +121,6 @@ function mount(handlers: Handlers) {
123
121
  return row;
124
122
  }
125
123
 
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
124
  function contextMenu(row: Element) {
157
125
  const event = new dom.window.MouseEvent("contextmenu", {
158
126
  bubbles: true,
@@ -309,7 +277,7 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
309
277
  let longPressed = 0;
310
278
  let opened = 0;
311
279
  const peeks: SwipePeek[] = [];
312
- const row = mountAnchor({
280
+ const row = mount({
313
281
  onLongPress: () => longPressed++,
314
282
  onOpen: () => opened++,
315
283
  onPeek: (next) => peeks.push(next),
@@ -331,7 +299,7 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
331
299
  it("a hold that drifts vertically past the axis threshold still enters selection mode", async () => {
332
300
  let longPressed = 0;
333
301
  const peeks: SwipePeek[] = [];
334
- const row = mountAnchor({
302
+ const row = mount({
335
303
  onLongPress: () => longPressed++,
336
304
  onOpen: () => undefined,
337
305
  onPeek: (next) => peeks.push(next),
@@ -349,10 +317,10 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
349
317
 
350
318
  it("an aborted short drag opens nothing on release", async () => {
351
319
  // 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.
320
+ // press was still live, and react-aria synthesizes a click for an
321
+ // unresolved press.
354
322
  let clicks = 0;
355
- const row = mountAnchor({
323
+ const row = mount({
356
324
  onLongPress: () => undefined,
357
325
  onOpen: () => undefined,
358
326
  onPeek: () => undefined,
@@ -367,10 +335,10 @@ describe("SwipeableRow gesture wiring (react-aria long press + axis arbitration)
367
335
  assert.equal(clicks, 0);
368
336
  });
369
337
 
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({
338
+ it("suppresses the native context menu raised over a drifting hold", async () => {
339
+ // Android Chrome raises its own menu at its own threshold, which can land
340
+ // mid-drift and ahead of the app's long press.
341
+ const row = mount({
374
342
  onLongPress: () => undefined,
375
343
  onOpen: () => undefined,
376
344
  onPeek: () => undefined,
@@ -3,11 +3,7 @@ import { describe, it } from "node:test";
3
3
  import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
5
  import type { ThreadRowData } from "./app-shell-types.js";
6
- import {
7
- SwipeableRow,
8
- type SwipeableRowOpenProps,
9
- type SwipePeek,
10
- } from "./swipeable-row.js";
6
+ import { SwipeableRow, type SwipePeek } from "./swipeable-row.js";
11
7
 
12
8
  const thread: ThreadRowData = {
13
9
  id: "thread-1",
@@ -63,45 +59,14 @@ describe("SwipeableRow", () => {
63
59
  assert.match(read, /aria-label="Mark as unread"/);
64
60
  });
65
61
 
66
- it("renders the open affordance as a button by default", () => {
67
- const html = render("none");
68
- assert.match(html, /<button[^>]*>/);
69
- assert.doesNotMatch(html, /<a /);
70
- });
71
-
72
- it("renders the open affordance as a real anchor when linkComponent is given", () => {
73
- const html = renderToString(
74
- createElement(SwipeableRow, {
75
- ...baseProps,
76
- peek: "none",
77
- linkComponent: ({ className, children }: SwipeableRowOpenProps) =>
78
- createElement(
79
- "a",
80
- { href: "/mail/m1?selectedMessageId=t1", className },
81
- children,
82
- ),
83
- }),
84
- );
85
- assert.match(html, /<a [^>]*href="\/mail\/m1\?selectedMessageId=t1"/);
86
- assert.match(html, /Q3 planning notes/);
87
- });
88
-
89
- it("keeps the button (not the anchor) in selection mode even with a linkComponent", () => {
90
- const html = renderToString(
91
- createElement(SwipeableRow, {
92
- ...baseProps,
93
- selectionMode: true,
94
- peek: "none",
95
- linkComponent: ({ className, children }: SwipeableRowOpenProps) =>
96
- createElement(
97
- "a",
98
- { href: "/should-not-render", className },
99
- children,
100
- ),
101
- }),
102
- );
103
- assert.doesNotMatch(html, /<a /);
104
- assert.match(html, /<button[^>]*>/);
62
+ it("opens through a button, never an anchor (#116)", () => {
63
+ for (const html of [
64
+ render("none"),
65
+ render("none", { selectionMode: true }),
66
+ ]) {
67
+ assert.match(html, /<button[^>]*data-message-row/);
68
+ assert.doesNotMatch(html, /<a /);
69
+ }
105
70
  });
106
71
 
107
72
  it("gives the row checkbox semantics while in selection mode", () => {
@@ -69,31 +69,6 @@ export const SelectionChecked: Story = {
69
69
  args: { peek: "none", selectionMode: true, checked: true },
70
70
  };
71
71
 
72
- /**
73
- * The open affordance is rendered as a real `<a href>` via `linkComponent`,
74
- * so deep-link, middle-click and open-in-new-tab work. Consumers pass their
75
- * router's Link; here a plain anchor stands in. Inspect the DOM: the row is an
76
- * anchor, not a button.
77
- */
78
- export const AsAnchor: Story = {
79
- name: "As anchor (linkComponent)",
80
- args: {
81
- peek: "none",
82
- linkComponent: ({ onOpenClick, children, ...rowProps }) => (
83
- <a
84
- {...rowProps}
85
- href="/mail/inbox?selectedMessageId=thread-1"
86
- onClick={(e) => {
87
- e.preventDefault();
88
- onOpenClick(e);
89
- }}
90
- >
91
- {children}
92
- </a>
93
- ),
94
- },
95
- };
96
-
97
72
  export const Acting: Story = {
98
73
  name: "Acting (interactive)",
99
74
  render: () => {
@@ -130,7 +105,7 @@ export const Acting: Story = {
130
105
  },
131
106
  };
132
107
 
133
- function AnchorRow({
108
+ function GestureRow({
134
109
  onLongPress,
135
110
  selectionMode,
136
111
  checked,
@@ -147,23 +122,14 @@ function AnchorRow({
147
122
  selectionMode={selectionMode ?? false}
148
123
  checked={checked ?? false}
149
124
  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
125
  />
163
126
  </PhoneFrame>
164
127
  );
165
128
  }
166
129
 
130
+ const rowElement = (canvasElement: HTMLElement) =>
131
+ canvasElement.querySelector<HTMLButtonElement>("button[data-message-row]");
132
+
167
133
  /**
168
134
  * The long press is the way into multi-select on touch. Press and hold the row
169
135
  * (mouse hold works too — react-aria fires the long press for both) and it
@@ -177,7 +143,7 @@ export const LongPressToSelect: Story = {
177
143
  const [selected, setSelected] = useState(false);
178
144
  return (
179
145
  <div className="space-y-2">
180
- <AnchorRow
146
+ <GestureRow
181
147
  selectionMode={selected}
182
148
  checked={selected}
183
149
  onLongPress={() => setSelected((v) => !v)}
@@ -206,7 +172,7 @@ export const LongPressWithDrift: Story = {
206
172
  const [selected, setSelected] = useState(false);
207
173
  return (
208
174
  <div className="space-y-2">
209
- <AnchorRow
175
+ <GestureRow
210
176
  selectionMode={selected}
211
177
  checked={selected}
212
178
  onLongPress={() => setSelected(true)}
@@ -220,10 +186,10 @@ export const LongPressWithDrift: Story = {
220
186
  );
221
187
  },
222
188
  play: async ({ canvasElement }) => {
223
- const anchor = canvasElement.querySelector<HTMLAnchorElement>("a[href]");
224
- if (!anchor) return;
189
+ const row = rowElement(canvasElement);
190
+ if (!row) return;
225
191
  const touch = (type: string, clientX: number, clientY: number) =>
226
- anchor.dispatchEvent(
192
+ row.dispatchEvent(
227
193
  new PointerEvent(type, {
228
194
  bubbles: true,
229
195
  pointerType: "touch",
@@ -247,30 +213,29 @@ export const LongPressWithDrift: Story = {
247
213
  };
248
214
 
249
215
  /**
250
- * A touch long press over a link row normally raises the browser's own link
251
- * context menu ("Open in new tab / Copy link address"), which collides with the
252
- * long-press-to-select gesture above. `useLongPress` suppresses that menu when
253
- * the press came from touch or pen, while leaving a mouse right-click's menu
254
- * alone. The play step drives a synthetic touch press then a contextmenu and
255
- * writes the outcome below.
216
+ * A touch long press over a row normally raises the browser's own context menu,
217
+ * which collides with the long-press-to-select gesture above. `useLongPress`
218
+ * suppresses that menu when the press came from touch or pen, while leaving a
219
+ * mouse right-click's menu alone. The play step drives a synthetic touch press
220
+ * then a contextmenu and writes the outcome below.
256
221
  */
257
222
  export const TouchContextMenuSuppressed: Story = {
258
223
  name: "Touch context menu suppressed",
259
224
  render: () => (
260
225
  <div className="space-y-2">
261
- <AnchorRow />
226
+ <GestureRow />
262
227
  <p data-testid="context-menu-outcome" className="text-xs text-fg-muted">
263
228
  Waiting for a touch press…
264
229
  </p>
265
230
  </div>
266
231
  ),
267
232
  play: async ({ canvasElement }) => {
268
- const anchor = canvasElement.querySelector<HTMLAnchorElement>("a[href]");
233
+ const row = rowElement(canvasElement);
269
234
  const outcome = canvasElement.querySelector<HTMLParagraphElement>(
270
235
  '[data-testid="context-menu-outcome"]',
271
236
  );
272
- if (!anchor || !outcome) return;
273
- anchor.dispatchEvent(
237
+ if (!row || !outcome) return;
238
+ row.dispatchEvent(
274
239
  new PointerEvent("pointerdown", {
275
240
  bubbles: true,
276
241
  pointerType: "touch",
@@ -281,7 +246,7 @@ export const TouchContextMenuSuppressed: Story = {
281
246
  bubbles: true,
282
247
  cancelable: true,
283
248
  });
284
- anchor.dispatchEvent(menu);
249
+ row.dispatchEvent(menu);
285
250
  outcome.textContent = menu.defaultPrevented
286
251
  ? "Native context menu suppressed on touch."
287
252
  : "Native context menu allowed.";
@@ -1,4 +1,3 @@
1
- import type { DOMAttributes } from "@react-types/shared";
2
1
  import { Check, Mail, MailOpen, Trash2 } from "lucide-react";
3
2
  import { useRef, useState } from "react";
4
3
  import { mergeProps } from "react-aria";
@@ -64,26 +63,6 @@ export function commitPeek(offset: number): SwipePeek {
64
63
  return "none";
65
64
  }
66
65
 
67
- /**
68
- * Props the row's interactive (open) element must receive — the swipe gesture
69
- * handlers (merged with react-aria's long-press props: pointer handlers,
70
- * `aria-describedby`, and friends), the transform/transition style, the row
71
- * body, plus an onClick that suppresses navigation when the row is peeked. A
72
- * consumer passes `linkComponent` to render these on a real anchor (e.g. a
73
- * router Link) so the open affordance is a true `<a href>` — keeping
74
- * open-in-new-tab, middle-click, deep-link and a11y — instead of the default
75
- * JS-only `<button onOpen>`.
76
- */
77
- export interface SwipeableRowOpenProps extends DOMAttributes {
78
- className: string;
79
- style: React.CSSProperties;
80
- /** Wire to the anchor's onClick. When the row is peeked it calls
81
- * preventDefault so a tap closes the peek instead of navigating; otherwise
82
- * it is a no-op and the anchor's native navigation proceeds. */
83
- onOpenClick: (e: { preventDefault: () => void }) => void;
84
- children: React.ReactNode;
85
- }
86
-
87
66
  export function SwipeableRow({
88
67
  thread,
89
68
  selectionMode,
@@ -95,7 +74,6 @@ export function SwipeableRow({
95
74
  onLongPress,
96
75
  onOpen,
97
76
  onAct,
98
- linkComponent,
99
77
  }: {
100
78
  thread: ThreadRowData;
101
79
  selectionMode: boolean;
@@ -109,12 +87,6 @@ export function SwipeableRow({
109
87
  /** Tapping a revealed action performs it: "leading" = toggle read,
110
88
  * "trailing" = delete. */
111
89
  onAct: (side: "leading" | "trailing") => void;
112
- /** Render the open affordance as a real anchor (router Link / `<a href>`)
113
- * instead of the default `<button onOpen>`. Receives the gesture handlers,
114
- * style, peek-aware onClickCapture and row body to spread onto the anchor.
115
- * When provided, a plain tap navigates via the anchor's native click (so
116
- * deep-link/middle-click work); onOpen is not called for the tap. */
117
- linkComponent?: (props: SwipeableRowOpenProps) => React.ReactNode;
118
90
  }) {
119
91
  const gesture = useRef<{
120
92
  startX: number;
@@ -197,9 +169,8 @@ export function SwipeableRow({
197
169
  // or a real cancel arrived) — those handle themselves, so do nothing.
198
170
  if (!g) return;
199
171
  // 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.
172
+ // live, and an unresolved press synthesizes a click on release. The drag
173
+ // was neither a swipe nor a tap, so end the press with it.
203
174
  if (g.axis !== "none" && !g.escaped) cancelLongPress(e);
204
175
  if (g.axis === "horizontal") {
205
176
  if (offset !== null) onPeek(commitPeek(offset));
@@ -216,8 +187,6 @@ export function SwipeableRow({
216
187
  onPeek("none");
217
188
  return;
218
189
  }
219
- // A real anchor handles the open via its native click — don't double-fire.
220
- if (linkComponent) return;
221
190
  onOpen();
222
191
  };
223
192
 
@@ -242,12 +211,6 @@ export function SwipeableRow({
242
211
  onPointerCancel,
243
212
  });
244
213
 
245
- // A tap on a peeked anchor must close the peek, not navigate; suppress the
246
- // native click in that case. onPointerUp already snapped it closed.
247
- const onOpenClick = (e: { preventDefault: () => void }) => {
248
- if (peek !== "none") e.preventDefault();
249
- };
250
-
251
214
  const offset = dragX ?? peekOffset(peek);
252
215
  const revealed: SwipePeek =
253
216
  offset > 0 ? "leading" : offset < 0 ? "trailing" : "none";
@@ -255,12 +218,9 @@ export function SwipeableRow({
255
218
  const interactiveClassName = cn(
256
219
  // opaque bg so the row occludes the action behind it until peeked
257
220
  "relative touch-pan-y bg-surface",
258
- // This row's long press enters selection mode; without these, Android
259
- // Chrome opens the link context menu / starts text selection and iOS
260
- // Safari fires the callout, racing the app's handler. useLongPress
261
- // suppresses the touch-fired contextmenu; the callout fires no
262
- // cancelable event, so CSS is the only lever left for it.
263
- "select-none [-webkit-touch-callout:none]",
221
+ // This row's long press enters selection mode; without this, the hold
222
+ // starts a native text selection across the row's subject and snippet.
223
+ "select-none",
264
224
  comfortableRowClass({ active: checked || active }),
265
225
  );
266
226
  const interactiveStyle: React.CSSProperties = {
@@ -297,14 +257,14 @@ export function SwipeableRow({
297
257
  )}
298
258
  {/*
299
259
  * Tappable, focusable entry point into selection mode — long-press is
300
- * never the only way in. Nested inside the row's own open control
301
- * (button or, via linkComponent, an anchor); mirrors the leading-slot
302
- * toggle already shipped in the web client's row.
260
+ * never the only way in. A span, not a <button>: it sits inside the
261
+ * row's own open button, which may not nest one. The checkbox role and
262
+ * label are what the user and the test suite address, so both stay.
303
263
  */}
304
- {/* biome-ignore lint/a11y/useSemanticElements: a native <input type="checkbox"> can't host the Avatar as its visible content; role="checkbox" on a button mirrors the row-checkbox pattern already shipped in MessageListItem.tsx */}
305
- <button
306
- type="button"
264
+ {/* biome-ignore lint/a11y/useSemanticElements: a native <input type="checkbox"> can't host the Avatar as its visible content, and a nested <button> inside the row's own button is invalid HTML */}
265
+ <span
307
266
  role="checkbox"
267
+ tabIndex={0}
308
268
  aria-checked={checked}
309
269
  aria-label={`Select message from ${thread.fromName}`}
310
270
  onPointerDown={stopRowGesture}
@@ -315,10 +275,16 @@ export function SwipeableRow({
315
275
  e.stopPropagation();
316
276
  onLongPress();
317
277
  }}
278
+ onKeyDown={(e) => {
279
+ if (e.key !== "Enter" && e.key !== " ") return;
280
+ e.preventDefault();
281
+ e.stopPropagation();
282
+ onLongPress();
283
+ }}
318
284
  className="-m-2 inline-flex size-11 shrink-0 items-center justify-center rounded-full"
319
285
  >
320
286
  <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
321
- </button>
287
+ </span>
322
288
  <ComfortableRowTextContent thread={thread} />
323
289
  </>
324
290
  );
@@ -350,29 +316,21 @@ export function SwipeableRow({
350
316
  </button>
351
317
  )}
352
318
 
353
- {/* In selection mode the row is tap-to-toggle, never a navigation link —
354
- keep the button so a checkbox tap can't open a thread. */}
355
- {linkComponent && !selectionMode ? (
356
- linkComponent({
357
- ...gestureProps,
358
- className: interactiveClassName,
359
- style: interactiveStyle,
360
- onOpenClick,
361
- children: body,
362
- })
363
- ) : (
364
- // biome-ignore lint/a11y/useAriaPropsSupportedByRole: role is only ever "checkbox" (aria-checked's owning role) when selectionMode is true; the ternaries are linked, biome can't see that statically
365
- <button
366
- type="button"
367
- role={selectionMode ? "checkbox" : undefined}
368
- aria-checked={selectionMode ? checked : undefined}
369
- {...gestureProps}
370
- className={interactiveClassName}
371
- style={interactiveStyle}
372
- >
373
- {body}
374
- </button>
375
- )}
319
+ {/* `data-message-row` is the mail app's row marker: its key dispatcher
320
+ reads it to tell a row apart from a control nested inside one, and
321
+ the e2e suite locates rows by it. */}
322
+ {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: role is only ever "checkbox" (aria-checked's owning role) when selectionMode is true; the ternaries are linked, biome can't see that statically */}
323
+ <button
324
+ type="button"
325
+ role={selectionMode ? "checkbox" : undefined}
326
+ aria-checked={selectionMode ? checked : undefined}
327
+ data-message-row
328
+ {...gestureProps}
329
+ className={interactiveClassName}
330
+ style={interactiveStyle}
331
+ >
332
+ {body}
333
+ </button>
376
334
  </div>
377
335
  );
378
336
  }
package/src/index.ts CHANGED
@@ -598,7 +598,6 @@ export {
598
598
  export {
599
599
  commitPeek,
600
600
  SwipeableRow,
601
- type SwipeableRowOpenProps,
602
601
  type SwipePeek,
603
602
  } from "./components/swipeable-row.js";
604
603
  export { TouchListBody } from "./components/touch-list.js";