@remit/ui 0.0.69 → 0.0.71

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.69",
3
+ "version": "0.0.71",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -17,7 +17,6 @@ export interface AutoMovedBadgeProps {
17
17
  * way to reach the filter that keeps moving mail. Omit for a classifier move.
18
18
  */
19
19
  filtersHref?: string;
20
- manageLabel?: string;
21
20
  className?: string;
22
21
  }
23
22
 
@@ -34,7 +33,6 @@ export function AutoMovedBadge({
34
33
  onUndo,
35
34
  undoLabel = "Undo",
36
35
  filtersHref,
37
- manageLabel = "Manage filter",
38
36
  className,
39
37
  }: AutoMovedBadgeProps) {
40
38
  return (
@@ -61,7 +59,7 @@ export function AutoMovedBadge({
61
59
  }}
62
60
  className="font-semibold underline decoration-dotted underline-offset-2 hover:decoration-solid"
63
61
  >
64
- {manageLabel}
62
+ Manage filter
65
63
  </a>
66
64
  )}
67
65
  </Badge>
@@ -75,17 +75,6 @@ export interface BriefSectionsProps {
75
75
  onSelectSource?: (id: string) => void;
76
76
  /** Seeds the filter panel open on first render (stories / deep links). */
77
77
  defaultExpanded?: boolean;
78
- /**
79
- * Whether this component owns arrow-key traversal and the roving tabindex
80
- * over its rows. On by default so a consumer that only passes rows (the
81
- * Storybook prototype) still has a keyboard.
82
- *
83
- * The web client turns it off: its triage layer routes ↑/↓/Home/End through
84
- * one window-level dispatcher, and a container handler here would swallow
85
- * them first — taking Shift+↑/↓ with them, which the dispatcher needs for
86
- * range selection and `rovingNextIndex` ignores.
87
- */
88
- manageFocus?: boolean;
89
78
  }
90
79
 
91
80
  /**
@@ -107,7 +96,6 @@ export function BriefSections({
107
96
  sourcesNote,
108
97
  onSelectSource,
109
98
  defaultExpanded = false,
110
- manageFocus = true,
111
99
  }: BriefSectionsProps) {
112
100
  const [active, setActive] = useState<ReadonlySet<BriefFilterId>>(new Set());
113
101
  const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
@@ -115,7 +103,6 @@ export function BriefSections({
115
103
  useRovingFocus({
116
104
  containerRef: listRef,
117
105
  itemSelector: LIST_ROW_SELECTOR,
118
- enabled: manageFocus,
119
106
  });
120
107
 
121
108
  const toggleFilter = (id: BriefFilterId) => {
@@ -5,8 +5,6 @@ export interface DialogProps {
5
5
  open: boolean;
6
6
  onClose: () => void;
7
7
  title: string;
8
- /** Unique ID for aria-labelledby. Defaults to "dialog-title". */
9
- titleId?: string;
10
8
  children?: ReactNode;
11
9
  className?: string;
