@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.
- package/package.json +1 -1
- package/src/components/filter-sheet.stories.tsx +14 -0
- package/src/components/filter-sheet.tsx +46 -35
- package/src/components/message-list-pane.stories.tsx +21 -10
- package/src/components/message-list-pane.tsx +7 -7
- package/src/components/popover-menu.render.test.ts +34 -0
- package/src/components/popover-menu.stories.tsx +59 -0
- package/src/components/popover-menu.tsx +38 -7
- package/src/components/selection-top-bar.render.test.ts +206 -73
- package/src/components/selection-top-bar.stories.tsx +152 -53
- package/src/components/selection-top-bar.tsx +241 -73
- package/src/components/selection-wizard.render.test.ts +25 -7
- package/src/components/selection-wizard.tsx +120 -50
- package/src/index.ts +3 -10
- package/src/lib/wizard-steps.test.ts +37 -8
- package/src/lib/wizard-steps.ts +32 -8
- package/src/components/selection-sheet.render.test.ts +0 -169
- package/src/components/selection-sheet.stories.tsx +0 -179
- package/src/components/selection-sheet.test.ts +0 -88
- package/src/components/selection-sheet.tsx +0 -496
|
@@ -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("
|
|
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
|
-
|
|
148
|
+
describe("at 375px", () => {
|
|
45
149
|
const html = renderToString(
|
|
46
|
-
createElement(SelectionTopBar, {
|
|
150
|
+
createElement(SelectionTopBar, {
|
|
151
|
+
...handlers,
|
|
152
|
+
count: 3,
|
|
153
|
+
selectAll,
|
|
154
|
+
onOrganize: () => undefined,
|
|
155
|
+
onMove: () => undefined,
|
|
156
|
+
}),
|
|
47
157
|
);
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
61
|
-
assert.
|
|
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("
|
|
223
|
+
it("keeps the overflow menu for an overflowSlot with no verbs of its own", () => {
|
|
65
224
|
const html = renderToString(
|
|
66
|
-
createElement(SelectionTopBar, {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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("
|
|
234
|
+
it("withdraws the overflowSlot while busy", () => {
|
|
75
235
|
const html = renderToString(
|
|
76
236
|
createElement(SelectionTopBar, {
|
|
77
237
|
...handlers,
|
|
78
238
|
count: 2,
|
|
79
|
-
|
|
239
|
+
isBusy: true,
|
|
240
|
+
overflowSlot: createElement("span", null, "Apply label"),
|
|
80
241
|
}),
|
|
81
242
|
);
|
|
82
|
-
assert.
|
|
243
|
+
assert.doesNotMatch(html, /aria-label="More actions"/);
|
|
83
244
|
});
|
|
84
245
|
|
|
85
|
-
it("
|
|
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="
|
|
250
|
+
assert.doesNotMatch(html, /aria-label="More actions"/);
|
|
90
251
|
});
|
|
91
252
|
|
|
92
|
-
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="
|
|
263
|
+
assert.doesNotMatch(html, /aria-label="More actions"/);
|
|
102
264
|
});
|
|
103
265
|
|
|
104
|
-
it("
|
|
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, /
|
|
317
|
+
assert.doesNotMatch(text(html), /Select all/);
|
|
149
318
|
});
|
|
150
319
|
|
|
151
|
-
it("renders
|
|
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, /
|
|
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("
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
27
|
-
*
|
|
28
|
-
const
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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…",
|
|
147
|
-
* (nothing to act on with an unknown total), and an explicit Stop
|
|
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
|
|
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.
|
|
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.",
|