@remit/ui 0.0.14 → 0.0.15

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.14",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,29 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { Button } from "./button.js";
6
+
7
+ describe("Button", () => {
8
+ it("defaults to the md size", () => {
9
+ const html = renderToString(createElement(Button, {}, "Save"));
10
+ assert.match(html, /h-9/);
11
+ assert.doesNotMatch(html, /h-11/);
12
+ });
13
+
14
+ it("renders the sm size at 28px", () => {
15
+ const html = renderToString(createElement(Button, { size: "sm" }, "Save"));
16
+ assert.match(html, /h-7/);
17
+ });
18
+
19
+ it("renders the touch size at 44px square", () => {
20
+ const html = renderToString(
21
+ createElement(Button, {
22
+ size: "touch",
23
+ "aria-label": "Delete",
24
+ }),
25
+ );
26
+ assert.match(html, /h-11/);
27
+ assert.match(html, /w-11/);
28
+ });
29
+ });
@@ -2,7 +2,7 @@ import type { ButtonHTMLAttributes, ReactNode } from "react";
2
2
  import { cn } from "../lib/cn.js";
3
3
 
4
4
  type Variant = "primary" | "secondary" | "ghost" | "danger";
5
- type Size = "sm" | "md";
5
+ type Size = "sm" | "md" | "touch";
6
6
 
7
7
  export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
8
8
  variant?: Variant;
