@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
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ArrowLeft,
|
|
3
|
+
Loader2,
|
|
4
|
+
MailOpen,
|
|
5
|
+
ShieldAlert,
|
|
6
|
+
Sparkles,
|
|
7
|
+
Trash2,
|
|
8
|
+
Wand2,
|
|
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
|
-
/**
|
|
43
|
+
/** Opens Organize for the selection. Omitted where organize cannot be
|
|
44
|
+
* scoped to one account. */
|
|
45
|
+
onOrganize?: () => void;
|
|
46
|
+
/** Moves the selection to Junk. Omitted in Junk itself, or with no Junk
|
|
47
|
+
* folder appointed. */
|
|
48
|
+
onJunk?: () => void;
|
|
49
|
+
/** Optional — dropped from the overflow menu while `isBusy`. */
|
|
26
50
|
onMarkRead?: () => void;
|
|
27
51
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
52
|
+
* Opens the free-text organize panel. An overflow verb rather than a glyph:
|
|
53
|
+
* it collapses a verb, a destination and a scope into one tap, which is the
|
|
54
|
+
* opposite of what the bar's own verbs do.
|
|
55
|
+
*/
|
|
56
|
+
onSomethingElse?: () => void;
|
|
57
|
+
/**
|
|
58
|
+
* The list header's own chrome, rendered only while nothing is ticked: the
|
|
59
|
+
* nav button, the unread count beside the title, and the search affordance.
|
|
60
|
+
* The bar is the list header, so these live here rather than on a second
|
|
61
|
+
* row above it.
|
|
62
|
+
*/
|
|
63
|
+
navSlot?: ReactNode;
|
|
64
|
+
titleMeta?: ReactNode;
|
|
65
|
+
searchSlot?: ReactNode;
|
|
66
|
+
/**
|
|
67
|
+
* The expanded search field, which owns the title's place for as long as it
|
|
68
|
+
* is up. Idle only — a ticked row hands the row back to the count.
|
|
69
|
+
*/
|
|
70
|
+
searchField?: ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* A row below the action row, up only while nothing is ticked — the
|
|
73
|
+
* search's make-this-a-filter affordance. A selection's own verbs take the
|
|
74
|
+
* bar over, so the second way into the same wizard stands down.
|
|
75
|
+
*/
|
|
76
|
+
idleSlot?: ReactNode;
|
|
77
|
+
/**
|
|
78
|
+
* The Move verb. A render prop because the trigger owns the folder picker
|
|
79
|
+
* and its API dependencies; the bar only gives it a place in the verb row.
|
|
30
80
|
*/
|
|
31
81
|
moveSlot?: ReactNode;
|
|
32
82
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
+
onOrganize,
|
|
175
|
+
onJunk,
|
|
176
|
+
onMarkRead,
|
|
177
|
+
onSomethingElse,
|
|
178
|
+
navSlot,
|
|
179
|
+
titleMeta,
|
|
180
|
+
searchSlot,
|
|
181
|
+
searchField,
|
|
182
|
+
idleSlot,
|
|
92
183
|
moveSlot,
|
|
184
|
+
overflowSlot,
|
|
93
185
|
isBusy = false,
|
|
94
186
|
isCounting = false,
|
|
95
187
|
selectAll,
|
|
@@ -97,68 +189,140 @@ 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
|
+
}
|
|
214
|
+
if (onSomethingElse && !isBusy && !isCounting) {
|
|
215
|
+
overflowItems.push({
|
|
216
|
+
key: "something-else",
|
|
217
|
+
label: "Something else",
|
|
218
|
+
icon: <Wand2 className="size-4" />,
|
|
219
|
+
onSelect: onSomethingElse,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
104
223
|
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 && (
|
|
224
|
+
<header
|
|
225
|
+
data-selection-bar=""
|
|
226
|
+
className="flex shrink-0 flex-col border-b border-line bg-surface-sunken"
|
|
227
|
+
>
|
|
228
|
+
<div
|
|
229
|
+
data-selection-bar-row="actions"
|
|
230
|
+
className="flex h-pane-header items-center gap-1 px-row-inset"
|
|
231
|
+
>
|
|
232
|
+
{selecting ? (
|
|
134
233
|
<Button
|
|
135
234
|
variant="ghost"
|
|
136
235
|
size="touch"
|
|
137
|
-
icon={<
|
|
138
|
-
onClick={
|
|
139
|
-
aria-label="
|
|
140
|
-
className="shrink-0"
|
|
236
|
+
icon={<ArrowLeft className="size-5" />}
|
|
237
|
+
onClick={onCancel}
|
|
238
|
+
aria-label="Cancel selection"
|
|
239
|
+
className="-ml-2 shrink-0"
|
|
141
240
|
/>
|
|
241
|
+
) : (
|
|
242
|
+
navSlot
|
|
142
243
|
)}
|
|
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"
|
|
244
|
+
{selectAll && (
|
|
245
|
+
<SelectAllToggle
|
|
246
|
+
selectAll={selectAll}
|
|
247
|
+
selecting={selecting}
|
|
248
|
+
className="hidden shrink-0 pr-2 md:flex"
|
|
159
249
|
/>
|
|
160
250
|
)}
|
|
251
|
+
{selecting ? (
|
|
252
|
+
<span
|
|
253
|
+
data-selection-count=""
|
|
254
|
+
className="min-w-0 flex-1 truncate px-1 text-sm font-medium text-fg"
|
|
255
|
+
role="status"
|
|
256
|
+
aria-live="polite"
|
|
257
|
+
>
|
|
258
|
+
{statusLabel ?? defaultLabel}
|
|
259
|
+
</span>
|
|
260
|
+
) : searchField ? (
|
|
261
|
+
<div className="flex min-w-0 flex-1 items-center gap-1">
|
|
262
|
+
{searchField}
|
|
263
|
+
</div>
|
|
264
|
+
) : (
|
|
265
|
+
<>
|
|
266
|
+
<h1 className="min-w-0 flex-1 truncate px-1 text-sm font-semibold text-fg">
|
|
267
|
+
{title}
|
|
268
|
+
</h1>
|
|
269
|
+
{titleMeta}
|
|
270
|
+
{searchSlot}
|
|
271
|
+
</>
|
|
272
|
+
)}
|
|
273
|
+
{selecting && !isCounting && (
|
|
274
|
+
<>
|
|
275
|
+
<Button
|
|
276
|
+
variant="ghost"
|
|
277
|
+
size="touch"
|
|
278
|
+
icon={
|
|
279
|
+
isBusy ? (
|
|
280
|
+
<Loader2 className="size-5 animate-spin" />
|
|
281
|
+
) : (
|
|
282
|
+
<Trash2 className="size-5 text-danger" />
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
onClick={isBusy ? undefined : onDelete}
|
|
286
|
+
aria-label="Move selected messages to Trash"
|
|
287
|
+
aria-busy={isBusy || undefined}
|
|
288
|
+
className="shrink-0"
|
|
289
|
+
/>
|
|
290
|
+
{!isBusy && moveSlot}
|
|
291
|
+
{!isBusy && onOrganize && (
|
|
292
|
+
<Button
|
|
293
|
+
variant="ghost"
|
|
294
|
+
size="touch"
|
|
295
|
+
icon={<Sparkles className="size-5" />}
|
|
296
|
+
onClick={onOrganize}
|
|
297
|
+
aria-label="Organize selected messages"
|
|
298
|
+
className="shrink-0"
|
|
299
|
+
/>
|
|
300
|
+
)}
|
|
301
|
+
</>
|
|
302
|
+
)}
|
|
303
|
+
{selecting && (
|
|
304
|
+
<PopoverMenu
|
|
305
|
+
triggerLabel="More actions"
|
|
306
|
+
items={overflowItems}
|
|
307
|
+
className="shrink-0"
|
|
308
|
+
>
|
|
309
|
+
{!isBusy && !isCounting && overflowSlot}
|
|
310
|
+
</PopoverMenu>
|
|
311
|
+
)}
|
|
161
312
|
</div>
|
|
313
|
+
{!selecting && idleSlot}
|
|
314
|
+
{selecting && selectAll && (
|
|
315
|
+
<div
|
|
316
|
+
data-selection-bar-row="select-all"
|
|
317
|
+
className="px-row-inset pb-2 md:hidden"
|
|
318
|
+
>
|
|
319
|
+
<SelectAllToggle
|
|
320
|
+
selectAll={selectAll}
|
|
321
|
+
selecting={selecting}
|
|
322
|
+
className="w-full"
|
|
323
|
+
/>
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
162
326
|
{progress && (
|
|
163
327
|
<div className="px-row-inset pb-2">
|
|
164
328
|
<ProgressBar
|
|
@@ -170,6 +334,7 @@ export function SelectionTopBar({
|
|
|
170
334
|
)}
|
|
171
335
|
{notice && (
|
|
172
336
|
<Banner
|
|
337
|
+
data-selection-notice=""
|
|
173
338
|
tone={notice.tone}
|
|
174
339
|
variant="soft"
|
|
175
340
|
role="status"
|