@remit/ui 0.0.14 → 0.0.16

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.
@@ -103,4 +103,33 @@ describe("SwipeableRow", () => {
103
103
  assert.doesNotMatch(html, /<a /);
104
104
  assert.match(html, /<button[^>]*>/);
105
105
  });
106
+
107
+ it("gives the row checkbox semantics while in selection mode", () => {
108
+ const checked = render("none", { selectionMode: true, checked: true });
109
+ assert.match(checked, /role="checkbox"/);
110
+ assert.match(checked, /aria-checked="true"/);
111
+
112
+ const unchecked = render("none", { selectionMode: true, checked: false });
113
+ assert.match(unchecked, /aria-checked="false"/);
114
+ });
115
+
116
+ it("does not put checkbox semantics on the outer row outside selection mode", () => {
117
+ // Outside selection mode the row's own open control (the outer button)
118
+ // stays a plain button — only the nested leading-avatar toggle carries
119
+ // checkbox semantics, asserted separately below.
120
+ const html = render("none");
121
+ const outerTag = html.match(
122
+ /^<div class="relative overflow-hidden"><button type="button"[^>]*>/,
123
+ )?.[0];
124
+ assert.ok(outerTag, "outer row button found");
125
+ assert.doesNotMatch(outerTag as string, /role="checkbox"/);
126
+ });
127
+
128
+ it("renders the leading avatar as a focusable checkbox-role toggle outside selection mode", () => {
129
+ const html = render("none");
130
+ assert.match(
131
+ html,
132
+ /role="checkbox"[^>]*aria-label="Select message from Alex Rivera"/,
133
+ );
134
+ });
106
135
  });
@@ -2,8 +2,8 @@ import { Check, Mail, MailOpen, Trash2 } from "lucide-react";
2
2
  import { useRef, useState } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
4
  import type { ThreadRowData } from "./app-shell-types.js";
5
+ import { Avatar } from "./avatar.js";
5
6
  import {
6
- ComfortableRowBody,
7
7
  ComfortableRowTextContent,
8
8
  comfortableRowClass,
9
9
  } from "./message-row.js";
