@remit/ui 0.0.58 → 0.0.60

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.
@@ -5,6 +5,7 @@ import { renderToString } from "react-dom/server";
5
5
  import { SelectionTopBar } from "./selection-top-bar.js";
6
6
 
7
7
  const handlers = {
8
+ title: "Inbox",
8
9
  onCancel: () => undefined,
9
10
  onDelete: () => undefined,
10
11
  };
@@ -19,12 +20,113 @@ const text = (html: string) =>
19
20
  .replace(/'/g, "'")
20
21
  .replace(/&/g, "&");
21
22
 
23
+ /** The bar's own rows, sliced out of the rendered markup so an assertion can
24
+ * say which row a control landed on. Row one is the count and the verbs; the
25
+ * select-all row exists only below 768px, while selecting. */
26
+ const row = (html: string, name: "actions" | "select-all"): string => {
27
+ const open = html.indexOf(`data-selection-bar-row="${name}"`);
28
+ assert.notEqual(open, -1, `no ${name} row rendered`);
29
+ const rest = html.slice(open);
30
+ const next = rest.indexOf("data-selection-bar-row=", 1);
31
+ return next === -1 ? rest : rest.slice(0, next);
32
+ };
33
+
34
+ const hasRow = (html: string, name: "actions" | "select-all"): boolean =>
35
+ html.includes(`data-selection-bar-row="${name}"`);
36
+
37
+ const INLINE_SELECT_ALL = /hidden shrink-0 pr-2 md:flex/;
38
+
39
+ const selectAll = { checked: false, onChange: () => undefined };
40
+
22
41
  describe("SelectionTopBar", () => {
23
- it("renders singular copy for one message", () => {
42
+ it("names the mailbox when nothing is ticked", () => {
43
+ const html = renderToString(
44
+ createElement(SelectionTopBar, { ...handlers, count: 0 }),
45
+ );
46
+ assert.match(text(html), /Inbox/);
47
+ assert.doesNotMatch(text(html), /messages selected/);
48
+ });
49
+
50
+ it("carries the list header's own chrome while nothing is ticked", () => {
51
+ const html = renderToString(
52
+ createElement(SelectionTopBar, {
53
+ ...handlers,
54
+ count: 0,
55
+ navSlot: createElement("button", { type: "button" }, "Menu"),
56
+ titleMeta: createElement("span", null, "12 unread"),
57
+ searchSlot: createElement("button", { type: "button" }, "Search"),
58
+ }),
59
+ );
60
+ assert.match(text(html), /Menu/);
61
+ assert.match(text(html), /12 unread/);
62
+ assert.match(text(html), /Search/);
63
+ });
64
+
65
+ it("hands the row to the count and the verbs from the first ticked row", () => {
66
+ const html = renderToString(
67
+ createElement(SelectionTopBar, {
68
+ ...handlers,
69
+ count: 1,
70
+ navSlot: createElement("button", { type: "button" }, "Menu"),
71
+ titleMeta: createElement("span", null, "12 unread"),
72
+ searchSlot: createElement("button", { type: "button" }, "Search"),
73
+ }),
74
+ );
75
+ assert.match(text(html), /1 message selected/);
76
+ assert.doesNotMatch(text(html), /12 unread/);
77
+ assert.doesNotMatch(text(html), /Search/);
78
+ });
79
+
80
+ it("lets the expanded search field take the title's place while idle", () => {
81
+ const html = renderToString(
82
+ createElement(SelectionTopBar, {
83
+ ...handlers,
84
+ count: 0,
85
+ searchField: createElement("input", { "aria-label": "Search mail" }),
86
+ }),
87
+ );
88
+ assert.match(html, /aria-label="Search mail"/);
89
+ assert.doesNotMatch(text(html), /Inbox/);
90
+ });
91
+
92
+ it("carries the make-filter row while nothing is ticked, and drops it once something is", () => {
93
+ const idleSlot = createElement("button", { type: "button" }, "Make filter");
94
+ assert.match(
95
+ text(
96
+ renderToString(
97
+ createElement(SelectionTopBar, { ...handlers, count: 0, idleSlot }),
98
+ ),
99
+ ),
100
+ /Make filter/,
101
+ );
102
+ assert.doesNotMatch(
103
+ text(
104
+ renderToString(
105
+ createElement(SelectionTopBar, { ...handlers, count: 2, idleSlot }),
106
+ ),
107
+ ),
108
+ /Make filter/,
109
+ );
110
+ });
111
+
112
+ it("offers Something else as an overflow verb, never a glyph", () => {
113
+ const html = renderToString(
114
+ createElement(SelectionTopBar, {
115
+ ...handlers,
116
+ count: 2,
117
+ onSomethingElse: () => undefined,
118
+ }),
119
+ );
120
+ assert.match(html, /aria-label="More actions"/);
121
+ assert.doesNotMatch(row(html, "actions"), /aria-label="Something else"/);
122
+ });
123
+
124
+ it("replaces the title with the count from the first ticked row", () => {
24
125
  const html = renderToString(
25
126
  createElement(SelectionTopBar, { ...handlers, count: 1 }),
26
127
  );
27
128
  assert.match(text(html), /1 message selected/);
129
+ assert.doesNotMatch(text(html), /Inbox/);
28
130
  });
29
131
 
30
132
  it("renders plural copy for many messages", () => {
@@ -41,75 +143,151 @@ describe("SelectionTopBar", () => {
41
143
  assert.match(text(html), /3,412 messages selected/);
42
144
  });
43
145
 
44
- it("renders cancel and delete controls", () => {
146
+ describe("at 375px", () => {
45
147
  const html = renderToString(
46
- createElement(SelectionTopBar, { ...handlers, count: 2 }),
148
+ createElement(SelectionTopBar, {
149
+ ...handlers,
150
+ count: 3,
151
+ selectAll,
152
+ onOrganize: () => undefined,
153
+ moveSlot: createElement(
154
+ "button",
155
+ { type: "button", "aria-label": "Move selected messages" },
156
+ "Move",
157
+ ),
158
+ }),
47
159
  );
48
- assert.match(html, /aria-label="Cancel selection"/);
49
- assert.match(html, /aria-label="Move selected messages to Trash"/);
160
+
161
+ it("carries the count and the three verbs on row one", () => {
162
+ const actions = row(html, "actions");
163
+ assert.match(actions, /3 messages selected/);
164
+ assert.match(actions, /aria-label="Move selected messages to Trash"/);
165
+ assert.match(actions, /aria-label="Move selected messages"/);
166
+ assert.match(actions, /aria-label="Organize selected messages"/);
167
+ });
168
+
169
+ it("puts select-all on a second row, out of the way of the verbs", () => {
170
+ assert.ok(hasRow(html, "select-all"));
171
+ const secondRow = row(html, "select-all");
172
+ assert.match(secondRow, /Select all/);
173
+ assert.match(
174
+ secondRow,
175
+ /md:hidden/,
176
+ "the second row exists only below 768px",
177
+ );
178
+ assert.match(
179
+ row(html, "actions"),
180
+ INLINE_SELECT_ALL,
181
+ "the inline copy is the one that stands down below 768px",
182
+ );
183
+ });
184
+
185
+ it("offers a back arrow to leave selection", () => {
186
+ assert.match(row(html, "actions"), /aria-label="Cancel selection"/);
187
+ });
50
188
  });
51
189
 
52
- it("renders cancel, mark-read and delete at the 44px touch size", () => {
190
+ describe("at 1024px", () => {
191
+ it("keeps select-all inline in the bar with zero rows ticked", () => {
192
+ const html = renderToString(
193
+ createElement(SelectionTopBar, { ...handlers, count: 0, selectAll }),
194
+ );
195
+ const actions = row(html, "actions");
196
+ assert.match(actions, /Select all/);
197
+ assert.match(actions, INLINE_SELECT_ALL, "inline from 768px up");
198
+ assert.doesNotMatch(
199
+ actions,
200
+ /aria-label="Cancel selection"/,
201
+ "nothing to leave with nothing ticked",
202
+ );
203
+ assert.equal(
204
+ hasRow(html, "select-all"),
205
+ false,
206
+ "no second row when nothing is ticked",
207
+ );
208
+ });
209
+ });
210
+
211
+ it("renders Junk and Mark read in the overflow menu, not the verb row", () => {
53
212
  const html = renderToString(
54
213
  createElement(SelectionTopBar, {
55
214
  ...handlers,
56
215
  count: 2,
216
+ onJunk: () => undefined,
57
217
  onMarkRead: () => undefined,
58
218
  }),
59
219
  );
60
- const buttonCount = (html.match(/h-11 w-11/g) ?? []).length;
61
- assert.equal(buttonCount, 3, "cancel, mark-read and delete are all 44px");
220
+ assert.match(html, /aria-label="More actions"/);
221
+ assert.match(html, /aria-haspopup="menu"/);
222
+ assert.doesNotMatch(row(html, "actions"), /aria-label="Mark as read"/);
62
223
  });
63
224
 
64
- it("spaces delete at least 16px from its preceding sibling", () => {
225
+ it("keeps the overflow menu for an overflowSlot with no verbs of its own", () => {
65
226
  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"/,
227
+ createElement(SelectionTopBar, {
228
+ ...handlers,
229
+ count: 2,
230
+ overflowSlot: createElement("span", null, "Apply label"),
231
+ }),
71
232
  );
233
+ assert.match(html, /aria-label="More actions"/);
72
234
  });
73
235
 
74
- it("renders mark-read control when onMarkRead is provided", () => {
236
+ it("withdraws the overflowSlot while busy", () => {
75
237
  const html = renderToString(
76
238
  createElement(SelectionTopBar, {
77
239
  ...handlers,
78
240
  count: 2,
79
- onMarkRead: () => undefined,
241
+ isBusy: true,
242
+ overflowSlot: createElement("span", null, "Apply label"),
80
243
  }),
81
244
  );
82
- assert.match(html, /aria-label="Mark as read"/);
245
+ assert.doesNotMatch(html, /aria-label="More actions"/);
83
246
  });
84
247
 
85
- it("omits mark-read control when onMarkRead is absent", () => {
248
+ it("renders no overflow trigger when neither overflow verb is wired", () => {
86
249
  const html = renderToString(
87
250
  createElement(SelectionTopBar, { ...handlers, count: 2 }),
88
251
  );
89
- assert.doesNotMatch(html, /aria-label="Mark as read"/);
252
+ assert.doesNotMatch(html, /aria-label="More actions"/);
90
253
  });
91
254
 
92
- it("hides mark-read while busy instead of disabling it", () => {
255
+ it("drops the overflow verbs while busy instead of disabling them", () => {
93
256
  const html = renderToString(
94
257
  createElement(SelectionTopBar, {
95
258
  ...handlers,
96
259
  count: 2,
97
260
  onMarkRead: () => undefined,
261
+ onJunk: () => undefined,
98
262
  isBusy: true,
99
263
  }),
100
264
  );
101
- assert.doesNotMatch(html, /aria-label="Mark as read"/);
265
+ assert.doesNotMatch(html, /aria-label="More actions"/);
102
266
  });
103
267
 
104
- it("hides delete while counting", () => {
268
+ it("renders the verbs at the 44px touch size", () => {
269
+ const html = renderToString(
270
+ createElement(SelectionTopBar, {
271
+ ...handlers,
272
+ count: 2,
273
+ onOrganize: () => undefined,
274
+ }),
275
+ );
276
+ const buttonCount = (html.match(/h-11 w-11/g) ?? []).length;
277
+ assert.equal(buttonCount, 3, "back, delete and organize are all 44px");
278
+ });
279
+
280
+ it("hides the verbs while counting", () => {
105
281
  const html = renderToString(
106
282
  createElement(SelectionTopBar, {
107
283
  ...handlers,
108
284
  count: 0,
109
285
  isCounting: true,
286
+ onOrganize: () => undefined,
110
287
  }),
111
288
  );
112
289
  assert.doesNotMatch(html, /aria-label="Move selected messages to Trash"/);
290
+ assert.doesNotMatch(html, /aria-label="Organize selected messages"/);
113
291
  });
114
292
 
115
293
  it("renders statusLabel in place of the count copy when provided", () => {
@@ -124,13 +302,6 @@ describe("SelectionTopBar", () => {
124
302
  assert.doesNotMatch(text(html), /3,412 messages selected/);
125
303
  });
126
304
 
127
- it("falls back to the count copy when statusLabel is absent", () => {
128
- const html = renderToString(
129
- createElement(SelectionTopBar, { ...handlers, count: 2 }),
130
- );
131
- assert.match(text(html), /2 messages selected/);
132
- });
133
-
134
305
  it("marks the count/status line as a polite live region", () => {
135
306
  const html = renderToString(
136
307
  createElement(SelectionTopBar, { ...handlers, count: 2 }),
@@ -145,10 +316,10 @@ describe("SelectionTopBar", () => {
145
316
  const html = renderToString(
146
317
  createElement(SelectionTopBar, { ...handlers, count: 2 }),
147
318
  );
148
- assert.doesNotMatch(html, /aria-label="Select all"/);
319
+ assert.doesNotMatch(text(html), /Select all/);
149
320
  });
150
321
 
151
- it("renders the select-all control, unchecked, in the some-selected state", () => {
322
+ it("renders select-all unchecked in the some-selected state", () => {
152
323
  const html = renderToString(
153
324
  createElement(SelectionTopBar, {
154
325
  ...handlers,
@@ -160,15 +331,11 @@ describe("SelectionTopBar", () => {
160
331
  },
161
332
  }),
162
333
  );
163
- assert.match(html, /aria-label="Select all"/);
164
- assert.doesNotMatch(
165
- html,
166
- /aria-label="Select all"[^>]*checked=""/,
167
- "some-selected is not the checked state",
168
- );
334
+ assert.match(text(html), /Select all/);
335
+ assert.doesNotMatch(html, /type="checkbox"[^>]*checked=""/);
169
336
  });
170
337
 
171
- it("renders the select-all control checked in the all-selected state", () => {
338
+ it("names the control by what pressing it does once everything is ticked", () => {
172
339
  const html = renderToString(
173
340
  createElement(SelectionTopBar, {
174
341
  ...handlers,
@@ -180,11 +347,8 @@ describe("SelectionTopBar", () => {
180
347
  },
181
348
  }),
182
349
  );
183
- assert.match(
184
- html,
185
- /aria-label="Select all"[^>]*checked=""/,
186
- "all-selected renders the checkbox checked",
187
- );
350
+ assert.match(html, /type="checkbox"[^>]*checked=""/);
351
+ assert.match(text(html), /Deselect all/);
188
352
  });
189
353
 
190
354
  it("names the loaded scope by default once select-all is checked", () => {
@@ -200,7 +364,6 @@ describe("SelectionTopBar", () => {
200
364
  }),
201
365
  );
202
366
  assert.match(text(html), /All 47 loaded selected/);
203
- assert.doesNotMatch(text(html), /^47 messages selected/);
204
367
  });
205
368
 
206
369
  it("lets statusLabel override the scoped default for an escalated selection", () => {
@@ -215,20 +378,6 @@ describe("SelectionTopBar", () => {
215
378
  assert.match(text(html), /All 3,412 matching "npm" selected/);
216
379
  });
217
380
 
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
381
  it("renders the notice text and tone", () => {
233
382
  const html = renderToString(
234
383
  createElement(SelectionTopBar, {
@@ -245,7 +394,6 @@ describe("SelectionTopBar", () => {
245
394
  });
246
395
 
247
396
  it("renders the notice's action as a real button, not prose", () => {
248
- const onRetry = () => undefined;
249
397
  const html = renderToString(
250
398
  createElement(SelectionTopBar, {
251
399
  ...handlers,
@@ -253,7 +401,7 @@ describe("SelectionTopBar", () => {
253
401
  notice: {
254
402
  tone: "danger",
255
403
  text: "3,072 moved to Trash. 340 couldn't be deleted.",
256
- action: { label: "Retry 340", onClick: onRetry },
404
+ action: { label: "Retry 340", onClick: () => undefined },
257
405
  },
258
406
  }),
259
407
  );
@@ -290,17 +438,4 @@ describe("SelectionTopBar", () => {
290
438
  );
291
439
  assert.doesNotMatch(html, /role="progressbar"/);
292
440
  });
293
-
294
- it("omitting every new prop renders exactly the pre-existing bar shape", () => {
295
- const html = renderToString(
296
- createElement(SelectionTopBar, { ...handlers, count: 2 }),
297
- );
298
- assert.doesNotMatch(
299
- html,
300
- /aria-label="Select all"/,
301
- "no select-all control",
302
- );
303
- assert.match(text(html), /2 messages selected/, "default count copy");
304
- assert.doesNotMatch(html, /role="progressbar"/, "no progress bar");
305
- });
306
441
  });
@@ -1,5 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { FolderInput } from "lucide-react";
2
+ import { FolderInput, Menu, Search, Tag } from "lucide-react";
3
+ import { Button } from "./button.js";
4
+ import { PopoverMenu } from "./popover-menu.js";
5
+ import { SearchBar } from "./search-bar.js";
3
6
  import { SelectionTopBar } from "./selection-top-bar.js";
4
7
 
5
8
  const meta: Meta<typeof SelectionTopBar> = {
@@ -7,8 +10,12 @@ const meta: Meta<typeof SelectionTopBar> = {
7
10
  component: SelectionTopBar,
8
11
  parameters: { layout: "padded" },
9
12
  args: {
13
+ title: "Inbox",
10
14
  onCancel: () => undefined,
11
15
  onMarkRead: () => undefined,
16
+ onJunk: () => undefined,
17
+ onOrganize: () => undefined,
18
+ onSomethingElse: () => undefined,
12
19
  onDelete: () => undefined,
13
20
  },
14
21
  // Full viewport width — a fixed w-[390px] wrapper inside a 390px Storybook
@@ -31,52 +38,173 @@ const MoveSlot = () => (
31
38
  aria-label="Move selected messages"
32
39
  className="inline-flex size-11 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
33
40
  >
34
- <FolderInput className="size-4" />
41
+ <FolderInput className="size-5" />
35
42
  </button>
36
43
  );
37
44
 
38
- export const One: Story = { args: { count: 1 } };
45
+ /** Stand-in for the caller's apply-label picker, whose list belongs to the
46
+ * account. A worded row, so it reads as one of the menu's actions. */
47
+ const LabelSlot = () => (
48
+ <PopoverMenu
49
+ triggerLabel="Apply label to selected messages"
50
+ triggerIcon={<Tag className="size-4 text-fg-subtle" />}
51
+ triggerText="Apply label"
52
+ align="start"
53
+ nested
54
+ touch={false}
55
+ triggerClassName="min-h-11 w-full justify-start gap-3 px-4 py-2.5 text-sm font-normal text-fg"
56
+ items={[
57
+ { key: "work", label: "Work", onSelect: () => undefined },
58
+ { key: "receipts", label: "Receipts", onSelect: () => undefined },
59
+ ]}
60
+ />
61
+ );
39
62
 
40
- export const Many: Story = { args: { count: 3 } };
63
+ const selectAll = {
64
+ checked: false,
65
+ indeterminate: true,
66
+ onChange: () => undefined,
67
+ };
41
68
 
42
- export const WithoutMarkRead: Story = {
43
- args: { count: 2, onMarkRead: undefined },
69
+ /** The list header's own chrome, which the bar carries while nothing is
70
+ * ticked. The live app builds these in `MailListHeader`. */
71
+ const chrome = {
72
+ navSlot: (
73
+ <Button
74
+ variant="ghost"
75
+ size="touch"
76
+ icon={<Menu className="size-5" />}
77
+ aria-label="Menu"
78
+ className="-ml-2 shrink-0"
79
+ />
80
+ ),
81
+ titleMeta: (
82
+ <span className="shrink-0 text-2xs text-fg-subtle">15,338 unread</span>
83
+ ),
84
+ searchSlot: (
85
+ <Button
86
+ variant="ghost"
87
+ size="touch"
88
+ icon={<Search className="size-5" />}
89
+ aria-label="Search"
90
+ className="shrink-0"
91
+ />
92
+ ),
44
93
  };
45
94
 
46
- export const Busy: Story = { args: { count: 2, isBusy: true } };
95
+ /**
96
+ * Nothing ticked: the surface is the list header and names the mailbox. From
97
+ * 768px up the select-all control is already there — the same bar, one state
98
+ * earlier, rather than a second surface that appears on the first tick.
99
+ */
100
+ export const Idle: Story = {
101
+ args: {
102
+ ...chrome,
103
+ count: 0,
104
+ selectAll: { checked: false, onChange: () => undefined },
105
+ },
106
+ };
47
107
 
48
- export const CrossAccountHint: Story = {
108
+ /** The same idle bar with search expanded over the title, which is what the
109
+ * tablet header does once a query is in play, and the make-filter row the
110
+ * search offers under it. */
111
+ export const IdleSearching: Story = {
49
112
  args: {
50
- count: 4,
51
- notice: {
52
- tone: "warning",
53
- text: "Move only works within one account — clear selection or pick messages from a single account",
54
- },
113
+ ...chrome,
114
+ count: 0,
115
+ selectAll: { checked: false, onChange: () => undefined },
116
+ idleSlot: (
117
+ <button
118
+ type="button"
119
+ className="flex min-h-11 w-full items-center px-row-inset text-left text-sm text-accent"
120
+ >
121
+ Make this a filter
122
+ </button>
123
+ ),
124
+ searchField: (
125
+ <>
126
+ <div className="min-w-0 flex-1">
127
+ <SearchBar
128
+ value="npm"
129
+ onChange={() => undefined}
130
+ onClear={() => undefined}
131
+ globalFocusKey={false}
132
+ showClearButton={false}
133
+ />
134
+ </div>
135
+ <Button
136
+ variant="ghost"
137
+ size="touch"
138
+ icon={<Search className="size-5" />}
139
+ aria-label="Close search"
140
+ className="shrink-0"
141
+ />
142
+ </>
143
+ ),
55
144
  },
56
145
  };
57
146
 
58
- /** Some but not all loaded rows checked: the select-all control renders the
59
- * `Checkbox` tri-state dash, not the box or the tick. */
60
- export const SelectAll: Story = {
147
+ /**
148
+ * One ticked row. The count takes the title's place and the header's own
149
+ * chrome stands down; the verbs arrive with it: Delete, Move and Organize
150
+ * carry a glyph, Junk, Mark read and Something else live under the kebab.
151
+ * Below 768px select-all takes a second row, so row one stays a count and a
152
+ * row of verbs with a back arrow out of selection.
153
+ */
154
+ export const One: Story = {
155
+ args: { ...chrome, count: 1, selectAll, moveSlot: <MoveSlot /> },
156
+ };
157
+
158
+ export const Many: Story = {
159
+ args: { count: 3, selectAll, moveSlot: <MoveSlot /> },
160
+ };
161
+
162
+ /**
163
+ * The overflow menu with the account's label picker at its foot. Labels carry
164
+ * no verb on the bar (#477), so this is where applying one lives.
165
+ */
166
+ export const WithLabelPicker: Story = {
61
167
  args: {
62
168
  count: 3,
63
- selectAll: {
64
- checked: false,
65
- indeterminate: true,
66
- onChange: () => undefined,
169
+ selectAll,
170
+ moveSlot: <MoveSlot />,
171
+ overflowSlot: <LabelSlot />,
172
+ },
173
+ };
174
+
175
+ /** A selection spanning folders or accounts has no move target and no
176
+ * account to file a rule under: the bar carries Delete and the overflow. */
177
+ export const WithoutMoveOrOrganize: Story = {
178
+ args: { count: 2, onOrganize: undefined, selectAll },
179
+ };
180
+
181
+ /** A mutation in flight: Delete carries the spinner and every other verb
182
+ * stands down, because nothing else can act until it lands. */
183
+ export const Busy: Story = {
184
+ args: { count: 2, isBusy: true, moveSlot: <MoveSlot />, selectAll },
185
+ };
186
+
187
+ export const CrossAccountHint: Story = {
188
+ args: {
189
+ count: 4,
190
+ selectAll,
191
+ notice: {
192
+ tone: "warning",
193
+ text: "Move only works within one account — clear selection or pick messages from a single account",
67
194
  },
68
195
  },
69
196
  };
70
197
 
71
198
  /**
72
- * Every loaded row checked. The count line names its scope by default
73
- * "All 47 loaded selected" instead of a bare "47 messages selected" next
74
- * to a fully ticked box, which reads as "everything" to anyone who has used
75
- * a select-all checkbox before.
199
+ * Every loaded row checked. The control now says what pressing it does, and
200
+ * the count line names its scope by default "All 47 loaded selected"
201
+ * instead of a bare "47 messages selected" next to a fully ticked box, which
202
+ * reads as "everything" to anyone who has used a select-all checkbox before.
76
203
  */
77
204
  export const AllSelected: Story = {
78
205
  args: {
79
206
  count: 47,
207
+ moveSlot: <MoveSlot />,
80
208
  selectAll: {
81
209
  checked: true,
82
210
  indeterminate: false,
@@ -143,9 +271,8 @@ export const Escalated: Story = {
143
271
 
144
272
  /**
145
273
  * While a search result set is still paging, the exact count isn't known
146
- * yet — a running total instead of a static "Counting…", delete hidden
147
- * (nothing to act on with an unknown total), and an explicit Stop rather
148
- * than overloading the X (which still means "cancel selection").
274
+ * yet — a running total instead of a static "Counting…", the verbs hidden
275
+ * (nothing to act on with an unknown total), and an explicit Stop.
149
276
  */
150
277
  export const Counting: Story = {
151
278
  args: {
@@ -182,7 +309,7 @@ export const CountingLargeResultSet: Story = {
182
309
  /**
183
310
  * A bulk delete in progress reports a running total via `statusLabel` and a
184
311
  * determinate `ProgressBar`; the delete button shows its busy spinner (never
185
- * disables) and mark-read is hidden — nothing here can act mid-delete.
312
+ * disables) and the overflow verbs drop out — nothing here can act mid-delete.
186
313
  */
187
314
  export const DeletingWithProgress: Story = {
188
315
  args: {
@@ -209,23 +336,10 @@ export const PartialFailure: Story = {
209
336
  },
210
337
  };
211
338
 
212
- /**
213
- * count === 0 is unreachable in production: both the kit
214
- * (`message-list-pane.tsx`'s `toggleCheck`) and the web-client
215
- * (`MessageList.tsx`'s multi-select effect) auto-exit selection mode the
216
- * instant the last checked row is unchecked. `SelectionTopBar` itself has no
217
- * floor on `count` — this story pins the contract that no caller should ever
218
- * leave it mounted here.
219
- */
220
- export const ZeroSelected: Story = {
221
- args: { count: 0 },
222
- };
223
-
224
339
  /**
225
340
  * A move over an escalated selection: same chunked run as a delete, worded for
226
341
  * the action that is running and toned as ordinary progress rather than
227
- * destructive. Mark-read and the move slot are hidden while it runs — nothing
228
- * here can act mid-run.
342
+ * destructive.
229
343
  */
230
344
  export const MovingWithProgress: Story = {
231
345
  args: {