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