@remit/ui 0.0.57 → 0.0.59

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.
@@ -0,0 +1,1078 @@
1
+ /**
2
+ * The selection wizard's shell and its seven step bodies (#477).
3
+ *
4
+ * One responsive surface: full-bleed below 768px, the same screens as a centred
5
+ * modal from 768px up. Every screen is a fixed header, one scrolling body and a
6
+ * fixed footer in the thumb zone, so back and forward are always reachable.
7
+ *
8
+ * Everything here is props-in. The step is held by id, the answers and the
9
+ * callbacks belong to whoever drives it, and nothing touches history.
10
+ */
11
+
12
+ import {
13
+ AlertTriangle,
14
+ ArrowLeft,
15
+ ArrowRight,
16
+ Check,
17
+ Loader2,
18
+ X,
19
+ } from "lucide-react";
20
+ import { Fragment, type ReactNode, useEffect, useId, useRef } from "react";
21
+ import { cn } from "../lib/cn.js";
22
+ import {
23
+ backExits,
24
+ type MatchCount,
25
+ type MatchMode,
26
+ matchDoorHint,
27
+ matchDoorLabel,
28
+ matchPhrase,
29
+ matchSummary,
30
+ type RunCopy,
31
+ type RunState,
32
+ runCopy,
33
+ type SampleEmptyReason,
34
+ type StepId,
35
+ sampleEmptyCopy,
36
+ stepIndex,
37
+ stepLabel,
38
+ unreadableDraftClauses,
39
+ type Verb,
40
+ verbCopy,
41
+ type WizardDraft,
42
+ } from "../lib/wizard-steps.js";
43
+ import { Badge } from "./badge.js";
44
+ import { Button } from "./button.js";
45
+ import { FieldLabel } from "./field-label.js";
46
+ import {
47
+ AddChipButton,
48
+ ClauseChip,
49
+ type ClauseDraft,
50
+ ClauseEditor,
51
+ } from "./filter-clause-chip.js";
52
+ import {
53
+ type ClauseField,
54
+ commitLabel,
55
+ type MatchOperator,
56
+ matchJoinWord,
57
+ matchOperatorLabel,
58
+ previewCountSummary,
59
+ type RuleClause,
60
+ type RuleScope,
61
+ ruleBlockedCopy,
62
+ scopeLabel,
63
+ } from "./filter-rule.js";
64
+ import type { ClauseEditState } from "./filter-rule-editor.js";
65
+ import { Input } from "./input.js";
66
+ import {
67
+ type MoveMailboxOption,
68
+ MoveMailboxPicker,
69
+ } from "./move-mailbox-picker.js";
70
+ import { ProgressBar } from "./progress-bar.js";
71
+ import type { SearchConversionNotice } from "./search-conversion.js";
72
+ import { SearchConversionNoticeView } from "./search-conversion-notice.js";
73
+ import { SegmentedControl, type SegmentedOption } from "./segmented-control.js";
74
+ import type { Suggestion } from "./suggest-list.js";
75
+
76
+ /** A row of the match, as the sample and the failure list render it. */
77
+ export interface WizardMessage {
78
+ id: string;
79
+ sender: string;
80
+ subject: string;
81
+ date: string;
82
+ }
83
+
84
+ export interface StepRailProps {
85
+ steps: readonly StepId[];
86
+ step: StepId;
87
+ }
88
+
89
+ export function StepRail({ steps, step }: StepRailProps) {
90
+ const active = stepIndex(steps, step);
91
+ return (
92
+ <ol className="flex items-center gap-1.5" aria-label="Progress">
93
+ {steps.map((id, i) => (
94
+ <li key={id} className="flex flex-1 items-center gap-1.5">
95
+ <span
96
+ className={cn(
97
+ "h-1 flex-1 rounded-full transition-colors",
98
+ i <= active ? "bg-accent" : "bg-surface-sunken",
99
+ )}
100
+ aria-current={i === active ? "step" : undefined}
101
+ />
102
+ </li>
103
+ ))}
104
+ </ol>
105
+ );
106
+ }
107
+
108
+ export interface WizardScreenProps {
109
+ title: string;
110
+ subtitle?: string;
111
+ steps: readonly StepId[];
112
+ step: StepId;
113
+ onBack: () => void;
114
+ onExit: () => void;
115
+ footer: ReactNode;
116
+ children: ReactNode;
117
+ }
118
+
119
+ /**
120
+ * The wizard chrome. The body is the only scrolling region, so the header's back
121
+ * and the footer's controls never leave the screen. From 768px up the same
122
+ * markup centres over the list as a modal rather than becoming a second screen.
123
+ */
124
+ export function WizardScreen({
125
+ title,
126
+ subtitle,
127
+ steps,
128
+ step,
129
+ onBack,
130
+ onExit,
131
+ footer,
132
+ children,
133
+ }: WizardScreenProps) {
134
+ const active = stepIndex(steps, step);
135
+ return (
136
+ <div 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">
137
+ <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
+ <header className="shrink-0 border-b border-line px-3 pb-2 pt-3">
139
+ <div className="flex items-center gap-1">
140
+ <button
141
+ type="button"
142
+ onClick={onBack}
143
+ aria-label="Back"
144
+ className="flex size-11 items-center justify-center rounded-md text-fg-muted"
145
+ >
146
+ <ArrowLeft className="size-5" />
147
+ </button>
148
+ <div className="min-w-0 flex-1 text-center">
149
+ <h1 className="truncate text-sm font-semibold">{title}</h1>
150
+ {subtitle && (
151
+ <p className="truncate text-2xs text-fg-muted">{subtitle}</p>
152
+ )}
153
+ </div>
154
+ <button
155
+ type="button"
156
+ onClick={onExit}
157
+ aria-label="Cancel"
158
+ className="flex size-11 items-center justify-center rounded-md text-fg-muted"
159
+ >
160
+ <X className="size-5" />
161
+ </button>
162
+ </div>
163
+ <div className="space-y-1 px-1 pt-2">
164
+ <StepRail steps={steps} step={step} />
165
+ <p className="text-2xs text-fg-subtle">
166
+ Step {active + 1} of {steps.length} · {stepLabel(steps[active])}
167
+ </p>
168
+ </div>
169
+ </header>
170
+
171
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
172
+ {children}
173
+ </div>
174
+
175
+ <footer className="shrink-0 border-t border-line px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3">
176
+ {footer}
177
+ </footer>
178
+ </div>
179
+ </div>
180
+ );
181
+ }
182
+
183
+ export interface ChoiceCardProps {
184
+ selected: boolean;
185
+ title: ReactNode;
186
+ description: ReactNode;
187
+ /** Stays pressable and dimmed; pressing it must take the user somewhere that works. */
188
+ unavailable?: boolean;
189
+ onSelect: () => void;
190
+ children?: ReactNode;
191
+ }
192
+
193
+ export function ChoiceCard({
194
+ selected,
195
+ title,
196
+ description,
197
+ unavailable,
198
+ onSelect,
199
+ children,
200
+ }: ChoiceCardProps) {
201
+ return (
202
+ <div className="space-y-1.5">
203
+ <button
204
+ type="button"
205
+ onClick={onSelect}
206
+ aria-pressed={selected}
207
+ aria-disabled={unavailable || undefined}
208
+ className={cn(
209
+ "flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors",
210
+ selected
211
+ ? "border-accent bg-accent-soft"
212
+ : "border-line bg-surface hover:bg-surface-sunken",
213
+ unavailable && "opacity-55",
214
+ )}
215
+ >
216
+ <span
217
+ className={cn(
218
+ "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border",
219
+ selected ? "border-accent bg-accent text-accent-fg" : "border-line",
220
+ )}
221
+ >
222
+ {selected && <Check className="size-3" />}
223
+ </span>
224
+ <span className="min-w-0 flex-1">
225
+ <span className="block text-sm font-medium text-fg">{title}</span>
226
+ <span className="mt-0.5 block text-xs text-fg-muted">
227
+ {description}
228
+ </span>
229
+ </span>
230
+ </button>
231
+ {children}
232
+ </div>
233
+ );
234
+ }
235
+
236
+ export interface SelectionSampleProps {
237
+ messages: readonly WizardMessage[];
238
+ /**
239
+ * The server's count of what the match covers (#477 5.3), carrying whether it
240
+ * is still moving so nothing commits against a number that is about to change.
241
+ * `uncounted` is the widened door, which carries no count until it has run.
242
+ */
243
+ count: MatchCount;
244
+ label: string;
245
+ /** Why there are no rows. Defaults to nothing matching, never to a bare empty state. */
246
+ emptyReason?: SampleEmptyReason;
247
+ }
248
+
249
+ const sampleFooter = (count: MatchCount, shown: number): string => {
250
+ if (count.status === "uncounted") {
251
+ return "The first matches. The total is not known until the run finishes.";
252
+ }
253
+ if (count.status === "ready" && count.count > shown && !count.stale) {
254
+ return `and ${count.count - shown} more`;
255
+ }
256
+ return previewCountSummary(count);
257
+ };
258
+
259
+ /**
260
+ * The members of the match, closing every screen that names one. A named match
261
+ * with no members shown is an unseen bulk action, so the rows scroll in their own
262
+ * bounded region rather than truncating.
263
+ */
264
+ export function SelectionSample({
265
+ messages,
266
+ count,
267
+ label,
268
+ emptyReason,
269
+ }: SelectionSampleProps) {
270
+ return (
271
+ <section className="flex flex-col rounded-lg border border-line bg-surface">
272
+ <h2 className="shrink-0 border-b border-line px-3 py-2 text-2xs font-medium uppercase tracking-wide text-fg-subtle">
273
+ {label}
274
+ </h2>
275
+ {messages.length === 0 ? (
276
+ <p className="px-3 py-4 text-xs text-fg-muted">
277
+ {sampleEmptyCopy(emptyReason ?? "noMatch")}
278
+ </p>
279
+ ) : (
280
+ <>
281
+ <ul className="min-h-0 max-h-[45dvh] divide-y divide-line overflow-y-auto overscroll-contain md:max-h-[17rem]">
282
+ {messages.map((message) => (
283
+ <li key={message.id} className="px-3 py-2">
284
+ <div className="flex items-baseline justify-between gap-2">
285
+ <span className="truncate text-xs font-medium text-fg">
286
+ {message.sender}
287
+ </span>
288
+ <span className="shrink-0 text-2xs text-fg-subtle">
289
+ {message.date}
290
+ </span>
291
+ </div>
292
+ <p className="truncate text-xs text-fg-muted">
293
+ {message.subject}
294
+ </p>
295
+ </li>
296
+ ))}
297
+ </ul>
298
+ <p className="shrink-0 border-t border-line px-3 py-2 text-2xs text-fg-subtle">
299
+ {sampleFooter(count, messages.length)}
300
+ </p>
301
+ </>
302
+ )}
303
+ </section>
304
+ );
305
+ }
306
+
307
+ export interface FooterNavProps {
308
+ backLabel?: string;
309
+ onBack: () => void;
310
+ nextLabel: string;
311
+ onNext: () => void;
312
+ nextVariant?: "primary" | "danger";
313
+ /** What the step is still missing. Dims the control; never disables it. */
314
+ blockedReason?: string;
315
+ /** Continue was pressed while blocked, so the reason is on screen. */
316
+ nudged?: boolean;
317
+ }
318
+
319
+ export function FooterNav({
320
+ backLabel = "Back",
321
+ onBack,
322
+ nextLabel,
323
+ onNext,
324
+ nextVariant = "primary",
325
+ blockedReason,
326
+ nudged,
327
+ }: FooterNavProps) {
328
+ return (
329
+ <div className="space-y-2">
330
+ {nudged && blockedReason && (
331
+ <p role="status" className="px-1 text-2xs text-warning">
332
+ {blockedReason}
333
+ </p>
334
+ )}
335
+ <div className="flex items-center gap-3">
336
+ <Button
337
+ variant="ghost"
338
+ size="touch"
339
+ onClick={onBack}
340
+ icon={<ArrowLeft className="size-4" />}
341
+ className="shrink-0"
342
+ >
343
+ {backLabel}
344
+ </Button>
345
+ <Button
346
+ variant={nextVariant}
347
+ size="touch"
348
+ onClick={onNext}
349
+ aria-disabled={blockedReason ? true : undefined}
350
+ className={cn("flex-1", blockedReason && "opacity-55")}
351
+ >
352
+ {nextLabel}
353
+ {nextVariant === "primary" && <ArrowRight className="size-4" />}
354
+ </Button>
355
+ </div>
356
+ </div>
357
+ );
358
+ }
359
+
360
+ export interface MatchStepProps {
361
+ selectedCount: number;
362
+ mode: MatchMode;
363
+ onModeChange: (mode: MatchMode) => void;
364
+ /**
365
+ * Similar-mail matching cannot run right now. A runtime state, not a property
366
+ * of the deployment: the door stays pressable and dimmed.
367
+ */
368
+ semanticUnavailable?: boolean;
369
+ /** The dimmed door was pressed and the senders were filled in instead. */
370
+ semanticFallbackTaken?: boolean;
371
+ onSemanticFallback: () => void;
372
+ sample: SelectionSampleProps;
373
+ }
374
+
375
+ export function MatchStepBody({
376
+ selectedCount,
377
+ mode,
378
+ onModeChange,
379
+ semanticUnavailable,
380
+ semanticFallbackTaken,
381
+ onSemanticFallback,
382
+ sample,
383
+ }: MatchStepProps) {
384
+ return (
385
+ <>
386
+ <div className="space-y-2">
387
+ <ChoiceCard
388
+ selected={mode === "selected"}
389
+ onSelect={() => onModeChange("selected")}
390
+ title={matchDoorLabel("selected", selectedCount)}
391
+ description={matchDoorHint("selected")}
392
+ />
393
+ <ChoiceCard
394
+ selected={mode === "similar"}
395
+ unavailable={semanticUnavailable}
396
+ onSelect={
397
+ semanticUnavailable
398
+ ? onSemanticFallback
399
+ : () => onModeChange("similar")
400
+ }
401
+ title={matchDoorLabel("similar", selectedCount)}
402
+ description={matchDoorHint("similar")}
403
+ >
404
+ {semanticFallbackTaken && (
405
+ <p role="status" className="px-1 text-2xs text-fg-subtle">
406
+ Similar-mail matching is unavailable right now — matching on the
407
+ senders instead.
408
+ </p>
409
+ )}
410
+ </ChoiceCard>
411
+ <ChoiceCard
412
+ selected={mode === "properties"}
413
+ onSelect={() => onModeChange("properties")}
414
+ title={matchDoorLabel("properties", selectedCount)}
415
+ description={matchDoorHint("properties")}
416
+ />
417
+ </div>
418
+ <div className="mt-4">
419
+ <SelectionSample {...sample} />
420
+ </div>
421
+ </>
422
+ );
423
+ }
424
+
425
+ const MATCH_OPERATOR_OPTIONS: SegmentedOption<MatchOperator>[] = [
426
+ { value: "all", label: matchOperatorLabel("all") },
427
+ { value: "any", label: matchOperatorLabel("any") },
428
+ ];
429
+
430
+ export interface PropertiesStepProps {
431
+ clauses: readonly RuleClause[];
432
+ matchOperator: MatchOperator;
433
+ onMatchOperatorChange: (operator: MatchOperator) => void;
434
+ /** The clause being added or amended, or absent when none is. */
435
+ clauseEdit?: ClauseEditState;
436
+ onStartAddClause: () => void;
437
+ onStartEditClause: (clauseId: string) => void;
438
+ onRemoveClause: (clauseId: string) => void;
439
+ onChangeDraft: (draft: ClauseDraft) => void;
440
+ onSubmitClause: () => void;
441
+ onCancelClause: () => void;
442
+ /** The fields this deployment can actually match on. Defaults to the whole vocabulary. */
443
+ clauseFields?: ClauseField[];
444
+ /** Values worth offering for the field being edited — never a constraint. */
445
+ clauseSuggestions?: readonly Suggestion[];
446
+ /** What converting a search left behind, when a query is what opened this. */
447
+ conversionNotice?: SearchConversionNotice;
448
+ /** The similar door was dimmed and these are the senders it fell back to. */
449
+ semanticFallbackTaken?: boolean;
450
+ sample: SelectionSampleProps;
451
+ }
452
+
453
+ /**
454
+ * The clauses as the rule editor renders them — the same chips, the same inline
455
+ * editor and the same value suggestions — so a rule reads identically whether it
456
+ * was built here or in Settings.
457
+ */
458
+ export function PropertiesStepBody({
459
+ clauses,
460
+ matchOperator,
461
+ onMatchOperatorChange,
462
+ clauseEdit,
463
+ onStartAddClause,
464
+ onStartEditClause,
465
+ onRemoveClause,
466
+ onChangeDraft,
467
+ onSubmitClause,
468
+ onCancelClause,
469
+ clauseFields,
470
+ clauseSuggestions,
471
+ conversionNotice,
472
+ semanticFallbackTaken,
473
+ sample,
474
+ }: PropertiesStepProps) {
475
+ const join = matchJoinWord(matchOperator);
476
+ return (
477
+ <div className="space-y-4">
478
+ {semanticFallbackTaken && (
479
+ <p role="status" className="px-1 text-xs text-fg-muted">
480
+ Similar-mail matching is unavailable right now. These are the senders
481
+ of the messages you picked.
482
+ </p>
483
+ )}
484
+
485
+ {conversionNotice && (
486
+ <SearchConversionNoticeView notice={conversionNotice} />
487
+ )}
488
+
489
+ <div className="flex flex-wrap items-center gap-1.5">
490
+ {clauses.map((clause, index) => (
491
+ <Fragment key={clause.id}>
492
+ {index > 0 && (
493
+ <span className="text-2xs font-medium uppercase text-fg-subtle">
494
+ {join}
495
+ </span>
496
+ )}
497
+ <ClauseChip
498
+ clause={clause}
499
+ onEdit={() => onStartEditClause(clause.id)}
500
+ onRemove={() => onRemoveClause(clause.id)}
501
+ />
502
+ </Fragment>
503
+ ))}
504
+ {!clauseEdit && (
505
+ <AddChipButton label="Add clause" onClick={onStartAddClause} />
506
+ )}
507
+ </div>
508
+
509
+ {clauseEdit && (
510
+ <ClauseEditor
511
+ draft={clauseEdit.draft}
512
+ mode={clauseEdit.mode}
513
+ fields={clauseFields}
514
+ suggestions={clauseSuggestions}
515
+ onChangeField={(field) =>
516
+ onChangeDraft({ ...clauseEdit.draft, field })
517
+ }
518
+ onChangeValue={(value) =>
519
+ onChangeDraft({ ...clauseEdit.draft, value })
520
+ }
521
+ onSubmit={onSubmitClause}
522
+ onCancel={onCancelClause}
523
+ />
524
+ )}
525
+
526
+ {clauses.length > 1 && (
527
+ <div className="flex items-center gap-2">
528
+ <span className="text-xs text-fg-muted">Match</span>
529
+ <SegmentedControl
530
+ name="wizard-match-operator"
531
+ size="sm"
532
+ aria-label="Match operator"
533
+ options={MATCH_OPERATOR_OPTIONS}
534
+ value={matchOperator}
535
+ onChange={onMatchOperatorChange}
536
+ />
537
+ </div>
538
+ )}
539
+
540
+ <p className="px-1 text-xs text-fg-muted">
541
+ We started you off from what you were looking at. Change any of it —
542
+ this is where the rule is decided, not the list behind it.
543
+ </p>
544
+
545
+ <SelectionSample {...sample} />
546
+ </div>
547
+ );
548
+ }
549
+
550
+ export interface FolderStepProps {
551
+ /**
552
+ * The destinations, already filtered, ordered and labelled by the app
553
+ * (`buildMoveTargets`). The wizard does not reorder them: a second ordering
554
+ * beside the one the Move picker already applies would fight it.
555
+ */
556
+ mailboxes: readonly MoveMailboxOption[];
557
+ /** The destination chosen so far. */
558
+ mailboxId?: string;
559
+ onSelect: (mailboxId: string) => void;
560
+ /**
561
+ * Creating a folder is an IMAP mutation and the move that follows is a
562
+ * dependent write, so this resolves only once the mail server confirms the
563
+ * folder (docs/architecture/imap-mutations.md). The picker holds the wait,
564
+ * refuses a second submit while it runs, and states a failure where it
565
+ * happened. Absent offers no create.
566
+ */
567
+ onCreateFolder?: (
568
+ name: string,
569
+ signal?: AbortSignal,
570
+ ) => Promise<MoveMailboxOption>;
571
+ }
572
+
573
+ export function FolderStepBody({
574
+ mailboxes,
575
+ mailboxId,
576
+ onSelect,
577
+ onCreateFolder,
578
+ }: FolderStepProps) {
579
+ const chosen = mailboxes.find((mailbox) => mailbox.id === mailboxId);
580
+ return (
581
+ <div className="flex min-h-0 flex-col gap-3">
582
+ <p className="px-1 text-xs text-fg-muted">
583
+ {chosen
584
+ ? `Moving to ${chosen.label}.`
585
+ : "Search for a folder, or type a name that doesn't exist yet to make one."}
586
+ </p>
587
+ <div className="overflow-hidden rounded-lg border border-line bg-surface">
588
+ <MoveMailboxPicker
589
+ mailboxes={mailboxes}
590
+ onSelect={onSelect}
591
+ onCreateFolder={onCreateFolder}
592
+ autoFocus
593
+ labels={{
594
+ searchPlaceholder: "Move to…",
595
+ optionLabel: (label) => `Move to ${label}`,
596
+ createPending: "Waiting for the mail server to confirm the folder…",
597
+ }}
598
+ />
599
+ </div>
600
+ </div>
601
+ );
602
+ }
603
+
604
+ export interface RuleStepProps {
605
+ /**
606
+ * The rule so far. The scope and its stop date are answered here; the rest is
607
+ * read, because whether a one-time apply can serve this rule depends on what
608
+ * is matching it — a body-text clause is unreadable under the literal matcher
609
+ * and perfectly readable under the semantic widen.
610
+ */
611
+ draft: WizardDraft;
612
+ onScopeChange: (scope: RuleScope) => void;
613
+ onUntilChange: (until: string) => void;
614
+ }
615
+
616
+ export function RuleStepBody({
617
+ draft,
618
+ onScopeChange,
619
+ onUntilChange,
620
+ }: RuleStepProps) {
621
+ const untilId = useId();
622
+ const { scope, until = "" } = draft;
623
+ const unreadable = unreadableDraftClauses(draft).length > 0;
624
+
625
+ return (
626
+ <div className="space-y-2">
627
+ <ChoiceCard
628
+ selected={scope === "once"}
629
+ onSelect={() => onScopeChange("once")}
630
+ title={scopeLabel("once")}
631
+ description="A one-off tidy-up. Nothing changes for future mail."
632
+ >
633
+ {unreadable && (
634
+ <p className="px-1 text-2xs text-warning">
635
+ {ruleBlockedCopy.bodyTextOnce}
636
+ </p>
637
+ )}
638
+ </ChoiceCard>
639
+ <ChoiceCard
640
+ selected={scope === "standing"}
641
+ onSelect={() => onScopeChange("standing")}
642
+ title={scopeLabel("standing")}
643
+ 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
+ />
645
+ <ChoiceCard
646
+ selected={scope === "until"}
647
+ onSelect={() => onScopeChange("until")}
648
+ title={scopeLabel("until")}
649
+ description="The same rule, and it stops on a day you pick."
650
+ >
651
+ {scope === "until" && (
652
+ <div className="rounded-lg border border-line bg-surface p-2">
653
+ <FieldLabel htmlFor={untilId}>Stops on</FieldLabel>
654
+ <Input
655
+ id={untilId}
656
+ type="date"
657
+ value={until}
658
+ onChange={(event) => onUntilChange(event.target.value)}
659
+ />
660
+ </div>
661
+ )}
662
+ </ChoiceCard>
663
+ </div>
664
+ );
665
+ }
666
+
667
+ export interface NameStepProps {
668
+ name: string;
669
+ onNameChange: (name: string) => void;
670
+ /**
671
+ * Continue was pressed while the name was still missing. The field takes
672
+ * focus, so the answer the message asks for is one keystroke away rather than
673
+ * one more tap.
674
+ */
675
+ nudged?: boolean;
676
+ }
677
+
678
+ export function NameStepBody({ name, onNameChange, nudged }: NameStepProps) {
679
+ const nameId = useId();
680
+ const inputRef = useRef<HTMLInputElement>(null);
681
+
682
+ useEffect(() => {
683
+ if (nudged) inputRef.current?.focus();
684
+ }, [nudged]);
685
+
686
+ return (
687
+ <>
688
+ <FieldLabel htmlFor={nameId}>Rule name</FieldLabel>
689
+ <Input
690
+ id={nameId}
691
+ ref={inputRef}
692
+ value={name}
693
+ placeholder="Travel confirmations"
694
+ onChange={(event) => onNameChange(event.target.value)}
695
+ trailing={
696
+ name ? (
697
+ <button
698
+ type="button"
699
+ aria-label="Clear name"
700
+ onClick={() => {
701
+ onNameChange("");
702
+ inputRef.current?.focus();
703
+ }}
704
+ className="-mr-1 flex size-7 shrink-0 items-center justify-center rounded-full text-fg-subtle hover:bg-surface hover:text-fg"
705
+ >
706
+ <X className="size-4" />
707
+ </button>
708
+ ) : undefined
709
+ }
710
+ />
711
+ <p className="mt-2 text-xs text-fg-muted">
712
+ We suggested one from this selection. Clear it and write your own if it
713
+ doesn't read right.
714
+ </p>
715
+ </>
716
+ );
717
+ }
718
+
719
+ export interface ReviewStepProps {
720
+ verb: Verb;
721
+ mode: MatchMode;
722
+ selectedCount: number;
723
+ clauses: readonly RuleClause[];
724
+ matchOperator: MatchOperator;
725
+ folder?: string;
726
+ scope?: RuleScope;
727
+ until?: string;
728
+ /** Present when the flow reached the naming step. */
729
+ ruleName?: string;
730
+ sample: SelectionSampleProps;
731
+ }
732
+
733
+ export function ReviewStepBody({
734
+ verb,
735
+ mode,
736
+ selectedCount,
737
+ clauses,
738
+ matchOperator,
739
+ folder,
740
+ scope,
741
+ until,
742
+ ruleName,
743
+ sample,
744
+ }: ReviewStepProps) {
745
+ const { label } = verbCopy(verb);
746
+ const description = { mode, selectedCount, clauses, matchOperator };
747
+ const widened = mode !== "selected";
748
+ const persists = scope === "standing" || scope === "until";
749
+
750
+ return (
751
+ <div className="space-y-4">
752
+ <div className="rounded-lg border border-line bg-surface p-3">
753
+ <p className="text-sm text-fg">
754
+ <span className="font-semibold">{label}</span>{" "}
755
+ {matchPhrase(description)}
756
+ {verb === "move" && folder ? ` to ${folder}` : ""}
757
+ {persists && (
758
+ <>
759
+ {" "}
760
+ and <span className="font-medium">save a rule</span> that keeps
761
+ doing it
762
+ </>
763
+ )}
764
+ {scope === "until" && until && ` until ${until}`}.
765
+ </p>
766
+ {widened && (
767
+ <p className="mt-2 flex items-start gap-1.5 text-xs text-warning">
768
+ <AlertTriangle className="mt-px size-3.5 shrink-0" />
769
+ This covers messages not shown in the list.
770
+ </p>
771
+ )}
772
+ </div>
773
+
774
+ <dl className="divide-y divide-line overflow-hidden rounded-lg border border-line bg-surface text-xs">
775
+ <div className="flex justify-between gap-3 px-3 py-2">
776
+ <dt className="text-fg-muted">Action</dt>
777
+ <dd className="font-medium">{label}</dd>
778
+ </div>
779
+ <div className="flex justify-between gap-3 px-3 py-2">
780
+ <dt className="shrink-0 text-fg-muted">Apply to</dt>
781
+ <dd className="truncate font-medium">{matchSummary(description)}</dd>
782
+ </div>
783
+ {folder && (
784
+ <div className="flex justify-between gap-3 px-3 py-2">
785
+ <dt className="text-fg-muted">Destination</dt>
786
+ <dd className="font-medium">{folder}</dd>
787
+ </div>
788
+ )}
789
+ {scope && (
790
+ <div className="flex justify-between gap-3 px-3 py-2">
791
+ <dt className="text-fg-muted">Scope</dt>
792
+ <dd className="font-medium">
793
+ {scopeLabel(scope)}
794
+ {scope === "until" && until ? ` · ${until}` : ""}
795
+ </dd>
796
+ </div>
797
+ )}
798
+ {ruleName !== undefined && (
799
+ <div className="flex justify-between gap-3 px-3 py-2">
800
+ <dt className="text-fg-muted">Rule name</dt>
801
+ <dd className="truncate font-medium">{ruleName}</dd>
802
+ </div>
803
+ )}
804
+ </dl>
805
+
806
+ <SelectionSample {...sample} />
807
+ </div>
808
+ );
809
+ }
810
+
811
+ export interface RunStepProps {
812
+ state: RunState;
813
+ verb: Verb;
814
+ scope?: RuleScope;
815
+ /** How many messages the match reached. */
816
+ matched: number;
817
+ /** How many of them the action has covered so far. */
818
+ applied: number;
819
+ /** The messages the mail server rejected, named one by one. */
820
+ failures: readonly WizardMessage[];
821
+ onRetry: () => void;
822
+ onDismiss: () => void;
823
+ }
824
+
825
+ const runIcon = (tone: RunCopy["tone"]): ReactNode => {
826
+ if (tone === "progress") {
827
+ return <Loader2 className="size-10 animate-spin text-accent" />;
828
+ }
829
+ if (tone === "warning")
830
+ return <AlertTriangle className="size-10 text-warning" />;
831
+ if (tone === "danger")
832
+ return <AlertTriangle className="size-10 text-danger" />;
833
+ return (
834
+ <span className="flex size-12 items-center justify-center rounded-full bg-accent-soft text-accent">
835
+ <Check className="size-6" />
836
+ </span>
837
+ );
838
+ };
839
+
840
+ export function RunStepBody({
841
+ state,
842
+ verb,
843
+ scope,
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
+ });
856
+
857
+ return (
858
+ <div className="space-y-4 pt-2">
859
+ <div className="flex flex-col items-center gap-3 py-6 text-center">
860
+ {runIcon(copy.tone)}
861
+ <p className="text-sm font-medium">{copy.title}</p>
862
+ <p className="max-w-xs text-xs text-fg-muted">{copy.detail}</p>
863
+ </div>
864
+
865
+ {copy.showProgress && (
866
+ <ProgressBar
867
+ value={applied}
868
+ max={matched}
869
+ tone={failures.length > 0 ? "warning" : "success"}
870
+ />
871
+ )}
872
+
873
+ {failures.length > 0 && (
874
+ <section className="rounded-lg border border-line bg-surface">
875
+ <h2 className="border-b border-line px-3 py-2 text-2xs font-medium uppercase tracking-wide text-fg-subtle">
876
+ {copy.failureListLabel}
877
+ </h2>
878
+ <ul className="divide-y divide-line">
879
+ {failures.map((message) => (
880
+ <li key={message.id} className="px-3 py-2">
881
+ <p className="truncate text-xs font-medium">{message.sender}</p>
882
+ <p className="truncate text-xs text-fg-muted">
883
+ {message.subject}
884
+ </p>
885
+ <Badge className="mt-1" tone="warning">
886
+ Server rejected
887
+ </Badge>
888
+ </li>
889
+ ))}
890
+ </ul>
891
+ </section>
892
+ )}
893
+ </div>
894
+ );
895
+ }
896
+
897
+ export function RunFooter({
898
+ state,
899
+ verb,
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
+ });
915
+
916
+ if (copy.retryLabel === undefined) {
917
+ return (
918
+ <Button
919
+ variant={copy.tone === "progress" ? "ghost" : "primary"}
920
+ size="touch"
921
+ className="w-full"
922
+ onClick={onDismiss}
923
+ >
924
+ {copy.dismissLabel}
925
+ </Button>
926
+ );
927
+ }
928
+
929
+ return (
930
+ <div className="flex items-center gap-3">
931
+ <Button variant="ghost" size="touch" onClick={onDismiss}>
932
+ {copy.dismissLabel}
933
+ </Button>
934
+ <Button
935
+ variant="primary"
936
+ size="touch"
937
+ className="flex-1"
938
+ onClick={onRetry}
939
+ >
940
+ {copy.retryLabel}
941
+ </Button>
942
+ </div>
943
+ );
944
+ }
945
+
946
+ const SCREEN_COPY: Record<StepId, { title?: string; subtitle?: string }> = {
947
+ match: { subtitle: "What should this apply to?" },
948
+ properties: {
949
+ title: "Match properties",
950
+ subtitle: "Which properties have to match?",
951
+ },
952
+ folder: { title: "Move to", subtitle: "Pick a destination" },
953
+ rule: { title: "Organize", subtitle: "How long should this hold?" },
954
+ name: { title: "Name the rule", subtitle: "So you can find it later" },
955
+ review: { title: "Review", subtitle: "Check before it runs" },
956
+ run: {},
957
+ };
958
+
959
+ export interface SelectionWizardProps {
960
+ verb: Verb;
961
+ steps: readonly StepId[];
962
+ /**
963
+ * Held by id, so an answer that shortens the list cannot strand it. A step the
964
+ * current answers dropped resolves to the opening step, and the header, the
965
+ * rail and the body all read that one resolved step.
966
+ */
967
+ step: StepId;
968
+ /**
969
+ * Moves back one step. Not called on a step where Back leaves the wizard
970
+ * (`backExits`) — the opening step has nothing behind it, and the Run step's
971
+ * action has already happened — where `onExit` is called instead.
972
+ */
973
+ onBack: () => void;
974
+ onExit: () => void;
975
+ onContinue: () => void;
976
+ onCommit: () => void;
977
+ /** What the current step is still missing. Dims Continue; never disables it. */
978
+ blockedReason?: string;
979
+ /** Continue was pressed while blocked, so the reason belongs on screen. */
980
+ nudged?: boolean;
981
+ match?: MatchStepProps;
982
+ properties?: PropertiesStepProps;
983
+ folder?: FolderStepProps;
984
+ rule?: RuleStepProps;
985
+ name?: NameStepProps;
986
+ review?: ReviewStepProps;
987
+ run?: RunStepProps;
988
+ }
989
+
990
+ const stepProps = <T,>(props: T | undefined, step: StepId): T => {
991
+ if (props === undefined) {
992
+ throw new Error(
993
+ `The ${stepLabel(step)} step was rendered without its answers.`,
994
+ );
995
+ }
996
+ return props;
997
+ };
998
+
999
+ /**
1000
+ * The whole wizard, driven from outside. It owns no state: the step, the answers
1001
+ * and every callback come in, so the same screens serve the app and the workbench
1002
+ * without either growing its own copy of them.
1003
+ */
1004
+ export function SelectionWizard(props: SelectionWizardProps) {
1005
+ const { verb, steps, onExit, onContinue, onCommit } = props;
1006
+ const { label, destructive } = verbCopy(verb);
1007
+ // Resolved once. Rendering the header from the resolved step and the body
1008
+ // from the held one is how a rail and a screen come to disagree.
1009
+ const step = steps[stepIndex(steps, props.step)];
1010
+ const screen = SCREEN_COPY[step];
1011
+ const onBack = backExits(steps, step) ? onExit : props.onBack;
1012
+
1013
+ const body = (): ReactNode => {
1014
+ if (step === "match")
1015
+ return <MatchStepBody {...stepProps(props.match, step)} />;
1016
+ if (step === "properties") {
1017
+ return <PropertiesStepBody {...stepProps(props.properties, step)} />;
1018
+ }
1019
+ if (step === "folder") {
1020
+ return <FolderStepBody {...stepProps(props.folder, step)} />;
1021
+ }
1022
+ if (step === "rule")
1023
+ return <RuleStepBody {...stepProps(props.rule, step)} />;
1024
+ if (step === "name") {
1025
+ return (
1026
+ <NameStepBody {...stepProps(props.name, step)} nudged={props.nudged} />
1027
+ );
1028
+ }
1029
+ if (step === "review") {
1030
+ return <ReviewStepBody {...stepProps(props.review, step)} />;
1031
+ }
1032
+ return <RunStepBody {...stepProps(props.run, step)} />;
1033
+ };
1034
+
1035
+ const footer = (): ReactNode => {
1036
+ if (step === "run") return <RunFooter {...stepProps(props.run, step)} />;
1037
+ if (step === "review") {
1038
+ const review = stepProps(props.review, step);
1039
+ return (
1040
+ <FooterNav
1041
+ onBack={onBack}
1042
+ nextLabel={review.scope ? commitLabel(review.scope) : label}
1043
+ nextVariant={destructive ? "danger" : "primary"}
1044
+ onNext={onCommit}
1045
+ />
1046
+ );
1047
+ }
1048
+ return (
1049
+ <FooterNav
1050
+ onBack={onBack}
1051
+ nextLabel="Continue"
1052
+ onNext={onContinue}
1053
+ blockedReason={props.blockedReason}
1054
+ nudged={props.nudged}
1055
+ />
1056
+ );
1057
+ };
1058
+
1059
+ const title = (): string => {
1060
+ if (step !== "run") return screen.title ?? label;
1061
+ const run = stepProps(props.run, step);
1062
+ return runCopy({ ...run, failed: run.failures.length }).screenTitle;
1063
+ };
1064
+
1065
+ return (
1066
+ <WizardScreen
1067
+ title={title()}
1068
+ subtitle={screen.subtitle}
1069
+ steps={steps}
1070
+ step={step}
1071
+ onBack={onBack}
1072
+ onExit={onExit}
1073
+ footer={footer()}
1074
+ >
1075
+ {body()}
1076
+ </WizardScreen>
1077
+ );
1078
+ }