@@ -25,6 +25,10 @@ const variants: Record<Variant, string> = {
25
25
  const sizes: Record<Size, string> = {
26
26
  sm: "h-7 px-2.5 text-xs",
27
27
  md: "h-9 px-3.5 text-sm",
28
+ /** 44px square — the touch-target floor (HIG 44 / Material 48-ish). For an
29
+ * icon-only control; a control carrying a text label should size itself
30
+ * with `md` plus an explicit `min-h-11` instead of stretching to square. */
31
+ touch: "h-11 w-11 text-sm",
28
32
  };
29
33
 
30
34
  export function Button({
@@ -30,6 +30,12 @@ export const Buttons: Story = {
30
30
  <Button variant="primary" disabled>
31
31
  Disabled
32
32
  </Button>
33
+ <Button
34
+ variant="ghost"
35
+ size="touch"
36
+ icon={<Mail className="size-4" />}
37
+ aria-label="Touch-sized icon button"
38
+ />
33
39
  </div>
34
40
  ),
35
41
  };
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { ProgressBar } from "./progress-bar.js";
6
+
7
+ describe("ProgressBar", () => {
8
+ it("renders progressbar role with the aria value triad", () => {
9
+ const html = renderToString(
10
+ createElement(ProgressBar, { value: 1200, max: 3412 }),
11
+ );
12
+ assert.match(html, /role="progressbar"/);
13
+ assert.match(html, /aria-valuenow="1200"/);
14
+ assert.match(html, /aria-valuemin="0"/);
15
+ assert.match(html, /aria-valuemax="3412"/);
16
+ });
17
+
18
+ it("computes the fill width as a percentage of value/max", () => {
19
+ const html = renderToString(
20
+ createElement(ProgressBar, { value: 25, max: 100 }),
21
+ );
22
+ assert.match(html, /width:25%/);
23
+ });
24
+
25
+ it("clamps the fill at 100% when value exceeds max", () => {
26
+ const html = renderToString(
27
+ createElement(ProgressBar, { value: 500, max: 100 }),
28
+ );
29
+ assert.match(html, /width:100%/);
30
+ });
31
+
32
+ it("omits the aria value triad when indeterminate", () => {
33
+ const html = renderToString(
34
+ createElement(ProgressBar, { value: 0, max: 0, indeterminate: true }),
35
+ );
36
+ assert.doesNotMatch(html, /aria-valuenow/);
37
+ assert.doesNotMatch(html, /aria-valuemax/);
38
+ });
39
+
40
+ it("applies the tone's fill color", () => {
41
+ const html = renderToString(
42
+ createElement(ProgressBar, { value: 1, max: 2, tone: "danger" }),
43
+ );
44
+ assert.match(html, /bg-danger/);
45
+ });
46
+ });
@@ -0,0 +1,35 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ProgressBar } from "./progress-bar.js";
3
+
4
+ const meta: Meta<typeof ProgressBar> = {
5
+ title: "Components/ProgressBar",
6
+ component: ProgressBar,
7
+ parameters: { layout: "padded" },
8
+ render: (args) => (
9
+ <div className="w-80">
10
+ <ProgressBar {...args} />
11
+ </div>
12
+ ),
13
+ };
14
+ export default meta;
15
+
16
+ type Story = StoryObj<typeof ProgressBar>;
17
+
18
+ export const Started: Story = { args: { value: 340, max: 3412 } };
19
+
20
+ export const Halfway: Story = { args: { value: 1706, max: 3412 } };
21
+
22
+ export const NearlyDone: Story = { args: { value: 3072, max: 3412 } };
23
+
24
+ export const Success: Story = {
25
+ args: { value: 3412, max: 3412, tone: "success" },
26
+ };
27
+
28
+ export const Danger: Story = {
29
+ args: { value: 1200, max: 3412, tone: "danger" },
30
+ };
31
+
32
+ /** Total is unknown — a paging search count, before the first page resolves. */
33
+ export const Indeterminate: Story = {
34
+ args: { value: 0, max: 0, indeterminate: true },
35
+ };
@@ -0,0 +1,62 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import type { BannerTone } from "./banner.js";
3
+
4
+ export interface ProgressBarProps {
5
+ /** Units completed so far. Ignored when `indeterminate`. */
6
+ value: number;
7
+ /** Total units. Ignored when `indeterminate`. */
8
+ max: number;
9
+ tone?: BannerTone;
10
+ /** Unknown total (or unknown rate) — an animated fill with no fixed end. */
11
+ indeterminate?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ const tones: Record<BannerTone, string> = {
16
+ info: "bg-accent-2",
17
+ success: "bg-positive",
18
+ warning: "bg-warning",
19
+ danger: "bg-danger",
20
+ };
21
+
22
+ /**
23
+ * Determinate (or indeterminate) progress meter for a bulk operation, e.g. a
24
+ * multi-thousand-message delete running in batches. A bare running count
25
+ * gives no sense of rate over a long operation; a filling bar answers "is
26
+ * this stuck?" pre-attentively.
27
+ */
28
+ export function ProgressBar({
29
+ value,
30
+ max,
31
+ tone = "info",
32
+ indeterminate = false,
33
+ className,
34
+ }: ProgressBarProps) {
35
+ const pct = indeterminate
36
+ ? 100
37
+ : max <= 0
38
+ ? 0
39
+ : Math.min(100, Math.max(0, (value / max) * 100));
40
+
41
+ return (
42
+ <div
43
+ role="progressbar"
44
+ aria-valuenow={indeterminate ? undefined : value}
45
+ aria-valuemin={indeterminate ? undefined : 0}
46
+ aria-valuemax={indeterminate ? undefined : max}
47
+ className={cn(
48
+ "h-1.5 w-full overflow-hidden rounded-full bg-surface-sunken",
49
+ className,
50
+ )}
51
+ >
52
+ <div
53
+ className={cn(
54
+ "h-full rounded-full transition-[width] duration-300 ease-out",
55
+ tones[tone],
56
+ indeterminate && "w-1/3 animate-pulse",
57
+ )}
58
+ style={indeterminate ? undefined : { width: `${pct}%` }}
59
+ />
60
+ </div>
61
+ );
62
+ }
@@ -9,9 +9,15 @@ const handlers = {
9
9
  onDelete: () => undefined,
10
10
  };
11
11
 
12
- /** SSR interleaves `<!-- -->` markers between interpolated text nodes; strip
13
- * tags/comments so copy assertions match the rendered words. */
14
- const text = (html: string) => html.replace(/<[^>]*>/g, "");
12
+ /** SSR interleaves `<!-- -->` markers between interpolated text nodes and
13
+ * HTML-encodes entities; strip tags/comments and decode the handful of
14
+ * entities copy assertions actually hit, so they match the rendered words. */
15
+ const text = (html: string) =>
16
+ html
17
+ .replace(/<[^>]*>/g, "")
18
+ .replace(/&quot;/g, '"')
19
+ .replace(/&#x27;/g, "'")
20
+ .replace(/&amp;/g, "&");
15
21
 
16
22
  describe("SelectionTopBar", () => {
17
23
  it("renders singular copy for one message", () => {
@@ -28,12 +34,41 @@ describe("SelectionTopBar", () => {
28
34
  assert.match(text(html), /3 messages selected/);
29
35
  });
30
36
 
37
+ it("applies thousands separators to the default count copy", () => {
38
+ const html = renderToString(
39
+ createElement(SelectionTopBar, { ...handlers, count: 3412 }),
40
+ );
41
+ assert.match(text(html), /3,412 messages selected/);
42
+ });
43
+
31
44
  it("renders cancel and delete controls", () => {
32
45
  const html = renderToString(
33
46
  createElement(SelectionTopBar, { ...handlers, count: 2 }),
34
47
  );
35
48
  assert.match(html, /aria-label="Cancel selection"/);
36
- assert.match(html, /aria-label="Delete selected messages"/);
49
+ assert.match(html, /aria-label="Move selected messages to Trash"/);
50
+ });
51
+
52
+ it("renders cancel, mark-read and delete at the 44px touch size", () => {
53
+ const html = renderToString(
54
+ createElement(SelectionTopBar, {
55
+ ...handlers,
56
+ count: 2,
57
+ onMarkRead: () => undefined,
58
+ }),
59
+ );
60
+ const buttonCount = (html.match(/h-11 w-11/g) ?? []).length;
61
+ assert.equal(buttonCount, 3, "cancel, mark-read and delete are all 44px");
62
+ });
63
+
64
+ it("spaces delete at least 16px from its preceding sibling", () => {
65
+ const html = renderToString(
66
+ createElement(SelectionTopBar, { ...handlers, count: 2 }),
67
+ );
68
+ assert.match(
69
+ html,
70
+ /class="[^"]*\bml-4\b[^"]*"[^>]*aria-label="Move selected messages to Trash"/,
71
+ );
37
72
  });
38
73
 
39
74
  it("renders mark-read control when onMarkRead is provided", () => {
@@ -54,15 +89,27 @@ describe("SelectionTopBar", () => {
54
89
  assert.doesNotMatch(html, /aria-label="Mark as read"/);
55
90
  });
56
91
 
57
- it("renders moveDisabledHint when provided", () => {
92
+ it("hides mark-read while busy instead of disabling it", () => {
58
93
  const html = renderToString(
59
94
  createElement(SelectionTopBar, {
60
95
  ...handlers,
61
96
  count: 2,
62
- moveDisabledHint: "Cross-account moves are not supported",
97
+ onMarkRead: () => undefined,
98
+ isBusy: true,
63
99
  }),
64
100
  );
65
- assert.match(html, /Cross-account moves are not supported/);
101
+ assert.doesNotMatch(html, /aria-label="Mark as read"/);
102
+ });
103
+
104
+ it("hides delete while counting", () => {
105
+ const html = renderToString(
106
+ createElement(SelectionTopBar, {
107
+ ...handlers,
108
+ count: 0,
109
+ isCounting: true,
110
+ }),
111
+ );
112
+ assert.doesNotMatch(html, /aria-label="Move selected messages to Trash"/);
66
113
  });
67
114
 
68
115
  it("renders statusLabel in place of the count copy when provided", () => {
@@ -74,7 +121,7 @@ describe("SelectionTopBar", () => {
74
121
  }),
75
122
  );
76
123
  assert.match(text(html), /Deleting 1,200 of 3,412…/);
77
- assert.doesNotMatch(text(html), /3412 messages selected/);
124
+ assert.doesNotMatch(text(html), /3,412 messages selected/);
78
125
  });
79
126
 
80
127
  it("falls back to the count copy when statusLabel is absent", () => {
@@ -84,15 +131,14 @@ describe("SelectionTopBar", () => {
84
131
  assert.match(text(html), /2 messages selected/);
85
132
  });
86
133
 
87
- it("renders failureHint when provided", () => {
134
+ it("marks the count/status line as a polite live region", () => {
88
135
  const html = renderToString(
89
- createElement(SelectionTopBar, {
90
- ...handlers,
91
- count: 2,
92
- failureHint: "340 failed to delete — retry?",
93
- }),
136
+ createElement(SelectionTopBar, { ...handlers, count: 2 }),
137
+ );
138
+ assert.match(
139
+ html,
140
+ /role="status"[^>]*aria-live="polite"[^>]*>2 messages selected/,
94
141
  );
95
- assert.match(html, /340 failed to delete — retry\?/);
96
142
  });
97
143
 
98
144
  it("omits the select-all control when selectAll is absent", () => {
@@ -141,7 +187,111 @@ describe("SelectionTopBar", () => {
141
187
  );
142
188
  });
143
189
 
144
- it("omitting every new prop renders exactly the pre-existing bar", () => {
190
+ it("names the loaded scope by default once select-all is checked", () => {
191
+ const html = renderToString(
192
+ createElement(SelectionTopBar, {
193
+ ...handlers,
194
+ count: 47,
195
+ selectAll: {
196
+ checked: true,
197
+ indeterminate: false,
198
+ onChange: () => undefined,
199
+ },
200
+ }),
201
+ );
202
+ assert.match(text(html), /All 47 loaded selected/);
203
+ assert.doesNotMatch(text(html), /^47 messages selected/);
204
+ });
205
+
206
+ it("lets statusLabel override the scoped default for an escalated selection", () => {
207
+ const html = renderToString(
208
+ createElement(SelectionTopBar, {
209
+ ...handlers,
210
+ count: 3412,
211
+ selectAll: { checked: true, onChange: () => undefined },
212
+ statusLabel: 'All 3,412 matching "npm" selected',
213
+ }),
214
+ );
215
+ assert.match(text(html), /All 3,412 matching "npm" selected/);
216
+ });
217
+
218
+ it("gives the select-all checkbox a 44px hit area", () => {
219
+ const html = renderToString(
220
+ createElement(SelectionTopBar, {
221
+ ...handlers,
222
+ count: 2,
223
+ selectAll: {
224
+ checked: false,
225
+ onChange: () => undefined,
226
+ },
227
+ }),
228
+ );
229
+ assert.match(html, /<label[^>]*size-11[^>]*>/);
230
+ });
231
+
232
+ it("renders the notice text and tone", () => {
233
+ const html = renderToString(
234
+ createElement(SelectionTopBar, {
235
+ ...handlers,
236
+ count: 2,
237
+ notice: {
238
+ tone: "warning",
239
+ text: "Move only works within one account",
240
+ },
241
+ }),
242
+ );
243
+ assert.match(text(html), /Move only works within one account/);
244
+ assert.match(html, /role="status"/);
245
+ });
246
+
247
+ it("renders the notice's action as a real button, not prose", () => {
248
+ const onRetry = () => undefined;
249
+ const html = renderToString(
250
+ createElement(SelectionTopBar, {
251
+ ...handlers,
252
+ count: 340,
253
+ notice: {
254
+ tone: "danger",
255
+ text: "3,072 moved to Trash. 340 couldn't be deleted.",
256
+ action: { label: "Retry 340", onClick: onRetry },
257
+ },
258
+ }),
259
+ );
260
+ assert.match(html, /<button[^>]*>Retry 340<\/button>/);
261
+ });
262
+
263
+ it("omits the notice when absent", () => {
264
+ const html = renderToString(
265
+ createElement(SelectionTopBar, { ...handlers, count: 2 }),
266
+ );
267
+ // Only the count/status line carries role="status" — no second one for
268
+ // an absent notice.
269
+ const statusRoles = (html.match(/role="status"/g) ?? []).length;
270
+ assert.equal(statusRoles, 1);
271
+ });
272
+
273
+ it("renders a determinate progress bar when progress is provided", () => {
274
+ const html = renderToString(
275
+ createElement(SelectionTopBar, {
276
+ ...handlers,
277
+ count: 3412,
278
+ statusLabel: "Deleting 1,200 of 3,412…",
279
+ isBusy: true,
280
+ progress: { value: 1200, max: 3412 },
281
+ }),
282
+ );
283
+ assert.match(html, /role="progressbar"/);
284
+ assert.match(html, /aria-valuenow="1200"/);
285
+ });
286
+
287
+ it("omits the progress bar when progress is absent", () => {
288
+ const html = renderToString(
289
+ createElement(SelectionTopBar, { ...handlers, count: 2 }),
290
+ );
291
+ assert.doesNotMatch(html, /role="progressbar"/);
292
+ });
293
+
294
+ it("omitting every new prop renders exactly the pre-existing bar shape", () => {
145
295
  const html = renderToString(
146
296
  createElement(SelectionTopBar, { ...handlers, count: 2 }),
147
297
  );
@@ -151,6 +301,6 @@ describe("SelectionTopBar", () => {
151
301
  "no select-all control",
152
302
  );
153
303
  assert.match(text(html), /2 messages selected/, "default count copy");
154
- assert.doesNotMatch(html, /role="status"/, "no status line rendered");
304
+ assert.doesNotMatch(html, /role="progressbar"/, "no progress bar");
155
305
  });
156
306
  });
@@ -10,8 +10,10 @@ const meta: Meta<typeof SelectionTopBar> = {
10
10
  onMarkRead: () => undefined,
11
11
  onDelete: () => undefined,
12
12
  },
13
+ // Full viewport width — a fixed w-[390px] wrapper inside a 390px Storybook
14
+ // viewport clipped the delete button off-screen in every story here.
13
15
  render: (args) => (
14
- <div className="w-[390px] rounded-md border border-line">
16
+ <div className="w-full rounded-md border border-line">
15
17
  <SelectionTopBar {...args} />
16
18
  </div>
17
19
  ),
@@ -33,12 +35,14 @@ export const Busy: Story = { args: { count: 2, isBusy: true } };
33
35
  export const CrossAccountHint: Story = {
34
36
  args: {
35
37
  count: 4,
36
- moveDisabledHint:
37
- "Move only works within one account — clear selection or pick messages from a single account",
38
+ notice: {
39
+ tone: "warning",
40
+ text: "Move only works within one account — clear selection or pick messages from a single account",
41
+ },
38
42
  },
39
43
  };
40
44
 
41
- /** Some but not all rows checked: the select-all control renders the
45
+ /** Some but not all loaded rows checked: the select-all control renders the
42
46
  * `Checkbox` tri-state dash, not the box or the tick. */
43
47
  export const SelectAll: Story = {
44
48
  args: {
@@ -51,44 +55,130 @@ export const SelectAll: Story = {
51
55
  },
52
56
  };
53
57
 
54
- /** Every row checked: the select-all control renders as a plain checked box. */
58
+ /**
59
+ * Every loaded row checked. The count line names its scope by default —
60
+ * "All 47 loaded selected" — instead of a bare "47 messages selected" next
61
+ * to a fully ticked box, which reads as "everything" to anyone who has used
62
+ * a select-all checkbox before.
63
+ */
55
64
  export const AllSelected: Story = {
56
65
  args: {
57
- count: 12,
66
+ count: 47,
67
+ selectAll: {
68
+ checked: true,
69
+ indeterminate: false,
70
+ onChange: () => undefined,
71
+ },
72
+ },
73
+ };
74
+
75
+ /**
76
+ * The search has more matches than are loaded: an escalation notice offers a
77
+ * real button (not prose) naming the total. Tapping it is what flips the
78
+ * selection's identity from an id set to the search query (out of scope for
79
+ * this kit — the caller supplies the count once paging resolves it).
80
+ */
81
+ export const EscalationAvailable: Story = {
82
+ args: {
83
+ count: 47,
58
84
  selectAll: {
59
85
  checked: true,
60
86
  indeterminate: false,
61
87
  onChange: () => undefined,
62
88
  },
89
+ notice: {
90
+ tone: "info",
91
+ text: "",
92
+ action: {
93
+ label: 'Select all 3,412 matching "npm"',
94
+ onClick: () => undefined,
95
+ },
96
+ },
63
97
  },
64
98
  };
65
99
 
66
- /** While a search result set is still paging, the exact count isn't known yet —
67
- * `statusLabel` replaces the "{count} selected" text with a counting message. */
100
+ /**
101
+ * Selection has been escalated to the search query: the count names the
102
+ * query's total, not a materialized id count, and the notice offers a way
103
+ * back to the bounded selection.
104
+ */
105
+ export const Escalated: Story = {
106
+ args: {
107
+ count: 3412,
108
+ statusLabel: 'All 3,412 matching "npm" selected',
109
+ notice: {
110
+ tone: "info",
111
+ text: "",
112
+ action: { label: "Clear selection", onClick: () => undefined },
113
+ },
114
+ },
115
+ };
116
+
117
+ /**
118
+ * While a search result set is still paging, the exact count isn't known
119
+ * yet — a running total instead of a static "Counting…", delete hidden
120
+ * (nothing to act on with an unknown total), and an explicit Stop rather
121
+ * than overloading the X (which still means "cancel selection").
122
+ */
68
123
  export const Counting: Story = {
69
124
  args: {
70
125
  count: 0,
71
- statusLabel: "Counting matching messages…",
126
+ isCounting: true,
127
+ statusLabel: "Counting… 1,900 so far",
128
+ selectAll: {
129
+ checked: true,
130
+ indeterminate: false,
131
+ onChange: () => undefined,
132
+ },
133
+ notice: {
134
+ tone: "info",
135
+ text: "",
136
+ action: { label: "Stop", onClick: () => undefined },
137
+ },
72
138
  },
73
139
  };
74
140
 
75
- /** A bulk delete in progress reports a running total via `statusLabel`, and
76
- * the delete button shows its busy spinner (never disables). */
141
+ /** Past ~10s the counting state says so, rather than looking stuck. */
142
+ export const CountingLargeResultSet: Story = {
143
+ args: {
144
+ count: 0,
145
+ isCounting: true,
146
+ statusLabel: "Counting… 12,400 so far. This is a big result set.",
147
+ notice: {
148
+ tone: "info",
149
+ text: "",
150
+ action: { label: "Stop", onClick: () => undefined },
151
+ },
152
+ },
153
+ };
154
+
155
+ /**
156
+ * A bulk delete in progress reports a running total via `statusLabel` and a
157
+ * determinate `ProgressBar`; the delete button shows its busy spinner (never
158
+ * disables) and mark-read is hidden — nothing here can act mid-delete.
159
+ */
77
160
  export const DeletingWithProgress: Story = {
78
161
  args: {
79
162
  count: 3412,
80
163
  statusLabel: "Deleting 1,200 of 3,412…",
81
164
  isBusy: true,
165
+ progress: { value: 1200, max: 3412 },
82
166
  },
83
167
  };
84
168
 
85
- /** After a bulk delete finishes with some batches failed, `failureHint`
86
- * surfaces the shortfall in danger tone independent of `moveDisabledHint`,
87
- * which is muted and cross-account-specific. */
169
+ /**
170
+ * After a bulk delete finishes with some batches failed: the count reflects
171
+ * only what's still selected — the failures — not the original selection,
172
+ * and Retry is a real button naming how many.
173
+ */
88
174
  export const PartialFailure: Story = {
89
175
  args: {
90
- count: 3412,
91
- failureHint: "3,072 deleted, 340 failed to delete — retry?",
176
+ count: 340,
177
+ notice: {
178
+ tone: "danger",
179
+ text: "3,072 moved to Trash. 340 couldn't be deleted.",
180
+ action: { label: "Retry 340", onClick: () => undefined },
181
+ },
92
182
  },
93
183
  };
94
184
 
@@ -1,34 +1,51 @@
1
1
  import { Loader2, MailOpen, Trash2, X } from "lucide-react";
2
2
  import type { ReactNode } from "react";
3
+ import { Banner, type BannerTone } from "./banner.js";
3
4
  import { Button } from "./button.js";
4
5
  import { Checkbox } from "./checkbox.js";
6
+ import { ProgressBar } from "./progress-bar.js";
7
+
8
+ const formatCount = (n: number): string => n.toLocaleString();
9
+
10
+ export interface SelectionTopBarNoticeAction {
11
+ label: string;
12
+ onClick: () => void;
13
+ }
14
+
15
+ export interface SelectionTopBarNotice {
16
+ tone: BannerTone;
17
+ text: string;
18
+ action?: SelectionTopBarNoticeAction;
19
+ }
5
20
 
6
21
  export interface SelectionTopBarProps {
7
22
  count: number;
8
23
  onCancel: () => void;
9
24
  onDelete: () => void;
10
- /** Optional — hide the mark-read button when omitted. */
25
+ /** Optional — hide the mark-read button when omitted, or while `isBusy`. */
11
26
  onMarkRead?: () => void;
12
27
  /**
13
28
  * Slot for a move-to-folder trigger. Rendered between mark-read and delete.
14
29
  * Kept as a render prop so the caller controls API dependencies.
15
30
  */
16
31
  moveSlot?: ReactNode;
17
- /**
18
- * Cross-account hint surfaced below the action row. When set, Move is
19
- * expected to be suppressed by the caller (via moveSlot).
20
- */
21
- moveDisabledHint?: string;
22
32
  /**
23
33
  * True while a delete or move mutation is in flight. The delete button
24
- * shows a spinner; other actions no-op. Never disables controls.
34
+ * shows a spinner and mark-read is hidden (never disabled — nothing here
35
+ * disables, states that can't act are hidden instead).
25
36
  */
26
37
  isBusy?: boolean;
38
+ /**
39
+ * True while a search result set is still paging to find its total. Hides
40
+ * delete — the count it would act on isn't known yet.
41
+ */
42
+ isCounting?: boolean;
27
43
  /**
28
44
  * Select-all control rendered between cancel and the count label. Presence
29
45
  * of this prop is what renders the checkbox — omit it for a bar with no
30
46
  * select-all affordance. `indeterminate` renders the some-selected tri-state
31
- * (`Checkbox`'s dash), `checked` is the all-selected state.
47
+ * (`Checkbox`'s dash), `checked` is the all-selected state. The checkbox
48
+ * itself stays visually small; a wrapping 44px hit area makes it tappable.
32
49
  */
33
50
  selectAll?: {
34
51
  checked: boolean;
@@ -36,18 +53,31 @@ export interface SelectionTopBarProps {
36
53
  onChange: () => void;
37
54
  };
38
55
  /**
39
- * Overrides the "{count} messages selected" text. For states where the
40
- * count itself isn't the useful thing to show yet "Counting…" while a
41
- * search result set is still paging, or "Deleting 1,200 of 3,412…" progress
42
- * during a bulk delete.
56
+ * Overrides the default "{count} messages selected" text. Required once the
57
+ * count's scope is anything other than "every loaded row is selected"
58
+ * an escalated selection ("All 3,412 matching \"npm\" selected"), a
59
+ * counting state ("Counting… 1,900 so far"), or bulk-delete progress
60
+ * ("Deleting 1,200 of 3,412…"). When `selectAll.checked` is true and this
61
+ * is omitted, the default text names the loaded-scope itself ("All 47
62
+ * loaded selected") rather than a bare count — a ticked select-all box
63
+ * next to a bare number reads as "everything", which is only true for the
64
+ * escalated case.
43
65
  */
44
66
  statusLabel?: string;
45
67
  /**
46
- * Danger-toned status line below the action row, for a partial failure
47
- * after a bulk operation (e.g. some batches failed to delete). Independent
48
- * of `moveDisabledHint` — a caller shows one or the other, never both.
68
+ * Determinate progress for a bulk operation in flight (e.g. a chunked
69
+ * delete). Renders a `ProgressBar` below the action row. Independent of
70
+ * `notice` — a caller can show progress and a notice at the same time.
71
+ */
72
+ progress?: { value: number; max: number; tone?: BannerTone };
73
+ /**
74
+ * Toned status line below the action row, sometimes carrying an action
75
+ * button — a cross-account move restriction, a "Select all N matching…"
76
+ * escalation, a "Stop" during counting, or a partial-failure "Retry N".
77
+ * Replaces the old `moveDisabledHint`/`failureHint` pair: a caller shows
78
+ * at most one notice at a time.
49
79
  */
50
- failureHint?: string;
80
+ notice?: SelectionTopBarNotice;
51
81
  }
52
82
 
53
83
  /**
@@ -60,83 +90,106 @@ export function SelectionTopBar({
60
90
  onMarkRead,
61
91
  onDelete,
62
92
  moveSlot,
63
- moveDisabledHint,
64
93
  isBusy = false,
94
+ isCounting = false,
65
95
  selectAll,
66
96
  statusLabel,
67
- failureHint,
97
+ progress,
98
+ notice,
68
99
  }: SelectionTopBarProps) {
100
+ const defaultLabel = selectAll?.checked
101
+ ? `All ${formatCount(count)} loaded selected`
102
+ : `${formatCount(count)} ${count === 1 ? "message" : "messages"} selected`;
103
+
69
104
  return (
70
105
  <header className="flex shrink-0 flex-col border-b border-line bg-surface-sunken">
71
106
  <div className="flex h-pane-header items-center gap-2 px-row-inset">
72
107
  <Button
73
108
  variant="ghost"
74
- size="sm"
109
+ size="touch"
75
110
  icon={<X className="size-4" />}
76
111
  onClick={onCancel}
77
112
  aria-label="Cancel selection"
78
- className="-ml-1 shrink-0"
113
+ className="-ml-2 shrink-0"
79
114
  />
80
115
  {selectAll && (
81
- <Checkbox
82
- aria-label="Select all"
83
- checked={selectAll.checked}
84
- indeterminate={selectAll.indeterminate}
85
- onChange={selectAll.onChange}
86
- className="shrink-0"
87
- />
116
+ // biome-ignore lint/a11y/noLabelWithoutControl: label wraps Checkbox's own input, giving the 20px control a real 44px hit area
117
+ <label className="-ml-1.5 flex size-11 shrink-0 cursor-pointer items-center justify-center">
118
+ <Checkbox
119
+ aria-label="Select all"
120
+ checked={selectAll.checked}
121
+ indeterminate={selectAll.indeterminate}
122
+ onChange={selectAll.onChange}
123
+ />
124
+ </label>
88
125
  )}
89
- <span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
90
- {statusLabel ??
91
- `${count} ${count === 1 ? "message" : "messages"} selected`}
126
+ <span
127
+ className="min-w-0 flex-1 truncate text-sm font-medium text-fg"
128
+ role="status"
129
+ aria-live="polite"
130
+ >
131
+ {statusLabel ?? defaultLabel}
92
132
  </span>
93
- {onMarkRead && (
133
+ {onMarkRead && !isBusy && (
94
134
  <Button
95
135
  variant="ghost"
96
- size="sm"
136
+ size="touch"
97
137
  icon={<MailOpen className="size-4" />}
98
- onClick={isBusy ? undefined : onMarkRead}
138
+ onClick={onMarkRead}
99
139
  aria-label="Mark as read"
100
- aria-busy={isBusy || undefined}
101
140
  className="shrink-0"
102
141
  />
103
142
  )}
104
143
  {moveSlot}
105
- <Button
106
- variant="ghost"
107
- size="sm"
108
- icon={
109
- isBusy ? (
110
- <Loader2 className="size-4 animate-spin" />
111
- ) : (
112
- <Trash2 className="size-4 text-danger" />
113
- )
114
- }
115
- onClick={isBusy ? undefined : onDelete}
116
- aria-label="Delete selected messages"
117
- aria-busy={isBusy || undefined}
118
- className="shrink-0"
119
- />
144
+ {!isCounting && (
145
+ <Button
146
+ variant="ghost"
147
+ size="touch"
148
+ icon={
149
+ isBusy ? (
150
+ <Loader2 className="size-4 animate-spin" />
151
+ ) : (
152
+ <Trash2 className="size-4 text-danger" />
153
+ )
154
+ }
155
+ onClick={isBusy ? undefined : onDelete}
156
+ aria-label="Move selected messages to Trash"
157
+ aria-busy={isBusy || undefined}
158
+ className="ml-4 shrink-0"
159
+ />
160
+ )}
120
161
  </div>
121
- {moveDisabledHint && (
122
- // biome-ignore lint/a11y/useSemanticElements: <p> with role="status" preserves block layout; <output> is inline
123
- <p
124
- className="px-row-inset pb-2 text-xs text-fg-muted"
125
- role="status"
126
- aria-live="polite"
127
- >
128
- {moveDisabledHint}
129
- </p>
162
+ {progress && (
163
+ <div className="px-row-inset pb-2">
164
+ <ProgressBar
165
+ value={progress.value}
166
+ max={progress.max}
167
+ tone={progress.tone}
168
+ />
169
+ </div>
130
170
  )}
131
- {failureHint && (
132
- // biome-ignore lint/a11y/useSemanticElements: <p> with role="status" preserves block layout; <output> is inline
133
- <p
134
- className="px-row-inset pb-2 text-xs text-danger"
171
+ {notice && (
172
+ <Banner
173
+ tone={notice.tone}
174
+ variant="soft"
135
175
  role="status"
136
176
  aria-live="polite"
177
+ className="mx-row-inset mb-2"
137
178
  >
138
- {failureHint}
139
- </p>
179
+ <div className="flex items-center justify-between gap-2">
180
+ {notice.text && <span>{notice.text}</span>}
181
+ {notice.action && (
182
+ <Button
183
+ variant="ghost"
184
+ size="md"
185
+ onClick={notice.action.onClick}
186
+ className="-my-1 min-h-11 shrink-0"
187
+ >
188
+ {notice.action.label}
189
+ </Button>
190
+ )}
191
+ </div>
192
+ </Banner>
140
193
  )}
141
194
  </header>
142
195
  );
@@ -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
@@ -235,6 +235,10 @@ export {
235
235
  type PopoverMenuItem,
236
236
  type PopoverMenuProps,
237
237
  } from "./components/popover-menu.js";
238
+ export {
239
+ ProgressBar,
240
+ type ProgressBarProps,
241
+ } from "./components/progress-bar.js";
238
242
  export {
239
243
  PullToRefresh,
240
244
  type PullToRefreshProps,
@@ -336,6 +340,8 @@ export {
336
340
  export { Select, type SelectProps } from "./components/select.js";
337
341
  export {
338
342
  SelectionTopBar,
343
+ type SelectionTopBarNotice,
344
+ type SelectionTopBarNoticeAction,
339
345
  type SelectionTopBarProps,
340
346
  } from "./components/selection-top-bar.js";
341
347
  export {