@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.
- package/package.json +1 -1
- package/src/components/filter-rule.ts +43 -20
- 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 +208 -73
- package/src/components/selection-top-bar.stories.tsx +156 -42
- package/src/components/selection-top-bar.tsx +237 -72
- package/src/components/selection-wizard.render.test.ts +725 -0
- package/src/components/selection-wizard.tsx +1078 -0
- package/src/index.ts +57 -0
- package/src/lib/rule-name.test.ts +28 -0
- package/src/lib/rule-name.ts +27 -0
- package/src/lib/sender-derivation.test.ts +53 -0
- package/src/lib/sender-derivation.ts +35 -0
- package/src/lib/wizard-steps.test.ts +673 -0
- package/src/lib/wizard-steps.ts +478 -0
|
@@ -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("
|
|
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
|
-
|
|
146
|
+
describe("at 375px", () => {
|
|
45
147
|
const html = renderToString(
|
|
46
|
-
createElement(SelectionTopBar, {
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
assert.
|
|
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("
|
|
225
|
+
it("keeps the overflow menu for an overflowSlot with no verbs of its own", () => {
|
|
65
226
|
const html = renderToString(
|
|
66
|
-
createElement(SelectionTopBar, {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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("
|
|
236
|
+
it("withdraws the overflowSlot while busy", () => {
|
|
75
237
|
const html = renderToString(
|
|
76
238
|
createElement(SelectionTopBar, {
|
|
77
239
|
...handlers,
|
|
78
240
|
count: 2,
|
|
79
|
-
|
|
241
|
+
isBusy: true,
|
|
242
|
+
overflowSlot: createElement("span", null, "Apply label"),
|
|
80
243
|
}),
|
|
81
244
|
);
|
|
82
|
-
assert.
|
|
245
|
+
assert.doesNotMatch(html, /aria-label="More actions"/);
|
|
83
246
|
});
|
|
84
247
|
|
|
85
|
-
it("
|
|
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="
|
|
252
|
+
assert.doesNotMatch(html, /aria-label="More actions"/);
|
|
90
253
|
});
|
|
91
254
|
|
|
92
|
-
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="
|
|
265
|
+
assert.doesNotMatch(html, /aria-label="More actions"/);
|
|
102
266
|
});
|
|
103
267
|
|
|
104
|
-
it("
|
|
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, /
|
|
319
|
+
assert.doesNotMatch(text(html), /Select all/);
|
|
149
320
|
});
|
|
150
321
|
|
|
151
|
-
it("renders
|
|
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, /
|
|
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("
|
|
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
|
-
|
|
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:
|
|
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-
|
|
41
|
+
<FolderInput className="size-5" />
|
|
35
42
|
</button>
|
|
36
43
|
);
|
|
37
44
|
|
|
38
|
-
|
|
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
|
-
|
|
63
|
+
const selectAll = {
|
|
64
|
+
checked: false,
|
|
65
|
+
indeterminate: true,
|
|
66
|
+
onChange: () => undefined,
|
|
67
|
+
};
|
|
41
68
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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…",
|
|
147
|
-
* (nothing to act on with an unknown total), and an explicit Stop
|
|
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
|
|
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.
|
|
228
|
-
* here can act mid-run.
|
|
342
|
+
* destructive.
|
|
229
343
|
*/
|
|
230
344
|
export const MovingWithProgress: Story = {
|
|
231
345
|
args: {
|