12
10
  /**
@@ -22,7 +20,6 @@ export function Dialog({
22
20
  open,
23
21
  onClose,
24
22
  title,
25
- titleId = "dialog-title",
26
23
  children,
27
24
  className,
28
25
  anchor = "center",
@@ -86,7 +83,7 @@ export function Dialog({
86
83
  ref={dialogRef}
87
84
  role="dialog"
88
85
  aria-modal="true"
89
- aria-labelledby={titleId}
86
+ aria-labelledby="dialog-title"
90
87
  className={cn(
91
88
  "relative z-10 overflow-hidden border-line bg-surface shadow-xl",
92
89
  isLeft
@@ -99,7 +96,7 @@ export function Dialog({
99
96
  onClick={(e) => e.stopPropagation()}
100
97
  onKeyDown={(e) => e.stopPropagation()}
101
98
  >
102
- <h2 id={titleId} className="sr-only">
99
+ <h2 id="dialog-title" className="sr-only">
103
100
  {title}
104
101
  </h2>
105
102
  {children}
@@ -7,9 +7,7 @@ import {
7
7
  useState,
8
8
  } from "react";
9
9
  import { isAbortError } from "../lib/abort.js";
10
- import { BottomSheet } from "./bottom-sheet.js";
11
10
  import { Button } from "./button.js";
12
- import { Dialog } from "./dialog.js";
13
11
  import {
14
12
  AddChipButton,
15
13
  ClauseChip,
@@ -699,44 +697,3 @@ export function FilterRuleEditor({
699
697
  </div>
700
698
  );
701
699
  }
702
-
703
- export interface FilterRuleDialogProps extends FilterRuleEditorProps {
704
- open: boolean;
705
- onClose: () => void;
706
- }
707
-
708
- /** Desktop home for the rule editor — the centered modal (RFC 038 D1). */
709
- export function FilterRuleDialog({
710
- open,
711
- onClose,
712
- ...editor
713
- }: FilterRuleDialogProps) {
714
- if (!open) return null;
715
- return (
716
- <Dialog open={open} onClose={onClose} title="Filter rule">
717
- <FilterRuleEditor {...editor} onCancel={onClose} />
718
- </Dialog>
719
- );
720
- }
721
-
722
- export interface FilterRuleSheetProps extends FilterRuleEditorProps {
723
- open: boolean;
724
- onClose: () => void;
725
- }
726
-
727
- /** Mobile home for the rule editor — the bottom sheet (RFC 038 D1). */
728
- export function FilterRuleSheet({
729
- open,
730
- onClose,
731
- ...editor
732
- }: FilterRuleSheetProps): ReactNode {
733
- return (
734
- <BottomSheet
735
- open={open}
736
- onClose={onClose}
737
- dismissLabel="Dismiss filter rule"
738
- >
739
- <FilterRuleEditor {...editor} onCancel={onClose} />
740
- </BottomSheet>
741
- );
742
- }
@@ -31,10 +31,8 @@ import {
31
31
  widenChipLabel,
32
32
  } from "./filter-rule.js";
33
33
  import {
34
- FilterRuleDialog,
35
34
  FilterRuleEditor,
36
35
  type FilterRuleEditorProps,
37
- FilterRuleSheet,
38
36
  } from "./filter-rule-editor.js";
39
37
 
40
38
  /** SSR splits interpolations with comment markers; sentences read across them. */
@@ -671,50 +669,3 @@ describe("FilterRuleEditor", () => {
671
669
  assert.doesNotMatch(html, /…and similar/);
672
670
  });
673
671
  });
674
-
675
- describe("FilterRuleDialog", () => {
676
- it("renders the editor when open", () => {
677
- const html = render(
678
- createElement(FilterRuleDialog, {
679
- open: true,
680
- onClose: () => {},
681
- rule: demoRule,
682
- folders: FOLDERS,
683
- preview: READY,
684
- }),
685
- );
686
- assert.match(html, /role="dialog"/);
687
- assert.match(html, /Filter rule/);
688
- });
689
-
690
- it("renders nothing when closed", () => {
691
- assert.equal(
692
- renderToString(
693
- createElement(FilterRuleDialog, {
694
- open: false,
695
- onClose: () => {},
696
- rule: demoRule,
697
- folders: FOLDERS,
698
- preview: READY,
699
- }),
700
- ),
701
- "",
702
- );
703
- });
704
- });
705
-
706
- describe("FilterRuleSheet", () => {
707
- it("renders the editor inside a dismissible sheet", () => {
708
- const html = render(
709
- createElement(FilterRuleSheet, {
710
- open: true,
711
- onClose: () => {},
712
- rule: demoRule,
713
- folders: FOLDERS,
714
- preview: READY,
715
- }),
716
- );
717
- assert.match(html, /Dismiss filter rule/);
718
- assert.match(html, /Filter rule/);
719
- });
720
- });
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { type ReactNode, useState } from "react";
3
3
  import type { StepId } from "../lib/wizard-steps.js";
