@remit/ui 0.0.59 → 0.0.61

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