@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
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ArrowLeft,
|
|
3
|
+
FolderInput,
|
|
4
|
+
Loader2,
|
|
5
|
+
MailOpen,
|
|
6
|
+
ShieldAlert,
|
|
7
|
+
Sparkles,
|
|
8
|
+
Trash2,
|
|
9
|
+
} from "lucide-react";
|
|
2
10
|
import type { ReactNode } from "react";
|
|
3
11
|
import { Banner, type BannerTone } from "./banner.js";
|
|
4
12
|
import { Button } from "./button.js";
|
|
5
13
|
import { Checkbox } from "./checkbox.js";
|
|
14
|
+
import { PopoverMenu, type PopoverMenuItem } from "./popover-menu.js";
|
|
6
15
|
import { ProgressBar } from "./progress-bar.js";
|
|
7
16
|
|
|
8
17
|
const formatCount = (n: number): string => n.toLocaleString();
|
|
@@ -18,40 +27,82 @@ export interface SelectionTopBarNotice {
|
|
|
18
27
|
action?: SelectionTopBarNoticeAction;
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
export interface SelectionTopBarSelectAll {
|
|
31
|
+
checked: boolean;
|
|
32
|
+
indeterminate?: boolean;
|
|
33
|
+
onChange: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export interface SelectionTopBarProps {
|
|
37
|
+
/** Names the surface while nothing is ticked — the mailbox, the brief. */
|
|
38
|
+
title: string;
|
|
22
39
|
count: number;
|
|
40
|
+
/** Leaves selection, from the back arrow at the head of the bar. */
|
|
23
41
|
onCancel: () => void;
|
|
24
42
|
onDelete: () => void;
|
|
25
|
-
/** Optional — hide the mark-read button when omitted, or while `isBusy`. */
|
|
26
|
-
onMarkRead?: () => void;
|
|
27
43
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
44
|
+
* Opens the wizard on Move. Omitted where a destination cannot be resolved —
|
|
45
|
+
* a selection spanning accounts, or a surface with no owning mailbox.
|
|
46
|
+
*/
|
|
47
|
+
onMove?: () => void;
|
|
48
|
+
/**
|
|
49
|
+
* The Move verb for a selection the wizard cannot take: an escalated
|
|
50
|
+
* predicate, which no bounded list of ids stands in for, so it keeps the
|
|
51
|
+
* caller's own folder picker. Rendered only in `onMove`'s place.
|
|
30
52
|
*/
|
|
31
53
|
moveSlot?: ReactNode;
|
|
54
|
+
/** Opens Organize for the selection. Omitted where organize cannot be
|
|
55
|
+
* scoped to one account. */
|
|
56
|
+
onOrganize?: () => void;
|
|
57
|
+
/** Moves the selection to Junk. Omitted in Junk itself, or with no Junk
|
|
58
|
+
* folder appointed. */
|
|
59
|
+
onJunk?: () => void;
|
|
60
|
+
/** Optional — dropped from the overflow menu while `isBusy`. */
|
|
61
|
+
onMarkRead?: () => void;
|
|
32
62
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
63
|
+
* The list header's own chrome, rendered only while nothing is ticked: the
|
|
64
|
+
* nav button, the unread count beside the title, and the search affordance.
|
|
65
|
+
* The bar is the list header, so these live here rather than on a second
|
|
66
|
+
* row above it.
|
|
67
|
+
*/
|
|
68
|
+
navSlot?: ReactNode;
|
|
69
|
+
titleMeta?: ReactNode;
|
|
70
|
+
searchSlot?: ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* The expanded search field, which owns the title's place for as long as it
|
|
73
|
+
* is up. Idle only — a ticked row hands the row back to the count.
|
|
74
|
+
*/
|
|
75
|
+
searchField?: ReactNode;
|
|
76
|
+
/**
|
|
77
|
+
* A row below the action row, up only while nothing is ticked — the
|
|
78
|
+
* search's make-this-a-filter affordance. A selection's own verbs take the
|
|
79
|
+
* bar over, so the second way into the same wizard stands down.
|
|
80
|
+
*/
|
|
81
|
+
idleSlot?: ReactNode;
|
|
82
|
+
/**
|
|
83
|
+
* Extra rows at the foot of the overflow menu — the apply-label picker,
|
|
84
|
+
* whose list belongs to the account rather than to the bar. Withdrawn while
|
|
85
|
+
* `isBusy` or `isCounting`, like the other overflow verbs.
|
|
86
|
+
*/
|
|
87
|
+
overflowSlot?: ReactNode;
|
|
88
|
+
/**
|
|
89
|
+
* True while a delete or move mutation is in flight. Delete shows a spinner
|
|
90
|
+
* and every other verb drops out — nothing here disables, states that
|
|
91
|
+
* cannot act are hidden instead.
|
|
36
92
|
*/
|
|
37
93
|
isBusy?: boolean;
|
|
38
94
|
/**
|
|
39
95
|
* True while a search result set is still paging to find its total. Hides
|
|
40
|
-
*
|
|
96
|
+
* the verbs — the count they would act on isn't known yet.
|
|
41
97
|
*/
|
|
42
98
|
isCounting?: boolean;
|
|
43
99
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* itself stays visually small; a wrapping 44px hit area makes it tappable.
|
|
100
|
+
* The labelled select-all control. Inline in the bar from 768px up, whether
|
|
101
|
+
* or not anything is ticked; below that it takes a second row while
|
|
102
|
+
* selecting, so row one stays a count and a row of verbs. `indeterminate`
|
|
103
|
+
* renders the some-selected tri-state.
|
|
49
104
|
*/
|
|
50
|
-
selectAll?:
|
|
51
|
-
checked: boolean;
|
|
52
|
-
indeterminate?: boolean;
|
|
53
|
-
onChange: () => void;
|
|
54
|
-
};
|
|
105
|
+
selectAll?: SelectionTopBarSelectAll;
|
|
55
106
|
/**
|
|
56
107
|
* Overrides the default "{count} messages selected" text. Required once the
|
|
57
108
|
* count's scope is anything other than "every loaded row is selected" —
|
|
@@ -74,22 +125,63 @@ export interface SelectionTopBarProps {
|
|
|
74
125
|
* Toned status line below the action row, sometimes carrying an action
|
|
75
126
|
* button — a cross-account move restriction, a "Select all N matching…"
|
|
76
127
|
* 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.
|
|
79
128
|
*/
|
|
80
129
|
notice?: SelectionTopBarNotice;
|
|
81
130
|
}
|
|
82
131
|
|
|
132
|
+
/** Words, not a bare box: a tick with no legend never says what "all" covers. */
|
|
133
|
+
function SelectAllToggle({
|
|
134
|
+
selectAll,
|
|
135
|
+
selecting,
|
|
136
|
+
className,
|
|
137
|
+
}: {
|
|
138
|
+
selectAll: SelectionTopBarSelectAll;
|
|
139
|
+
selecting: boolean;
|
|
140
|
+
className: string;
|
|
141
|
+
}) {
|
|
142
|
+
return (
|
|
143
|
+
<Checkbox
|
|
144
|
+
className={className}
|
|
145
|
+
checked={selectAll.checked}
|
|
146
|
+
indeterminate={selectAll.indeterminate}
|
|
147
|
+
onChange={selectAll.onChange}
|
|
148
|
+
label={
|
|
149
|
+
<span className="font-medium text-accent">
|
|
150
|
+
{selectAll.checked && selecting ? "Deselect all" : "Select all"}
|
|
151
|
+
</span>
|
|
152
|
+
}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
83
157
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
158
|
+
* The list header and the selection action bar, as one surface. With nothing
|
|
159
|
+
* ticked it names the mailbox; from the first ticked row it carries the count
|
|
160
|
+
* and the verbs in place of that title, in the same place at the top of the
|
|
161
|
+
* pane.
|
|
162
|
+
*
|
|
163
|
+
* Delete, Move and Organize carry a glyph; Junk, Mark read and whatever
|
|
164
|
+
* `overflowSlot` adds live in the overflow menu. Select-all is a labelled control — permanent from 768px up,
|
|
165
|
+
* where row one has the room to carry it, and a second row below that, where
|
|
166
|
+
* row one is a count and the verbs. A back arrow leaves selection at every
|
|
167
|
+
* width, so a partial selection is always one press from being dropped.
|
|
86
168
|
*/
|
|
87
169
|
export function SelectionTopBar({
|
|
170
|
+
title,
|
|
88
171
|
count,
|
|
89
172
|
onCancel,
|
|
90
|
-
onMarkRead,
|
|
91
173
|
onDelete,
|
|
174
|
+
onMove,
|
|
92
175
|
moveSlot,
|
|
176
|
+
onOrganize,
|
|
177
|
+
onJunk,
|
|
178
|
+
onMarkRead,
|
|
179
|
+
navSlot,
|
|
180
|
+
titleMeta,
|
|
181
|
+
searchSlot,
|
|
182
|
+
searchField,
|
|
183
|
+
idleSlot,
|
|
184
|
+
overflowSlot,
|
|
93
185
|
isBusy = false,
|
|
94
186
|
isCounting = false,
|
|
95
187
|
selectAll,
|
|
@@ -97,68 +189,143 @@ export function SelectionTopBar({
|
|
|
97
189
|
progress,
|
|
98
190
|
notice,
|
|
99
191
|
}: SelectionTopBarProps) {
|
|
192
|
+
const selecting = count > 0 || isCounting;
|
|
100
193
|
const defaultLabel = selectAll?.checked
|
|
101
194
|
? `All ${formatCount(count)} loaded selected`
|
|
102
195
|
: `${formatCount(count)} ${count === 1 ? "message" : "messages"} selected`;
|
|
103
196
|
|
|
197
|
+
const overflowItems: PopoverMenuItem[] = [];
|
|
198
|
+
if (onJunk && !isBusy && !isCounting) {
|
|
199
|
+
overflowItems.push({
|
|
200
|
+
key: "junk",
|
|
201
|
+
label: "Junk",
|
|
202
|
+
icon: <ShieldAlert className="size-4" />,
|
|
203
|
+
onSelect: onJunk,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (onMarkRead && !isBusy && !isCounting) {
|
|
207
|
+
overflowItems.push({
|
|
208
|
+
key: "mark-read",
|
|
209
|
+
label: "Mark read",
|
|
210
|
+
icon: <MailOpen className="size-4" />,
|
|
211
|
+
onSelect: onMarkRead,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
104
214
|
return (
|
|
105
|
-
<header
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
/>
|
|
115
|
-
{selectAll && (
|
|
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>
|
|
125
|
-
)}
|
|
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}
|
|
132
|
-
</span>
|
|
133
|
-
{onMarkRead && !isBusy && (
|
|
215
|
+
<header
|
|
216
|
+
data-selection-bar=""
|
|
217
|
+
className="flex shrink-0 flex-col border-b border-line bg-surface-sunken"
|
|
218
|
+
>
|
|
219
|
+
<div
|
|
220
|
+
data-selection-bar-row="actions"
|
|
221
|
+
className="flex h-pane-header items-center gap-1 px-row-inset"
|
|
222
|
+
>
|
|
223
|
+
{selecting ? (
|
|
134
224
|
<Button
|
|
135
225
|
variant="ghost"
|
|
136
226
|
size="touch"
|
|
137
|
-
icon={<
|
|
138
|
-
onClick={
|
|
139
|
-
aria-label="
|
|
140
|
-
className="shrink-0"
|
|
227
|
+
icon={<ArrowLeft className="size-5" />}
|
|
228
|
+
onClick={onCancel}
|
|
229
|
+
aria-label="Cancel selection"
|
|
230
|
+
className="-ml-2 shrink-0"
|
|
141
231
|
/>
|
|
232
|
+
) : (
|
|
233
|
+
navSlot
|
|
142
234
|
)}
|
|
143
|
-
{
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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"
|
|
235
|
+
{selectAll && (
|
|
236
|
+
<SelectAllToggle
|
|
237
|
+
selectAll={selectAll}
|
|
238
|
+
selecting={selecting}
|
|
239
|
+
className="hidden shrink-0 pr-2 md:flex"
|
|
159
240
|
/>
|
|
160
241
|
)}
|
|
242
|
+
{selecting ? (
|
|
243
|
+
<span
|
|
244
|
+
data-selection-count=""
|
|
245
|
+
className="min-w-0 flex-1 truncate px-1 text-sm font-medium text-fg"
|
|
246
|
+
role="status"
|
|
247
|
+
aria-live="polite"
|
|
248
|
+
>
|
|
249
|
+
{statusLabel ?? defaultLabel}
|
|
250
|
+
</span>
|
|
251
|
+
) : searchField ? (
|
|
252
|
+
<div className="flex min-w-0 flex-1 items-center gap-1">
|
|
253
|
+
{searchField}
|
|
254
|
+
</div>
|
|
255
|
+
) : (
|
|
256
|
+
<>
|
|
257
|
+
<h1 className="min-w-0 flex-1 truncate px-1 text-sm font-semibold text-fg">
|
|
258
|
+
{title}
|
|
259
|
+
</h1>
|
|
260
|
+
{titleMeta}
|
|
261
|
+
{searchSlot}
|
|
262
|
+
</>
|
|
263
|
+
)}
|
|
264
|
+
{selecting && !isCounting && (
|
|
265
|
+
<>
|
|
266
|
+
<Button
|
|
267
|
+
variant="ghost"
|
|
268
|
+
size="touch"
|
|
269
|
+
icon={
|
|
270
|
+
isBusy ? (
|
|
271
|
+
<Loader2 className="size-5 animate-spin" />
|
|
272
|
+
) : (
|
|
273
|
+
<Trash2 className="size-5 text-danger" />
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
onClick={isBusy ? undefined : onDelete}
|
|
277
|
+
aria-label="Move selected messages to Trash"
|
|
278
|
+
aria-busy={isBusy || undefined}
|
|
279
|
+
className="shrink-0"
|
|
280
|
+
/>
|
|
281
|
+
{!isBusy &&
|
|
282
|
+
(onMove ? (
|
|
283
|
+
<Button
|
|
284
|
+
variant="ghost"
|
|
285
|
+
size="touch"
|
|
286
|
+
icon={<FolderInput className="size-5" />}
|
|
287
|
+
onClick={onMove}
|
|
288
|
+
aria-label="Move selected messages"
|
|
289
|
+
className="shrink-0"
|
|
290
|
+
/>
|
|
291
|
+
) : (
|
|
292
|
+
moveSlot
|
|
293
|
+
))}
|
|
294
|
+
{!isBusy && onOrganize && (
|
|
295
|
+
<Button
|
|
296
|
+
variant="ghost"
|
|
297
|
+
size="touch"
|
|
298
|
+
icon={<Sparkles className="size-5" />}
|
|
299
|
+
onClick={onOrganize}
|
|
300
|
+
aria-label="Organize selected messages"
|
|
301
|
+
className="shrink-0"
|
|
302
|
+
/>
|
|
303
|
+
)}
|
|
304
|
+
</>
|
|
305
|
+
)}
|
|
306
|
+
{selecting && (
|
|
307
|
+
<PopoverMenu
|
|
308
|
+
triggerLabel="More actions"
|
|
309
|
+
items={overflowItems}
|
|
310
|
+
className="shrink-0"
|
|
311
|
+
>
|
|
312
|
+
{!isBusy && !isCounting && overflowSlot}
|
|
313
|
+
</PopoverMenu>
|
|
314
|
+
)}
|
|
161
315
|
</div>
|
|
316
|
+
{!selecting && idleSlot}
|
|
317
|
+
{selecting && selectAll && (
|
|
318
|
+
<div
|
|
319
|
+
data-selection-bar-row="select-all"
|
|
320
|
+
className="px-row-inset pb-2 md:hidden"
|
|
321
|
+
>
|
|
322
|
+
<SelectAllToggle
|
|
323
|
+
selectAll={selectAll}
|
|
324
|
+
selecting={selecting}
|
|
325
|
+
className="w-full"
|
|
326
|
+
/>
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
162
329
|
{progress && (
|
|
163
330
|
<div className="px-row-inset pb-2">
|
|
164
331
|
<ProgressBar
|
|
@@ -170,6 +337,7 @@ export function SelectionTopBar({
|
|
|
170
337
|
)}
|
|
171
338
|
{notice && (
|
|
172
339
|
<Banner
|
|
340
|
+
data-selection-notice=""
|
|
173
341
|
tone={notice.tone}
|
|
174
342
|
variant="soft"
|
|
175
343
|
role="status"
|
|
@@ -581,7 +581,7 @@ describe("SelectionWizard", () => {
|
|
|
581
581
|
wizardProps({ verb: "organize", steps, step: "properties" }),
|
|
582
582
|
),
|
|
583
583
|
);
|
|
584
|
-
assert.match(text(html), /Step 1 of
|
|
584
|
+
assert.match(text(html), /Step 1 of 5 · Apply to/);
|
|
585
585
|
assert.match(html, /What should this apply to\?/);
|
|
586
586
|
assert.match(html, /These 2 messages/);
|
|
587
587
|
assert.doesNotMatch(html, /Match properties/);
|
|
@@ -657,23 +657,41 @@ describe("SelectionWizard", () => {
|
|
|
657
657
|
}),
|
|
658
658
|
),
|
|
659
659
|
);
|
|
660
|
-
assert.match(html, /Add a property to match on\./);
|
|
661
|
-
assert.match(html, /
|
|
660
|
+
assert.match(text(html), /Add a property to match on\./);
|
|
661
|
+
assert.match(html, /opacity-55/);
|
|
662
|
+
// Nothing disables (#477 1.7), and `aria-disabled` disables it for
|
|
663
|
+
// everyone driving the page by anything other than a mouse.
|
|
664
|
+
assert.doesNotMatch(html, /aria-disabled/);
|
|
662
665
|
assert.doesNotMatch(html, /<button[^>]*disabled=""/);
|
|
663
666
|
});
|
|
664
667
|
|
|
665
|
-
it("
|
|
666
|
-
const
|
|
668
|
+
it("carries the blocked reason on the control before it is pressed, and shows it after", () => {
|
|
669
|
+
const quiet = renderToString(
|
|
670
|
+
createElement(
|
|
671
|
+
SelectionWizard,
|
|
672
|
+
wizardProps({
|
|
673
|
+
step: "properties",
|
|
674
|
+
blockedReason: "Add a property to match on.",
|
|
675
|
+
}),
|
|
676
|
+
),
|
|
677
|
+
);
|
|
678
|
+
// Described, so it is heard on the control; not on screen until pressed.
|
|
679
|
+
assert.match(quiet, /aria-describedby="/);
|
|
680
|
+
assert.match(quiet, /sr-only/);
|
|
681
|
+
assert.doesNotMatch(quiet, /role="status"/);
|
|
682
|
+
|
|
683
|
+
const nudged = renderToString(
|
|
667
684
|
createElement(
|
|
668
685
|
SelectionWizard,
|
|
669
686
|
wizardProps({
|
|
670
687
|
step: "properties",
|
|
671
688
|
blockedReason: "Add a property to match on.",
|
|
689
|
+
nudged: true,
|
|
672
690
|
}),
|
|
673
691
|
),
|
|
674
692
|
);
|
|
675
|
-
assert.
|
|
676
|
-
assert.
|
|
693
|
+
assert.match(nudged, /role="status"/);
|
|
694
|
+
assert.doesNotMatch(nudged, /sr-only/);
|
|
677
695
|
});
|
|
678
696
|
|
|
679
697
|
it("commits under the scope's own label, in the verb's own tone", () => {
|