4
4
  import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
5
- import { FooterNav, WizardScreen } from "./selection-wizard.js";
5
+ import { FolderStepBody, FooterNav, WizardScreen } from "./selection-wizard.js";
6
6
 
7
7
  const folders: FolderTreeNode[] = [
8
8
  { id: "mbx-inbox", label: "Inbox", path: "INBOX", isCurrent: true },
@@ -332,30 +332,17 @@ function WizardFolderStep() {
332
332
  />
333
333
  }
334
334
  >
335
- <div className="flex min-h-0 flex-col gap-3">
336
- <p className="px-1 text-xs text-fg-muted">
337
- {chosen
338
- ? `Moving to ${chosen.label}.`
339
- : "Tap a folder to open it, or make a new one where you want it."}
340
- </p>
341
- <div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-line bg-surface">
342
- <FolderTreePicker
343
- folders={known}
344
- selectedId={selected}
345
- onSelect={setSelected}
346
- onCreateFolder={(name, parentPath) =>
347
- createFolder(name, parentPath).then((created) => {
348
- setKnown((current) => [...current, created]);
349
- return created;
350
- })
351
- }
352
- labels={{
353
- createPending:
354
- "Waiting for the mail server to confirm the folder…",
355
- }}
356
- />
357
- </div>
358
- </div>
335
+ <FolderStepBody
336
+ folders={known}
337
+ mailboxId={selected}
338
+ onSelect={setSelected}
339
+ onCreateFolder={(name, parentPath) =>
340
+ createFolder(name, parentPath).then((created) => {
341
+ setKnown((current) => [...current, created]);
342
+ return created;
343
+ })
344
+ }
345
+ />
359
346
  </WizardScreen>
360
347
  );
361
348
  }
@@ -6,7 +6,6 @@ import { Button } from "./button.js";
6
6
  import { Card, CardBody, CardHeader, CardTitle } from "./card.js";
7
7
  import { FieldLabel } from "./field-label.js";
8
8
  import { Input } from "./input.js";
9
- import { ListItem } from "./list-item.js";
10
9
  import { PasswordInput } from "./password-input.js";
11
10
  import { SecuritySelect } from "./security-select.js";
12
11
  import { Select } from "./select.js";
@@ -176,32 +175,3 @@ export const Cards: Story = {
176
175
  </div>
177
176
  ),
178
177
  };
179
-
180
- export const ListItems: Story = {
181
- render: () => (
182
- <div className="max-w-md divide-y divide-line p-8">
183
- <ListItem
184
- unread
185
- leading={<Avatar name="Priya Natarajan" size="md" />}
186
- trailing="08:52"
187
- >
188
- <div className="text-sm font-semibold text-fg">Priya Natarajan</div>
189
- <div className="text-sm text-fg">Q3 roadmap review</div>
190
- <p className="line-clamp-1 text-xs text-fg-subtle">
191
- Sharing the agenda ahead of Thursday…
192
- </p>
193
- </ListItem>
194
- <ListItem
195
- active
196
- leading={<Avatar name="Marcus Webb" size="md" />}
197
- trailing="Wed"
198
- >
199
- <div className="text-sm font-medium text-fg-muted">Marcus Webb</div>
200
- <div className="text-sm text-fg-muted">Re: Reading pane density</div>
201
- <p className="line-clamp-1 text-xs text-fg-subtle">
202
- Strong +1 on tightening the rows…
203
- </p>
204
- </ListItem>
205
- </div>
206
- ),
207
- };
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
- import type { MoveMailboxOption } from "./move-mailbox-picker.js";
5
+ import type { FolderTreeNode } from "./folder-tree-picker.js";
6
6
  import type { RescueCandidate } from "./rescue-candidate-row.js";
