@remit/ui 0.0.60 → 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/selection-top-bar.render.test.ts +6 -8
- package/src/components/selection-top-bar.stories.tsx +9 -24
- package/src/components/selection-top-bar.tsx +27 -24
- package/src/components/selection-wizard.render.test.ts +25 -7
- package/src/components/selection-wizard.tsx +120 -50
- package/src/index.ts +2 -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
package/package.json
CHANGED
|
@@ -109,16 +109,18 @@ describe("SelectionTopBar", () => {
|
|
|
109
109
|
);
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
it("
|
|
112
|
+
it("keeps Junk and Mark read in the overflow menu, never as glyphs", () => {
|
|
113
113
|
const html = renderToString(
|
|
114
114
|
createElement(SelectionTopBar, {
|
|
115
115
|
...handlers,
|
|
116
116
|
count: 2,
|
|
117
|
-
|
|
117
|
+
onJunk: () => undefined,
|
|
118
|
+
onMarkRead: () => undefined,
|
|
118
119
|
}),
|
|
119
120
|
);
|
|
120
121
|
assert.match(html, /aria-label="More actions"/);
|
|
121
|
-
assert.doesNotMatch(row(html, "actions"), /aria-label="
|
|
122
|
+
assert.doesNotMatch(row(html, "actions"), /aria-label="Junk"/);
|
|
123
|
+
assert.doesNotMatch(row(html, "actions"), /aria-label="Mark read"/);
|
|
122
124
|
});
|
|
123
125
|
|
|
124
126
|
it("replaces the title with the count from the first ticked row", () => {
|
|
@@ -150,11 +152,7 @@ describe("SelectionTopBar", () => {
|
|
|
150
152
|
count: 3,
|
|
151
153
|
selectAll,
|
|
152
154
|
onOrganize: () => undefined,
|
|
153
|
-
|
|
154
|
-
"button",
|
|
155
|
-
{ type: "button", "aria-label": "Move selected messages" },
|
|
156
|
-
"Move",
|
|
157
|
-
),
|
|
155
|
+
onMove: () => undefined,
|
|
158
156
|
}),
|
|
159
157
|
);
|
|
160
158
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import {
|
|
2
|
+
import { Menu, Search, Tag } from "lucide-react";
|
|
3
3
|
import { Button } from "./button.js";
|
|
4
4
|
import { PopoverMenu } from "./popover-menu.js";
|
|
5
5
|
import { SearchBar } from "./search-bar.js";
|
|
@@ -14,8 +14,8 @@ const meta: Meta<typeof SelectionTopBar> = {
|
|
|
14
14
|
onCancel: () => undefined,
|
|
15
15
|
onMarkRead: () => undefined,
|
|
16
16
|
onJunk: () => undefined,
|
|
17
|
+
onMove: () => undefined,
|
|
17
18
|
onOrganize: () => undefined,
|
|
18
|
-
onSomethingElse: () => undefined,
|
|
19
19
|
onDelete: () => undefined,
|
|
20
20
|
},
|
|
21
21
|
// Full viewport width — a fixed w-[390px] wrapper inside a 390px Storybook
|
|
@@ -30,18 +30,6 @@ export default meta;
|
|
|
30
30
|
|
|
31
31
|
type Story = StoryObj<typeof SelectionTopBar>;
|
|
32
32
|
|
|
33
|
-
/** Stand-in for the caller's move-to-folder trigger, which owns its own
|
|
34
|
-
* folder-picker data. The bar only reserves the slot. */
|
|
35
|
-
const MoveSlot = () => (
|
|
36
|
-
<button
|
|
37
|
-
type="button"
|
|
38
|
-
aria-label="Move selected messages"
|
|
39
|
-
className="inline-flex size-11 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
|
|
40
|
-
>
|
|
41
|
-
<FolderInput className="size-5" />
|
|
42
|
-
</button>
|
|
43
|
-
);
|
|
44
|
-
|
|
45
33
|
/** Stand-in for the caller's apply-label picker, whose list belongs to the
|
|
46
34
|
* account. A worded row, so it reads as one of the menu's actions. */
|
|
47
35
|
const LabelSlot = () => (
|
|
@@ -147,16 +135,17 @@ export const IdleSearching: Story = {
|
|
|
147
135
|
/**
|
|
148
136
|
* One ticked row. The count takes the title's place and the header's own
|
|
149
137
|
* chrome stands down; the verbs arrive with it: Delete, Move and Organize
|
|
150
|
-
* carry a glyph, Junk
|
|
151
|
-
* Below 768px select-all takes a second row, so row one
|
|
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
|
|
152
141
|
* row of verbs with a back arrow out of selection.
|
|
153
142
|
*/
|
|
154
143
|
export const One: Story = {
|
|
155
|
-
args: { ...chrome, count: 1, selectAll
|
|
144
|
+
args: { ...chrome, count: 1, selectAll },
|
|
156
145
|
};
|
|
157
146
|
|
|
158
147
|
export const Many: Story = {
|
|
159
|
-
args: { count: 3, selectAll
|
|
148
|
+
args: { count: 3, selectAll },
|
|
160
149
|
};
|
|
161
150
|
|
|
162
151
|
/**
|
|
@@ -167,7 +156,6 @@ export const WithLabelPicker: Story = {
|
|
|
167
156
|
args: {
|
|
168
157
|
count: 3,
|
|
169
158
|
selectAll,
|
|
170
|
-
moveSlot: <MoveSlot />,
|
|
171
159
|
overflowSlot: <LabelSlot />,
|
|
172
160
|
},
|
|
173
161
|
};
|
|
@@ -175,13 +163,13 @@ export const WithLabelPicker: Story = {
|
|
|
175
163
|
/** A selection spanning folders or accounts has no move target and no
|
|
176
164
|
* account to file a rule under: the bar carries Delete and the overflow. */
|
|
177
165
|
export const WithoutMoveOrOrganize: Story = {
|
|
178
|
-
args: { count: 2, onOrganize: undefined, selectAll },
|
|
166
|
+
args: { count: 2, onMove: undefined, onOrganize: undefined, selectAll },
|
|
179
167
|
};
|
|
180
168
|
|
|
181
169
|
/** A mutation in flight: Delete carries the spinner and every other verb
|
|
182
170
|
* stands down, because nothing else can act until it lands. */
|
|
183
171
|
export const Busy: Story = {
|
|
184
|
-
args: { count: 2, isBusy: true,
|
|
172
|
+
args: { count: 2, isBusy: true, selectAll },
|
|
185
173
|
};
|
|
186
174
|
|
|
187
175
|
export const CrossAccountHint: Story = {
|
|
@@ -204,7 +192,6 @@ export const CrossAccountHint: Story = {
|
|
|
204
192
|
export const AllSelected: Story = {
|
|
205
193
|
args: {
|
|
206
194
|
count: 47,
|
|
207
|
-
moveSlot: <MoveSlot />,
|
|
208
195
|
selectAll: {
|
|
209
196
|
checked: true,
|
|
210
197
|
indeterminate: false,
|
|
@@ -260,7 +247,6 @@ export const Escalated: Story = {
|
|
|
260
247
|
args: {
|
|
261
248
|
count: 3412,
|
|
262
249
|
statusLabel: 'All 3,412 matching "npm" selected',
|
|
263
|
-
moveSlot: <MoveSlot />,
|
|
264
250
|
notice: {
|
|
265
251
|
tone: "info",
|
|
266
252
|
text: "",
|
|
@@ -367,7 +353,6 @@ export const MarkingReadWithProgress: Story = {
|
|
|
367
353
|
export const PartialFailureMove: Story = {
|
|
368
354
|
args: {
|
|
369
355
|
count: 340,
|
|
370
|
-
moveSlot: <MoveSlot />,
|
|
371
356
|
notice: {
|
|
372
357
|
tone: "danger",
|
|
373
358
|
text: "3,072 moved. 340 couldn't be moved.",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArrowLeft,
|
|
3
|
+
FolderInput,
|
|
3
4
|
Loader2,
|
|
4
5
|
MailOpen,
|
|
5
6
|
ShieldAlert,
|
|
6
7
|
Sparkles,
|
|
7
8
|
Trash2,
|
|
8
|
-
Wand2,
|
|
9
9
|
} from "lucide-react";
|
|
10
10
|
import type { ReactNode } from "react";
|
|
11
11
|
import { Banner, type BannerTone } from "./banner.js";
|
|
@@ -40,6 +40,17 @@ export interface SelectionTopBarProps {
|
|
|
40
40
|
/** Leaves selection, from the back arrow at the head of the bar. */
|
|
41
41
|
onCancel: () => void;
|
|
42
42
|
onDelete: () => void;
|
|
43
|
+
/**
|
|
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.
|
|
52
|
+
*/
|
|
53
|
+
moveSlot?: ReactNode;
|
|
43
54
|
/** Opens Organize for the selection. Omitted where organize cannot be
|
|
44
55
|
* scoped to one account. */
|
|
45
56
|
onOrganize?: () => void;
|
|
@@ -48,12 +59,6 @@ export interface SelectionTopBarProps {
|
|
|
48
59
|
onJunk?: () => void;
|
|
49
60
|
/** Optional — dropped from the overflow menu while `isBusy`. */
|
|
50
61
|
onMarkRead?: () => void;
|
|
51
|
-
/**
|
|
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
62
|
/**
|
|
58
63
|
* The list header's own chrome, rendered only while nothing is ticked: the
|
|
59
64
|
* nav button, the unread count beside the title, and the search affordance.
|
|
@@ -74,11 +79,6 @@ export interface SelectionTopBarProps {
|
|
|
74
79
|
* bar over, so the second way into the same wizard stands down.
|
|
75
80
|
*/
|
|
76
81
|
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.
|
|
80
|
-
*/
|
|
81
|
-
moveSlot?: ReactNode;
|
|
82
82
|
/**
|
|
83
83
|
* Extra rows at the foot of the overflow menu — the apply-label picker,
|
|
84
84
|
* whose list belongs to the account rather than to the bar. Withdrawn while
|
|
@@ -171,16 +171,16 @@ export function SelectionTopBar({
|
|
|
171
171
|
count,
|
|
172
172
|
onCancel,
|
|
173
173
|
onDelete,
|
|
174
|
+
onMove,
|
|
175
|
+
moveSlot,
|
|
174
176
|
onOrganize,
|
|
175
177
|
onJunk,
|
|
176
178
|
onMarkRead,
|
|
177
|
-
onSomethingElse,
|
|
178
179
|
navSlot,
|
|
179
180
|
titleMeta,
|
|
180
181
|
searchSlot,
|
|
181
182
|
searchField,
|
|
182
183
|
idleSlot,
|
|
183
|
-
moveSlot,
|
|
184
184
|
overflowSlot,
|
|
185
185
|
isBusy = false,
|
|
186
186
|
isCounting = false,
|
|
@@ -211,15 +211,6 @@ export function SelectionTopBar({
|
|
|
211
211
|
onSelect: onMarkRead,
|
|
212
212
|
});
|
|
213
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
|
-
|
|
223
214
|
return (
|
|
224
215
|
<header
|
|
225
216
|
data-selection-bar=""
|
|
@@ -287,7 +278,19 @@ export function SelectionTopBar({
|
|
|
287
278
|
aria-busy={isBusy || undefined}
|
|
288
279
|
className="shrink-0"
|
|
289
280
|
/>
|
|
290
|
-
{!isBusy &&
|
|
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
|
+
))}
|
|
291
294
|
{!isBusy && onOrganize && (
|
|
292
295
|
<Button
|
|
293
296
|
variant="ghost"
|
|
@@ -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", () => {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
matchPhrase,
|
|
29
29
|
matchSummary,
|
|
30
30
|
type RunCopy,
|
|
31
|
+
type RunOutcome,
|
|
31
32
|
type RunState,
|
|
32
33
|
runCopy,
|
|
33
34
|
type SampleEmptyReason,
|
|
@@ -133,7 +134,16 @@ export function WizardScreen({
|
|
|
133
134
|
}: WizardScreenProps) {
|
|
134
135
|
const active = stepIndex(steps, step);
|
|
135
136
|
return (
|
|
136
|
-
|
|
137
|
+
// A modal at every width: full-bleed below 768px it covers the list rather
|
|
138
|
+
// than sitting beside it, so the list behind must leave the accessibility
|
|
139
|
+
// tree with it — otherwise the verbs that opened the wizard are still
|
|
140
|
+
// reachable underneath the screen that replaced them.
|
|
141
|
+
<div
|
|
142
|
+
role="dialog"
|
|
143
|
+
aria-modal="true"
|
|
144
|
+
aria-label={title}
|
|
145
|
+
className="fixed inset-0 z-50 flex flex-col font-sans text-fg md:items-center md:justify-center md:bg-black/40 md:p-6"
|
|
146
|
+
>
|
|
137
147
|
<div className="flex min-h-0 w-full flex-1 flex-col bg-canvas md:h-[45rem] md:max-h-[calc(100dvh-3rem)] md:w-[35rem] md:max-w-[calc(100vw-3rem)] md:flex-none md:overflow-hidden md:rounded-xl md:border md:border-line md:shadow-lg">
|
|
138
148
|
<header className="shrink-0 border-b border-line px-3 pb-2 pt-3">
|
|
139
149
|
<div className="flex items-center gap-1">
|
|
@@ -190,6 +200,12 @@ export interface ChoiceCardProps {
|
|
|
190
200
|
children?: ReactNode;
|
|
191
201
|
}
|
|
192
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Nothing disables (#477 1.7). An unavailable choice is dimmed and stays
|
|
205
|
+
* pressable, because pressing it is what takes the user somewhere that works —
|
|
206
|
+
* `aria-disabled` would announce it as a control that does nothing and take that
|
|
207
|
+
* away. What makes it unavailable is announced with it instead.
|
|
208
|
+
*/
|
|
193
209
|
export function ChoiceCard({
|
|
194
210
|
selected,
|
|
195
211
|
title,
|
|
@@ -198,13 +214,14 @@ export function ChoiceCard({
|
|
|
198
214
|
onSelect,
|
|
199
215
|
children,
|
|
200
216
|
}: ChoiceCardProps) {
|
|
217
|
+
const hintId = useId();
|
|
201
218
|
return (
|
|
202
219
|
<div className="space-y-1.5">
|
|
203
220
|
<button
|
|
204
221
|
type="button"
|
|
205
222
|
onClick={onSelect}
|
|
206
223
|
aria-pressed={selected}
|
|
207
|
-
aria-
|
|
224
|
+
aria-describedby={unavailable && children ? hintId : undefined}
|
|
208
225
|
className={cn(
|
|
209
226
|
"flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors",
|
|
210
227
|
selected
|
|
@@ -228,7 +245,7 @@ export function ChoiceCard({
|
|
|
228
245
|
</span>
|
|
229
246
|
</span>
|
|
230
247
|
</button>
|
|
231
|
-
{children}
|
|
248
|
+
{children && <div id={hintId}>{children}</div>}
|
|
232
249
|
</div>
|
|
233
250
|
);
|
|
234
251
|
}
|
|
@@ -244,6 +261,12 @@ export interface SelectionSampleProps {
|
|
|
244
261
|
label: string;
|
|
245
262
|
/** Why there are no rows. Defaults to nothing matching, never to a bare empty state. */
|
|
246
263
|
emptyReason?: SampleEmptyReason;
|
|
264
|
+
/**
|
|
265
|
+
* The rows are still being fetched. No rows yet is not the same answer as no
|
|
266
|
+
* rows at all, and "nothing matches" beside a server count that says otherwise
|
|
267
|
+
* is the wrong conclusion (#477 3.5).
|
|
268
|
+
*/
|
|
269
|
+
loading?: boolean;
|
|
247
270
|
}
|
|
248
271
|
|
|
249
272
|
const sampleFooter = (count: MatchCount, shown: number): string => {
|
|
@@ -266,6 +289,7 @@ export function SelectionSample({
|
|
|
266
289
|
count,
|
|
267
290
|
label,
|
|
268
291
|
emptyReason,
|
|
292
|
+
loading,
|
|
269
293
|
}: SelectionSampleProps) {
|
|
270
294
|
return (
|
|
271
295
|
<section className="flex flex-col rounded-lg border border-line bg-surface">
|
|
@@ -274,7 +298,9 @@ export function SelectionSample({
|
|
|
274
298
|
</h2>
|
|
275
299
|
{messages.length === 0 ? (
|
|
276
300
|
<p className="px-3 py-4 text-xs text-fg-muted">
|
|
277
|
-
{
|
|
301
|
+
{loading
|
|
302
|
+
? "Fetching the messages this covers…"
|
|
303
|
+
: sampleEmptyCopy(emptyReason ?? "noMatch")}
|
|
278
304
|
</p>
|
|
279
305
|
) : (
|
|
280
306
|
<>
|
|
@@ -316,6 +342,13 @@ export interface FooterNavProps {
|
|
|
316
342
|
nudged?: boolean;
|
|
317
343
|
}
|
|
318
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Nothing disables (#477 1.7). A Continue with an answer still missing is dimmed
|
|
347
|
+
* and stays pressable — and stays pressable to assistive technology too, which
|
|
348
|
+
* `aria-disabled` would have taken away along with the reason. The reason is on
|
|
349
|
+
* the control the whole time it applies, through `aria-describedby`; pressing it
|
|
350
|
+
* is what brings the reason on screen.
|
|
351
|
+
*/
|
|
319
352
|
export function FooterNav({
|
|
320
353
|
backLabel = "Back",
|
|
321
354
|
onBack,
|
|
@@ -325,10 +358,15 @@ export function FooterNav({
|
|
|
325
358
|
blockedReason,
|
|
326
359
|
nudged,
|
|
327
360
|
}: FooterNavProps) {
|
|
361
|
+
const reasonId = useId();
|
|
328
362
|
return (
|
|
329
363
|
<div className="space-y-2">
|
|
330
|
-
{
|
|
331
|
-
<p
|
|
364
|
+
{blockedReason && (
|
|
365
|
+
<p
|
|
366
|
+
id={reasonId}
|
|
367
|
+
role={nudged ? "status" : undefined}
|
|
368
|
+
className={cn("px-1 text-2xs text-warning", !nudged && "sr-only")}
|
|
369
|
+
>
|
|
332
370
|
{blockedReason}
|
|
333
371
|
</p>
|
|
334
372
|
)}
|
|
@@ -346,7 +384,7 @@ export function FooterNav({
|
|
|
346
384
|
variant={nextVariant}
|
|
347
385
|
size="touch"
|
|
348
386
|
onClick={onNext}
|
|
349
|
-
aria-
|
|
387
|
+
aria-describedby={blockedReason ? reasonId : undefined}
|
|
350
388
|
className={cn("flex-1", blockedReason && "opacity-55")}
|
|
351
389
|
>
|
|
352
390
|
{nextLabel}
|
|
@@ -366,6 +404,12 @@ export interface MatchStepProps {
|
|
|
366
404
|
* of the deployment: the door stays pressable and dimmed.
|
|
367
405
|
*/
|
|
368
406
|
semanticUnavailable?: boolean;
|
|
407
|
+
/**
|
|
408
|
+
* What the mail server said when the widen was asked to run and failed. A
|
|
409
|
+
* failure is a reason the door cannot run, so it dims the door like any other
|
|
410
|
+
* — with the server's own words under it rather than a generic line.
|
|
411
|
+
*/
|
|
412
|
+
semanticErrorDetail?: string;
|
|
369
413
|
/** The dimmed door was pressed and the senders were filled in instead. */
|
|
370
414
|
semanticFallbackTaken?: boolean;
|
|
371
415
|
onSemanticFallback: () => void;
|
|
@@ -377,6 +421,7 @@ export function MatchStepBody({
|
|
|
377
421
|
mode,
|
|
378
422
|
onModeChange,
|
|
379
423
|
semanticUnavailable,
|
|
424
|
+
semanticErrorDetail,
|
|
380
425
|
semanticFallbackTaken,
|
|
381
426
|
onSemanticFallback,
|
|
382
427
|
sample,
|
|
@@ -401,6 +446,11 @@ export function MatchStepBody({
|
|
|
401
446
|
title={matchDoorLabel("similar", selectedCount)}
|
|
402
447
|
description={matchDoorHint("similar")}
|
|
403
448
|
>
|
|
449
|
+
{semanticErrorDetail && (
|
|
450
|
+
<p role="status" className="px-1 text-2xs text-danger">
|
|
451
|
+
Couldn't find similar messages: {semanticErrorDetail}
|
|
452
|
+
</p>
|
|
453
|
+
)}
|
|
404
454
|
{semanticFallbackTaken && (
|
|
405
455
|
<p role="status" className="px-1 text-2xs text-fg-subtle">
|
|
406
456
|
Similar-mail matching is unavailable right now — matching on the
|
|
@@ -568,6 +618,12 @@ export interface FolderStepProps {
|
|
|
568
618
|
name: string,
|
|
569
619
|
signal?: AbortSignal,
|
|
570
620
|
) => Promise<MoveMailboxOption>;
|
|
621
|
+
/**
|
|
622
|
+
* Why there is no folder list to choose from — a selection spanning accounts
|
|
623
|
+
* has no single account whose folders these could be (#477 5.5). Said here
|
|
624
|
+
* rather than left as an empty picker.
|
|
625
|
+
*/
|
|
626
|
+
restriction?: string;
|
|
571
627
|
}
|
|
572
628
|
|
|
573
629
|
export function FolderStepBody({
|
|
@@ -575,14 +631,21 @@ export function FolderStepBody({
|
|
|
575
631
|
mailboxId,
|
|
576
632
|
onSelect,
|
|
577
633
|
onCreateFolder,
|
|
634
|
+
restriction,
|
|
578
635
|
}: FolderStepProps) {
|
|
579
636
|
const chosen = mailboxes.find((mailbox) => mailbox.id === mailboxId);
|
|
580
637
|
return (
|
|
581
638
|
<div className="flex min-h-0 flex-col gap-3">
|
|
582
|
-
<p
|
|
583
|
-
{
|
|
584
|
-
|
|
585
|
-
|
|
639
|
+
<p
|
|
640
|
+
className={cn(
|
|
641
|
+
"px-1 text-xs",
|
|
642
|
+
restriction ? "text-warning" : "text-fg-muted",
|
|
643
|
+
)}
|
|
644
|
+
>
|
|
645
|
+
{restriction ??
|
|
646
|
+
(chosen
|
|
647
|
+
? `Moving to ${chosen.label}.`
|
|
648
|
+
: "Search for a folder, or type a name that doesn't exist yet to make one.")}
|
|
586
649
|
</p>
|
|
587
650
|
<div className="overflow-hidden rounded-lg border border-line bg-surface">
|
|
588
651
|
<MoveMailboxPicker
|
|
@@ -611,16 +674,26 @@ export interface RuleStepProps {
|
|
|
611
674
|
draft: WizardDraft;
|
|
612
675
|
onScopeChange: (scope: RuleScope) => void;
|
|
613
676
|
onUntilChange: (until: string) => void;
|
|
677
|
+
/**
|
|
678
|
+
* Why the two persisting scopes cannot be reached from here — a selection
|
|
679
|
+
* spanning accounts has no single account to create the rule for (#477 5.5).
|
|
680
|
+
* They stay pressable and say this; the one-off scope is unaffected.
|
|
681
|
+
*/
|
|
682
|
+
restriction?: string;
|
|
614
683
|
}
|
|
615
684
|
|
|
616
685
|
export function RuleStepBody({
|
|
617
686
|
draft,
|
|
618
687
|
onScopeChange,
|
|
619
688
|
onUntilChange,
|
|
689
|
+
restriction,
|
|
620
690
|
}: RuleStepProps) {
|
|
621
691
|
const untilId = useId();
|
|
622
692
|
const { scope, until = "" } = draft;
|
|
623
693
|
const unreadable = unreadableDraftClauses(draft).length > 0;
|
|
694
|
+
const restricted = restriction && (
|
|
695
|
+
<p className="px-1 text-2xs text-warning">{restriction}</p>
|
|
696
|
+
);
|
|
624
697
|
|
|
625
698
|
return (
|
|
626
699
|
<div className="space-y-2">
|
|
@@ -638,16 +711,21 @@ export function RuleStepBody({
|
|
|
638
711
|
</ChoiceCard>
|
|
639
712
|
<ChoiceCard
|
|
640
713
|
selected={scope === "standing"}
|
|
714
|
+
unavailable={Boolean(restriction)}
|
|
641
715
|
onSelect={() => onScopeChange("standing")}
|
|
642
716
|
title={scopeLabel("standing")}
|
|
643
717
|
description="Saves a rule and applies it to new mail as it arrives, and to the mail already in your mailbox. You'll name it next."
|
|
644
|
-
|
|
718
|
+
>
|
|
719
|
+
{restricted}
|
|
720
|
+
</ChoiceCard>
|
|
645
721
|
<ChoiceCard
|
|
646
722
|
selected={scope === "until"}
|
|
723
|
+
unavailable={Boolean(restriction)}
|
|
647
724
|
onSelect={() => onScopeChange("until")}
|
|
648
725
|
title={scopeLabel("until")}
|
|
649
726
|
description="The same rule, and it stops on a day you pick."
|
|
650
727
|
>
|
|
728
|
+
{restricted}
|
|
651
729
|
{scope === "until" && (
|
|
652
730
|
<div className="rounded-lg border border-line bg-surface p-2">
|
|
653
731
|
<FieldLabel htmlFor={untilId}>Stops on</FieldLabel>
|
|
@@ -753,7 +831,7 @@ export function ReviewStepBody({
|
|
|
753
831
|
<p className="text-sm text-fg">
|
|
754
832
|
<span className="font-semibold">{label}</span>{" "}
|
|
755
833
|
{matchPhrase(description)}
|
|
756
|
-
{
|
|
834
|
+
{folder ? ` to ${folder}` : ""}
|
|
757
835
|
{persists && (
|
|
758
836
|
<>
|
|
759
837
|
{" "}
|
|
@@ -818,10 +896,25 @@ export interface RunStepProps {
|
|
|
818
896
|
applied: number;
|
|
819
897
|
/** The messages the mail server rejected, named one by one. */
|
|
820
898
|
failures: readonly WizardMessage[];
|
|
899
|
+
/**
|
|
900
|
+
* How many the mail server rejected, when that is more than can be named. A
|
|
901
|
+
* server-side pass reports its own failure count without handing back the
|
|
902
|
+
* messages behind it; defaults to the ones named here.
|
|
903
|
+
*/
|
|
904
|
+
failedCount?: number;
|
|
821
905
|
onRetry: () => void;
|
|
822
906
|
onDismiss: () => void;
|
|
823
907
|
}
|
|
824
908
|
|
|
909
|
+
const runOutcomeOf = (props: RunStepProps): RunOutcome => ({
|
|
910
|
+
state: props.state,
|
|
911
|
+
verb: props.verb,
|
|
912
|
+
scope: props.scope,
|
|
913
|
+
matched: props.matched,
|
|
914
|
+
applied: props.applied,
|
|
915
|
+
failed: props.failedCount ?? props.failures.length,
|
|
916
|
+
});
|
|
917
|
+
|
|
825
918
|
const runIcon = (tone: RunCopy["tone"]): ReactNode => {
|
|
826
919
|
if (tone === "progress") {
|
|
827
920
|
return <Loader2 className="size-10 animate-spin text-accent" />;
|
|
@@ -837,22 +930,10 @@ const runIcon = (tone: RunCopy["tone"]): ReactNode => {
|
|
|
837
930
|
);
|
|
838
931
|
};
|
|
839
932
|
|
|
840
|
-
export function RunStepBody({
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
matched,
|
|
845
|
-
applied,
|
|
846
|
-
failures,
|
|
847
|
-
}: RunStepProps) {
|
|
848
|
-
const copy = runCopy({
|
|
849
|
-
state,
|
|
850
|
-
verb,
|
|
851
|
-
scope,
|
|
852
|
-
matched,
|
|
853
|
-
applied,
|
|
854
|
-
failed: failures.length,
|
|
855
|
-
});
|
|
933
|
+
export function RunStepBody(props: RunStepProps) {
|
|
934
|
+
const { matched, applied, failures } = props;
|
|
935
|
+
const outcome = runOutcomeOf(props);
|
|
936
|
+
const copy = runCopy(outcome);
|
|
856
937
|
|
|
857
938
|
return (
|
|
858
939
|
<div className="space-y-4 pt-2">
|
|
@@ -866,7 +947,7 @@ export function RunStepBody({
|
|
|
866
947
|
<ProgressBar
|
|
867
948
|
value={applied}
|
|
868
949
|
max={matched}
|
|
869
|
-
tone={
|
|
950
|
+
tone={outcome.failed > 0 ? "warning" : "success"}
|
|
870
951
|
/>
|
|
871
952
|
)}
|
|
872
953
|
|
|
@@ -894,24 +975,9 @@ export function RunStepBody({
|
|
|
894
975
|
);
|
|
895
976
|
}
|
|
896
977
|
|
|
897
|
-
export function RunFooter({
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
scope,
|
|
901
|
-
matched,
|
|
902
|
-
applied,
|
|
903
|
-
failures,
|
|
904
|
-
onRetry,
|
|
905
|
-
onDismiss,
|
|
906
|
-
}: RunStepProps) {
|
|
907
|
-
const copy = runCopy({
|
|
908
|
-
state,
|
|
909
|
-
verb,
|
|
910
|
-
scope,
|
|
911
|
-
matched,
|
|
912
|
-
applied,
|
|
913
|
-
failed: failures.length,
|
|
914
|
-
});
|
|
978
|
+
export function RunFooter(props: RunStepProps) {
|
|
979
|
+
const { onRetry, onDismiss } = props;
|
|
980
|
+
const copy = runCopy(runOutcomeOf(props));
|
|
915
981
|
|
|
916
982
|
if (copy.retryLabel === undefined) {
|
|
917
983
|
return (
|
|
@@ -1036,12 +1102,17 @@ export function SelectionWizard(props: SelectionWizardProps) {
|
|
|
1036
1102
|
if (step === "run") return <RunFooter {...stepProps(props.run, step)} />;
|
|
1037
1103
|
if (step === "review") {
|
|
1038
1104
|
const review = stepProps(props.review, step);
|
|
1105
|
+
// The commit is a Continue like any other: a match that reaches nothing,
|
|
1106
|
+
// or a count still moving, dims it and says so rather than committing a
|
|
1107
|
+
// bulk action over nothing and reporting it as done.
|
|
1039
1108
|
return (
|
|
1040
1109
|
<FooterNav
|
|
1041
1110
|
onBack={onBack}
|
|
1042
1111
|
nextLabel={review.scope ? commitLabel(review.scope) : label}
|
|
1043
1112
|
nextVariant={destructive ? "danger" : "primary"}
|
|
1044
1113
|
onNext={onCommit}
|
|
1114
|
+
blockedReason={props.blockedReason}
|
|
1115
|
+
nudged={props.nudged}
|
|
1045
1116
|
/>
|
|
1046
1117
|
);
|
|
1047
1118
|
}
|
|
@@ -1058,8 +1129,7 @@ export function SelectionWizard(props: SelectionWizardProps) {
|
|
|
1058
1129
|
|
|
1059
1130
|
const title = (): string => {
|
|
1060
1131
|
if (step !== "run") return screen.title ?? label;
|
|
1061
|
-
|
|
1062
|
-
return runCopy({ ...run, failed: run.failures.length }).screenTitle;
|
|
1132
|
+
return runCopy(runOutcomeOf(stepProps(props.run, step))).screenTitle;
|
|
1063
1133
|
};
|
|
1064
1134
|
|
|
1065
1135
|
return (
|