@@ -192,6 +192,14 @@ export function SwipeableRow({
192
192
  transition: dragX === null ? "transform 150ms ease" : "none",
193
193
  minHeight: 44,
194
194
  };
195
+ const unread = !thread.isRead;
196
+
197
+ // Stops the row's own pointer-gesture handlers (long-press, swipe axis
198
+ // detection) from also firing for a tap that started on the nested avatar
199
+ // toggle — the row and the toggle are two separate controls sharing the
200
+ // same leading 28px slot.
201
+ const stopRowGesture = (e: React.PointerEvent) => e.stopPropagation();
202
+
195
203
  const body = selectionMode ? (
196
204
  <>
197
205
  <span
@@ -207,7 +215,36 @@ export function SwipeableRow({
207
215
  <ComfortableRowTextContent thread={thread} />
208
216
  </>
209
217
  ) : (
210
- <ComfortableRowBody thread={thread} />
218
+ <>
219
+ {unread && (
220
+ <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
221
+ )}
222
+ {/*
223
+ * Tappable, focusable entry point into selection mode — long-press is
224
+ * never the only way in. Nested inside the row's own open control
225
+ * (button or, via linkComponent, an anchor); mirrors the leading-slot
226
+ * toggle already shipped in the web client's row.
227
+ */}
228
+ {/* 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 */}
229
+ <button
230
+ type="button"
231
+ role="checkbox"
232
+ aria-checked={checked}
233
+ aria-label={`Select message from ${thread.fromName}`}
234
+ onPointerDown={stopRowGesture}
235
+ onPointerMove={stopRowGesture}
236
+ onPointerUp={stopRowGesture}
237
+ onClick={(e) => {
238
+ e.preventDefault();
239
+ e.stopPropagation();
240
+ onLongPress();
241
+ }}
242
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-full"
243
+ >
244
+ <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
245
+ </button>
246
+ <ComfortableRowTextContent thread={thread} />
247
+ </>
211
248
  );
212
249
 
213
250
  return (
@@ -251,8 +288,11 @@ export function SwipeableRow({
251
288
  children: body,
252
289
  })
253
290
  ) : (
291
+ // 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
254
292
  <button
255
293
  type="button"
294
+ role={selectionMode ? "checkbox" : undefined}
295
+ aria-checked={selectionMode ? checked : undefined}
256
296
  onPointerDown={onPointerDown}
257
297
  onPointerMove={onPointerMove}
258
298
  onPointerUp={onPointerUp}
@@ -63,4 +63,29 @@ describe("TouchListBody", () => {
63
63
  const html = renderToString(createElement(TouchListBody, baseProps));
64
64
  assert.match(html, /Pull to refresh/);
65
65
  });
66
+
67
+ it("dims and suppresses taps on every row while busy", () => {
68
+ const html = renderToString(
69
+ createElement(TouchListBody, {
70
+ ...baseProps,
71
+ selectionMode: true,
72
+ checkedIds: new Set(["t1", "t2"]),
73
+ busy: true,
74
+ }),
75
+ );
76
+ const dimmed = (html.match(/pointer-events-none opacity-50/g) ?? []).length;
77
+ assert.equal(dimmed, 2, "both seeded rows are dimmed");
78
+ });
79
+
80
+ it("hides pull to refresh while busy", () => {
81
+ const html = renderToString(
82
+ createElement(TouchListBody, { ...baseProps, busy: true }),
83
+ );
84
+ assert.doesNotMatch(html, /Pull to refresh/);
85
+ });
86
+
87
+ it("renders rows undimmed when not busy", () => {
88
+ const html = renderToString(createElement(TouchListBody, baseProps));
89
+ assert.doesNotMatch(html, /pointer-events-none/);
90
+ });
66
91
  });
@@ -93,3 +93,18 @@ export const SelectionModeAllChecked: Story = {
93
93
  export const SelectionModeNoneChecked: Story = {
94
94
  args: { selectionMode: true, checkedIds: new Set<string>() },
95
95
  };
96
+
97
+ /**
98
+ * A bulk delete is running against the checked rows: they stay checked but
99
+ * dim, and stop responding to taps — no more opening a message that's
100
+ * mid-delete. Pairs with `SelectionTopBar`'s `DeletingWithProgress` story.
101
+ */
102
+ export const SelectionModeBusy: Story = {
103
+ args: {
104
+ selectionMode: true,
105
+ checkedIds: new Set(
106
+ sections.flatMap((section) => section.threads.map((t) => t.id)),
107
+ ),
108
+ busy: true,
109
+ },
110
+ };
@@ -14,6 +14,7 @@ export function TouchListBody({
14
14
  onOpenThread,
15
15
  onRefresh,
16
16
  refreshing,
17
+ busy = false,
17
18
  }: {
18
19
  sections: ThreadSection[];
19
20
  selectedThreadId?: string;
@@ -25,6 +26,12 @@ export function TouchListBody({
25
26
  onOpenThread: (id: string) => void;
26
27
  onRefresh: () => void;
27
28
  refreshing: boolean;
29
+ /**
30
+ * A bulk operation (e.g. delete) is running against the checked set. Rows
31
+ * dim and stop responding to taps instead of sitting normal, undimmed and
32
+ * still tappable while a count above them claims they're being deleted.
33
+ */
34
+ busy?: boolean;
28
35
  }) {
29
36
  // Local copy so the mock can act on a swipe: delete removes the row,
30
37
  // toggle-read flips its state. The live client owns real mutation.
@@ -56,24 +63,28 @@ export function TouchListBody({
56
63
  )}
57
64
  <div className="divide-y divide-line">
58
65
  {items.map((thread) => (
59
- <SwipeableRow
66
+ <div
60
67
  key={thread.id}
61
- thread={thread}
62
- selectionMode={selectionMode}
63
- checked={checkedIds.has(thread.id)}
64
- active={thread.id === selectedThreadId}
65
- peek={peek?.id === thread.id ? peek.side : "none"}
66
- onPeek={(next) =>
67
- setPeek(next === "none" ? null : { id: thread.id, side: next })
68
- }
69
- onToggleCheck={() => onToggleCheck(thread.id)}
70
- onLongPress={() => onEnterSelection(thread.id)}
71
- onOpen={() => onOpenThread(thread.id)}
72
- onAct={(side) => act(thread.id, side)}
73
- />
68
+ className={busy ? "pointer-events-none opacity-50" : undefined}
69
+ >
70
+ <SwipeableRow
71
+ thread={thread}
72
+ selectionMode={selectionMode}
73
+ checked={checkedIds.has(thread.id)}
74
+ active={thread.id === selectedThreadId}
75
+ peek={peek?.id === thread.id ? peek.side : "none"}
76
+ onPeek={(next) =>
77
+ setPeek(next === "none" ? null : { id: thread.id, side: next })
78
+ }
79
+ onToggleCheck={() => onToggleCheck(thread.id)}
80
+ onLongPress={() => onEnterSelection(thread.id)}
81
+ onOpen={() => onOpenThread(thread.id)}
82
+ onAct={(side) => act(thread.id, side)}
83
+ />
84
+ </div>
74
85
  ))}
75
86
  </div>
76
- {!selectionMode && !refreshing && (
87
+ {!selectionMode && !refreshing && !busy && (
77
88
  <button
78
89
  type="button"
79
90
  onClick={onRefresh}
package/src/index.ts CHANGED
@@ -75,7 +75,12 @@ export {
75
75
  BriefSections,
76
76
  type BriefSectionsProps,
77
77
  } from "./components/brief-sections.js";
78
- export { Button, type ButtonProps } from "./components/button.js";
78
+ export {
79
+ Button,
80
+ ButtonLink,
81
+ type ButtonLinkProps,
82
+ type ButtonProps,
83
+ } from "./components/button.js";
79
84
  export {
80
85
  Card,
81
86
  CardBody,
@@ -235,10 +240,39 @@ export {
235
240
  type PopoverMenuItem,
236
241
  type PopoverMenuProps,
237
242
  } from "./components/popover-menu.js";
243
+ export {
244
+ ProgressBar,
245
+ type ProgressBarProps,
246
+ } from "./components/progress-bar.js";
238
247
  export {
239
248
  PullToRefresh,
240
249
  type PullToRefreshProps,
241
250
  } from "./components/pull-to-refresh.js";
251
+ export {
252
+ QuarantineBugDialog,
253
+ type QuarantineBugDialogProps,
254
+ } from "./components/quarantine-bug-dialog.js";
255
+ export {
256
+ QuarantineEntryRow,
257
+ type QuarantineEntryRowProps,
258
+ } from "./components/quarantine-entry-row.js";
259
+ export { quarantineDemoEntries } from "./components/quarantine-fixtures.js";
260
+ export {
261
+ formatQuarantineReport,
262
+ QUARANTINE_REPORT_DISCLAIMER,
263
+ type QuarantineEntry,
264
+ type QuarantineFailureCode,
265
+ type QuarantineFailureStage,
266
+ type QuarantineMimeNode,
267
+ type QuarantineReportSections,
268
+ quarantineIssueTitle,
269
+ quarantineReportSections,
270
+ quarantineSummary,
271
+ } from "./components/quarantine-report.js";
272
+ export {
273
+ QuarantineSection,
274
+ type QuarantineSectionProps,
275
+ } from "./components/quarantine-section.js";
242
276
  export {
243
277
  QuotedText,
244
278
  type QuotedTextProps,
@@ -336,6 +370,8 @@ export {
336
370
  export { Select, type SelectProps } from "./components/select.js";
337
371
  export {
338
372
  SelectionTopBar,
373
+ type SelectionTopBarNotice,
374
+ type SelectionTopBarNoticeAction,
339
375
  type SelectionTopBarProps,
340
376
  } from "./components/selection-top-bar.js";
341
377
  export {