7
7
  import {
8
8
  RescueFromSpamFlow,
@@ -34,9 +34,9 @@ const candidates: RescueCandidate[] = [
34
34
  },
35
35
  ];
36
36
 
37
- const folders: MoveMailboxOption[] = [
38
- { id: "inbox", label: "Inbox" },
39
- { id: "spam", label: "Spam", isCurrent: true },
37
+ const folders: FolderTreeNode[] = [
38
+ { id: "inbox", label: "Inbox", path: "INBOX" },
39
+ { id: "spam", label: "Spam", path: "Junk", isCurrent: true },
40
40
  ];
41
41
 
42
42
  const renderFlow = (open: boolean): string =>
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { useState } from "react";
3
3
  import { Button } from "./button.js";
4
- import type { MoveMailboxOption } from "./move-mailbox-picker.js";
4
+ import type { FolderTreeNode } from "./folder-tree-picker.js";
5
5
  import type { RescueCandidate } from "./rescue-candidate-row.js";
6
6
  import { RescueFromSpamFlow } from "./rescue-from-spam-flow.js";
7
7
 
@@ -54,12 +54,14 @@ const CANDIDATES: RescueCandidate[] = [
54
54
  },
55
55
  ];
56
56
 
57
- const FOLDERS: MoveMailboxOption[] = [
58
- { id: "inbox", label: "Inbox" },
59
- { id: "spam", label: "Spam", isCurrent: true },
60
- { id: "receipts", label: "Receipts" },
61
- { id: "family", label: "Family" },
62
- { id: "work", label: "Work", searchValue: "work clients projects" },
57
+ const FOLDERS: FolderTreeNode[] = [
58
+ { id: "inbox", label: "Inbox", path: "INBOX" },
59
+ { id: "spam", label: "Spam", path: "Junk", isCurrent: true },
60
+ { id: "receipts", label: "Receipts", path: "Receipts" },
61
+ { id: "family", label: "Family", path: "Family" },
62
+ { id: "work", label: "Work", path: "Work" },
63
+ { id: "work-clients", label: "Clients", path: "Work/Clients" },
64
+ { id: "work-projects", label: "Projects", path: "Work/Projects" },
63
65
  ];
64
66
 
65
67
  function Demo() {
@@ -3,10 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Banner } from "./banner.js";
4
4
  import { BottomSheet } from "./bottom-sheet.js";
5
5
  import { Button } from "./button.js";
6
- import {
7
- type MoveMailboxOption,
8
- MoveMailboxPicker,
9
- } from "./move-mailbox-picker.js";
6
+ import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
10
7
  import type { RescueCandidate } from "./rescue-candidate-row.js";
11
8
  import {
12
9
  groupRescueCandidatesBySender,
@@ -23,7 +20,9 @@ export interface RescueFromSpamFlowProps {
23
20
  /** Where the move lands unless the user picks another folder. */
24
21
  defaultDestinationId: string;
25
22
  /** Folders the user can send the rescued mail to. */
26
- availableFolders: MoveMailboxOption[];
23
+ availableFolders: FolderTreeNode[];
24
+ /** The account's hierarchy separator, which the tree nests on. */
25
+ delimiter?: string;
27
26
  /** Run the move. The flow shows the done state right after. */
28
27
  onConfirmMove: (messageIds: string[], destinationId: string) => void;
29
28
  /** Close the sheet without moving anything. */
@@ -42,7 +41,7 @@ export const rescueMoveConsequence = (
42
41
  ): string =>
43
42
  `Moves ${count === 1 ? "this message" : `these ${count} ${plural(count)}`} out of Spam to ${destinationLabel} now. Nothing later.`;
44
43
 
45
- const folderLabel = (folders: MoveMailboxOption[], id: string): string =>
44
+ const folderLabel = (folders: FolderTreeNode[], id: string): string =>
46
45
  folders.find((f) => f.id === id)?.label ?? "Inbox";
47
46
 
48
47
  function ExplainWhy() {
@@ -157,13 +156,15 @@ function ReviewStep({
157
156
  function DestinationStep({
158
157
  count,
159
158
  folders,
159
+ delimiter,
160
160
  destination,
161
161
  onPick,
162
162
  onMove,
163
163
  onBack,
164
164
  }: {
165
165
  count: number;
166
- folders: MoveMailboxOption[];
166
+ folders: FolderTreeNode[];
167
+ delimiter?: string;
167
168
  destination: string;
168
169
  onPick: (id: string) => void;
169
170
  onMove: () => void;
@@ -180,16 +181,17 @@ function DestinationStep({
180
181
  </p>
181
182
  </div>
182
183
 
183
- <div className="min-h-0 flex-1 overflow-y-auto px-row-inset py-2">
184
+ <div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-row-inset py-2">
184
185
  {picking ? (
185
- <div className="overflow-hidden rounded-xl border border-line bg-surface">
186
- <MoveMailboxPicker
187
- mailboxes={folders}
188
- autoFocus
189
- onSelect={(id) => {
190
- onPick(id);
191
- setPicking(false);
192
- }}
186
+ <div className="flex min-h-0 flex-1 overflow-hidden rounded-xl border border-line bg-surface">
187
+ {/* Picking a folder also opens it, so the tree stays up: closing
188
+ on the first tap would put anything nested out of reach. The
189
+ consequence line below states the destination as it changes. */}
190
+ <FolderTreePicker
191
+ folders={folders}
192
+ selectedId={destination}
193
+ delimiter={delimiter}
194
+ onSelect={onPick}
193
195
  onCancel={() => setPicking(false)}
194
196
  />
195
197
  </div>
@@ -277,6 +279,7 @@ export function RescueFromSpamFlow({
277
279
  candidates,
278
280
  defaultDestinationId,
279
281
  availableFolders,
282
+ delimiter,
280
283
  onConfirmMove,
281
284
  onCancel,
282
285
  }: RescueFromSpamFlowProps) {
@@ -345,6 +348,7 @@ export function RescueFromSpamFlow({
345
348
  <DestinationStep
346
349
  count={selectedCount}
347
350
  folders={availableFolders}
351
+ delimiter={delimiter}
348
352
  destination={destination}
349
353
  onPick={setDestination}
350
354
  onMove={move}
@@ -65,8 +65,6 @@ export interface SearchChipInputProps {
65
65
  */
66
66
  chips?: readonly SearchChip[];
67
67
  onRemoveChip?: (id: string) => void;
68
- /** Opens a chip's own value editor, where the host offers one. */
69
- onActivateChip?: (id: string) => void;
70
68
  /** The free text alongside the chips. */
71
69
  value: string;
72
70
  onChange: (value: string) => void;
@@ -105,8 +103,6 @@ export interface SearchChipInputProps {
105
103
  * when something outside needs to address the field by a stable id.
106
104
  */
107
105
  inputId?: string;
108
- /** Accessible name for the chip grid. */
109
- chipsLabel?: string;
110
106
  /**
111
107
  * Completions for what is being typed. Omit for a field that offers none;
112
108
  * see {@link SearchFieldSuggest} for what the field and the host each own.
@@ -165,7 +161,6 @@ const isEditableTarget = (target: EventTarget | null): boolean => {
165
161
  export const SearchChipInput = ({
166
162
  chips = [],
167
163
  onRemoveChip,
168
- onActivateChip,
169
164
  value,
170
165
  onChange,
171
166
  onClear,
@@ -175,7 +170,6 @@ export const SearchChipInput = ({
175
170
  showClearButton = true,
176
171
  size = "sm",
177
172
  inputId,
178
- chipsLabel = "Search filters",
179
173
  suggest,
180
174
  className,
181
175
  }: SearchChipInputProps) => {
@@ -313,16 +307,13 @@ export const SearchChipInput = ({
313
307
  case "focusInput":
314
308
  moveFocus(null);
315
309
  return;
316
- case "activateChip": {
317
- const chip = chips[action.index];
318
- if (chip && onActivateChip) onActivateChip(chip.id);
310
+ case "activateChip":
319
311
  return;
320
- }
321
312
  case "none":
322
313
  return;
323
314
  }
324
315
  },
325
- [chips, removeChipAt, moveFocus, onActivateChip],
316
+ [chips, removeChipAt, moveFocus],
326
317
  );
327
318
 
328
319
  useEffect(() => {
@@ -371,7 +362,7 @@ export const SearchChipInput = ({
371
362
  // biome-ignore lint/a11y/useSemanticElements: see above
372
363
  <div
373
364
  role="grid"
374
- aria-label={chipsLabel}
365
+ aria-label="Search filters"
375
366
  className="flex min-w-0 flex-wrap items-center gap-1"
376
367
  >
377
368
  {chips.map((chip, index) => (
@@ -386,9 +377,6 @@ export const SearchChipInput = ({
386
377
  onFocusLabel={() => setFocusedChip(index)}
387
378
  onKeyDown={handleChipKeyDown(index)}
388
379
  onRemove={() => removeChipAt(index)}
389
- onActivate={
390
- onActivateChip ? () => onActivateChip(chip.id) : undefined
391
- }
392
380
  />
393
381
  ))}
394
382
  </div>
@@ -76,14 +76,16 @@ const propertiesProps = {
76
76
  };
77
77
 
78
78
  const folderProps = {
79
- mailboxes: [
80
- { id: "mbx-archive", label: "Archive" },
81
- { id: "mbx-travel", label: "Travel" },
82
- { id: "mbx-inbox", label: "Inbox", isCurrent: true },
79
+ folders: [
80
+ { id: "mbx-archive", label: "Archive", path: "Archive" },
81
+ { id: "mbx-travel", label: "Travel", path: "Travel" },
82
+ { id: "mbx-travel-2026", label: "2026", path: "Travel/2026" },
83
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX", isCurrent: true },
83
84
  ],
84
85
  mailboxId: "mbx-travel",
85
86
  onSelect: noop,
86
- onCreateFolder: () => Promise.resolve({ id: "mbx-new", label: "Hotels" }),
87
+ onCreateFolder: () =>
88
+ Promise.resolve({ id: "mbx-new", label: "Hotels", path: "Travel/Hotels" }),
87
89
  };
88
90
 
89
91
  const draft = (over: Partial<WizardDraft> = {}): WizardDraft => ({
@@ -359,11 +361,16 @@ describe("FolderStepBody", () => {
359
361
  assert.match(html, /Inbox \(current folder\)/);
360
362
  });
361
363
 
362
- it("says how to make a folder when none is chosen yet", () => {
364
+ it("starts at the top level, with nested folders behind the one holding them", () => {
365
+ const html = renderToString(createElement(FolderStepBody, folderProps));
366
+ assert.doesNotMatch(html, /Move to 2026/);
367
+ });
368
+
369
+ it("says how to reach a folder when none is chosen yet", () => {
363
370
  const html = renderToString(
364
371
  createElement(FolderStepBody, { ...folderProps, mailboxId: undefined }),
365
372
  );
366
- assert.match(text(html), /type a name that doesn&#x27;t exist yet/);
373
+ assert.match(text(html), /Tap a folder to open it/);
367
374
  });
368
375
  });
369
376
 
@@ -69,11 +69,8 @@ import {
69
69
  scopeLabel,
70
70
  } from "./filter-rule.js";
71
71
  import type { ClauseEditState } from "./filter-rule-editor.js";
72
+ import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
72
73
  import { Input } from "./input.js";
73
- import {
74
- type MoveMailboxOption,
75
- MoveMailboxPicker,
76
- } from "./move-mailbox-picker.js";
77
74
  import { ProgressBar } from "./progress-bar.js";
78
75
  import type { SearchConversionNotice } from "./search-conversion.js";
79
76
  import { SearchConversionNoticeView } from "./search-conversion-notice.js";
@@ -354,7 +351,6 @@ export function SelectionSample({
354
351
  }
355
352
 
356
353
  export interface FooterNavProps {
357
- backLabel?: string;
358
354
  onBack: () => void;
359
355
  nextLabel: string;
360
356
  onNext: () => void;
@@ -372,7 +368,6 @@ export interface FooterNavProps {
372
368
  * carries the two ways that reason reaches the user.
373
369
  */
374
370
  export function FooterNav({
375
- backLabel = "Back",
376
371
  onBack,
377
372
  nextLabel,
378
373
  onNext,
@@ -399,7 +394,7 @@ export function FooterNav({
399
394
  icon={<ArrowLeft className="size-4" />}
400
395
  className="shrink-0"
401
396
  >
402
- {backLabel}
397
+ Back
403
398
  </Button>
404
399
  <Button
405
400
  variant={nextVariant}
@@ -651,7 +646,7 @@ export interface FolderStepProps {
651
646
  * (`buildMoveTargets`). The wizard does not reorder them: a second ordering
652
647
  * beside the one the Move picker already applies would fight it.
653
648
  */
654
- mailboxes: readonly MoveMailboxOption[];
649
+ folders: readonly FolderTreeNode[];
655
650
  /** The destination chosen so far. */
656
651
  mailboxId?: string;
657
652
  onSelect: (mailboxId: string) => void;
@@ -664,8 +659,11 @@ export interface FolderStepProps {
664
659
  */
665
660
  onCreateFolder?: (
666
661
  name: string,
662
+ parentPath: string,
667
663
  signal?: AbortSignal,
668
- ) => Promise<MoveMailboxOption>;
664
+ ) => Promise<FolderTreeNode>;
665
+ /** The account's hierarchy separator, which the tree nests on. */
666
+ delimiter?: string;
669
667
  /**
670
668
  * Why there is no folder list to choose from — a selection spanning accounts
671
669
  * has no single account whose folders these could be (#477 5.5). Said here
@@ -675,13 +673,14 @@ export interface FolderStepProps {
675
673
  }
676
674
 
677
675
  export function FolderStepBody({
678
- mailboxes,
676
+ folders,
679
677
  mailboxId,
680
678
  onSelect,
681
679
  onCreateFolder,
680
+ delimiter,
682
681
  restriction,
683
682
  }: FolderStepProps) {
684
- const chosen = mailboxes.find((mailbox) => mailbox.id === mailboxId);
683
+ const chosen = folders.find((folder) => folder.id === mailboxId);
685
684
  return (
686
685
  <div className="flex min-h-0 flex-col gap-3">
687
686
  <p
@@ -693,17 +692,16 @@ export function FolderStepBody({
693
692
  {restriction ??
694
693
  (chosen
695
694
  ? `Moving to ${chosen.label}.`
696
- : "Search for a folder, or type a name that doesn't exist yet to make one.")}
695
+ : "Tap a folder to open it, or make a new one where you want it.")}
697
696
  </p>
698
- <div className="overflow-hidden rounded-lg border border-line bg-surface">
699
- <MoveMailboxPicker
700
- mailboxes={mailboxes}
697
+ <div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-line bg-surface">
698
+ <FolderTreePicker
699
+ folders={folders}
700
+ selectedId={mailboxId}
701
701
  onSelect={onSelect}
702
702
  onCreateFolder={onCreateFolder}
703
- autoFocus
703
+ delimiter={delimiter}
704
704
  labels={{
705
- searchPlaceholder: "Move to…",
706
- optionLabel: (label) => `Move to ${label}`,
707
705
  createPending: "Waiting for the mail server to confirm the folder…",
708
706
  }}
709
707
  />