@remit/ui 0.0.68 → 0.0.70

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,95 @@
1
+ import { X } from "lucide-react";
2
+ import { useId } from "react";
3
+ import { Button } from "./button.js";
4
+ import { Dialog } from "./dialog.js";
5
+ import { FieldLabel } from "./field-label.js";
6
+ import { Input } from "./input.js";
7
+
8
+ export interface FolderRenameDialogProps {
9
+ open: boolean;
10
+ /** The folder as it reads now. */
11
+ folderLabel: string;
12
+ /** What the folder falls back to once the name is cleared. */
13
+ defaultLabel: string;
14
+ name: string;
15
+ onNameChange: (name: string) => void;
16
+ onSubmit: () => void;
17
+ onClose: () => void;
18
+ pending?: boolean;
19
+ error?: string;
20
+ }
21
+
22
+ /**
23
+ * Renames a folder for this account. The name is Remit's own — the folder keeps
24
+ * its place and its path on the mail server — so clearing it puts the folder
25
+ * back to what the server calls it.
26
+ */
27
+ export const FolderRenameDialog = ({
28
+ open,
29
+ folderLabel,
30
+ defaultLabel,
31
+ name,
32
+ onNameChange,
33
+ onSubmit,
34
+ onClose,
35
+ pending = false,
36
+ error,
37
+ }: FolderRenameDialogProps) => {
38
+ const fieldId = useId();
39
+ const title = `Rename ${folderLabel}`;
40
+
41
+ if (!open) return null;
42
+
43
+ return (
44
+ <Dialog open={open} onClose={onClose} title={title}>
45
+ <header className="flex items-center gap-2 border-b border-line px-5 py-3">
46
+ <span className="flex-1 text-sm font-semibold text-fg">{title}</span>
47
+ <Button
48
+ variant="ghost"
49
+ size="sm"
50
+ icon={<X className="size-3.5" />}
51
+ onClick={onClose}
52
+ aria-label="Cancel"
53
+ />
54
+ </header>
55
+ <div className="space-y-3 px-5 py-4">
56
+ <div>
57
+ <FieldLabel htmlFor={fieldId}>Folder name</FieldLabel>
58
+ <Input
59
+ id={fieldId}
60
+ value={name}
61
+ placeholder={defaultLabel}
62
+ onChange={(event) => onNameChange(event.target.value)}
63
+ onKeyDown={(event) => {
64
+ if (event.key !== "Enter") return;
65
+ event.preventDefault();
66
+ onSubmit();
67
+ }}
68
+ />
69
+ </div>
70
+ <p className="text-xs text-fg-muted">
71
+ Shown everywhere in Remit. Leave it blank to use{" "}
72
+ <span className="font-medium text-fg">{defaultLabel}</span>.
73
+ </p>
74
+ {error && (
75
+ <p className="text-xs text-danger" role="alert">
76
+ {error}
77
+ </p>
78
+ )}
79
+ </div>
80
+ <footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
81
+ <Button variant="secondary" size="sm" onClick={onClose}>
82
+ Cancel
83
+ </Button>
84
+ <Button
85
+ variant="primary"
86
+ size="sm"
87
+ onClick={onSubmit}
88
+ disabled={pending}
89
+ >
90
+ {pending ? "Saving…" : "Save name"}
91
+ </Button>
92
+ </footer>
93
+ </Dialog>
94
+ );
95
+ };
@@ -74,6 +74,13 @@ describe("FolderRow", () => {
74
74
  assert.doesNotMatch(render({ depth: 1 }), /left:74px/);
75
75
  });
76
76
 
77
+ it("keeps its actions beside the tree item rather than inside it", () => {
78
+ const html = render({
79
+ actions: createElement("button", { type: "button" }, "Delete"),
80
+ });
81
+ assert.ok(html.indexOf(">Delete<") > html.indexOf("</button>"));
82
+ });
83
+
77
84
  it("takes its place in a roving tab order", () => {
78
85
  assert.match(render({ tabIndex: 0 }), /tabindex="0"/);
79
86
  assert.match(render({ tabIndex: -1 }), /tabindex="-1"/);
@@ -1,5 +1,5 @@
1
1
  import { Check, ChevronRight, Folder } from "lucide-react";
2
- import type { Ref } from "react";
2
+ import type { ReactNode, Ref } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
4
 
5
5
  export const FOLDER_ROW_BASE =
@@ -52,6 +52,12 @@ export interface FolderRowProps {
52
52
  currentTag?: string;
53
53
  /** A hairline under the label, drawn for every row but the last. */
54
54
  separated?: boolean;
55
+ /**
56
+ * Controls that operate on the folder rather than open it. They sit beside
57
+ * the row instead of inside it — a button nested in a button is invalid, and
58
+ * a tree item that swallows its own actions cannot be reached by keyboard.
59
+ */
60
+ actions?: ReactNode;
55
61
  tabIndex?: number;
56
62
  onActivate?: () => void;
57
63
  onFocus?: () => void;
@@ -72,6 +78,7 @@ export const FolderRow = ({
72
78
  current = false,
73
79
  currentTag,
74
80
  separated = false,
81
+ actions,
75
82
  tabIndex,
76
83
  onActivate,
77
84
  onFocus,
@@ -106,6 +113,7 @@ export const FolderRow = ({
106
113
  {icon}
107
114
  <span className="min-w-0 flex-1 truncate">{label}</span>
108
115
  </div>
116
+ {actions}
109
117
  {separated && <FolderRowSeparator depth={depth} />}
110
118
  </div>
111
119
  );
@@ -141,6 +149,7 @@ export const FolderRow = ({
141
149
  <Check className="size-4 shrink-0 text-accent" aria-hidden="true" />
142
150
  )}
143
151
  </button>
152
+ {actions}
144
153
  {separated && <FolderRowSeparator depth={depth} />}
145
154
  </div>
146
155
  );
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  import { Search } from "lucide-react";
2
2
  import {
3
3
  type KeyboardEvent as ReactKeyboardEvent,
4
+ type ReactNode,
4
5
  useCallback,
5
6
  useEffect,
6
7
  useMemo,
@@ -72,8 +73,14 @@ export interface FolderTreePickerProps {
72
73
  folders: readonly FolderTreeNode[];
73
74
  /** The destination chosen so far. */
74
75
  selectedId?: string;
75
- /** Marks the row. Choosing a destination advances nothing on its own. */
76
- onSelect: (folderId: string) => void;
76
+ /**
77
+ * Marks the row. Choosing a destination advances nothing on its own. Absent
78
+ * means the tree is browsed rather than chosen from, and a row does nothing
79
+ * beyond opening and closing.
80
+ */
81
+ onSelect?: (folderId: string) => void;
82
+ /** Controls beside each row, for a surface that acts on folders themselves. */
83
+ rowActions?: (folder: FolderTreeNode) => ReactNode;
77
84
  /**
78
85
  * Creating a folder is an IMAP mutation, so this resolves only once the mail
79
86
  * server confirms the folder (docs/architecture/imap-mutations.md). The form
@@ -128,6 +135,7 @@ export const FolderTreePicker = ({
128
135
  folders,
129
136
  selectedId,
130
137
  onSelect,
138
+ rowActions,
131
139
  onCreateFolder,
132
140
  onCancel,
133
141
  delimiter = "/",
@@ -204,7 +212,7 @@ export const FolderTreePicker = ({
204
212
 
205
213
  const activateRow = useCallback(
206
214
  (row: FolderTreeRow) => {
207
- if (isSelectable(row)) onSelect(row.folder.id);
215
+ if (isSelectable(row)) onSelect?.(row.folder.id);
208
216
  setExpanded(row.folder.path, !row.expanded);
209
217
  },
210
218
  [onSelect, setExpanded],
@@ -252,7 +260,7 @@ export const FolderTreePicker = ({
252
260
  setDraft(null);
253
261
  setDraftName("");
254
262
  if (parentPath) setExpanded(parentPath, true);
255
- onSelect(created.id);
263
+ onSelect?.(created.id);
256
264
  })
257
265
  .catch((error: unknown) => {
258
266
  if (isAbortError(error)) return;
@@ -367,6 +375,7 @@ export const FolderTreePicker = ({
367
375
  currentTag={text.currentTag}
368
376
  selected={selectable && folder.id === selectedId}
369
377
  separated={separated}
378
+ actions={rowActions?.(folder)}
370
379
  ariaLabel={rowAriaLabel(row)}
371
380
  tabIndex={index === focusedIndex ? 0 : -1}
372
381
  onActivate={() => activateRow(row)}
@@ -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}
@@ -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";
@@ -651,7 +648,7 @@ export interface FolderStepProps {
651
648
  * (`buildMoveTargets`). The wizard does not reorder them: a second ordering
652
649
  * beside the one the Move picker already applies would fight it.
653
650
  */
654
- mailboxes: readonly MoveMailboxOption[];
651
+ folders: readonly FolderTreeNode[];
655
652
  /** The destination chosen so far. */
656
653
  mailboxId?: string;
657
654
  onSelect: (mailboxId: string) => void;
@@ -664,8 +661,11 @@ export interface FolderStepProps {
664
661
  */
665
662
  onCreateFolder?: (
666
663
  name: string,
664
+ parentPath: string,
667
665
  signal?: AbortSignal,
668
- ) => Promise<MoveMailboxOption>;
666
+ ) => Promise<FolderTreeNode>;
667
+ /** The account's hierarchy separator, which the tree nests on. */
668
+ delimiter?: string;
669
669
  /**
670
670
  * Why there is no folder list to choose from — a selection spanning accounts
671
671
  * has no single account whose folders these could be (#477 5.5). Said here
@@ -675,13 +675,14 @@ export interface FolderStepProps {
675
675
  }
676
676
 
677
677
  export function FolderStepBody({
678
- mailboxes,
678
+ folders,
679
679
  mailboxId,
680
680
  onSelect,
681
681
  onCreateFolder,
682
+ delimiter,
682
683
  restriction,
683
684
  }: FolderStepProps) {
684
- const chosen = mailboxes.find((mailbox) => mailbox.id === mailboxId);
685
+ const chosen = folders.find((folder) => folder.id === mailboxId);
685
686
  return (
686
687
  <div className="flex min-h-0 flex-col gap-3">
687
688
  <p
@@ -693,17 +694,16 @@ export function FolderStepBody({
693
694
  {restriction ??
694
695
  (chosen
695
696
  ? `Moving to ${chosen.label}.`
696
- : "Search for a folder, or type a name that doesn't exist yet to make one.")}
697
+ : "Tap a folder to open it, or make a new one where you want it.")}
697
698
  </p>
698
- <div className="overflow-hidden rounded-lg border border-line bg-surface">
699
- <MoveMailboxPicker
700
- mailboxes={mailboxes}
699
+ <div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-line bg-surface">
700
+ <FolderTreePicker
701
+ folders={folders}
702
+ selectedId={mailboxId}
701
703
  onSelect={onSelect}
702
704
  onCreateFolder={onCreateFolder}
703
- autoFocus
705
+ delimiter={delimiter}
704
706
  labels={{
705
- searchPlaceholder: "Move to…",
706
- optionLabel: (label) => `Move to ${label}`,
707
707
  createPending: "Waiting for the mail server to confirm the folder…",
708
708
  }}
709
709
  />
package/src/index.ts CHANGED
@@ -198,6 +198,20 @@ export {
198
198
  type FilterSheetProps,
199
199
  type FilterSheetSource,
200
200
  } from "./components/filter-sheet.js";
201
+ export {
202
+ FolderManageActions,
203
+ type FolderManageActionsProps,
204
+ } from "./components/folder-manage-actions.js";
205
+ export {
206
+ FolderManager,
207
+ type FolderManagerLabels,
208
+ type FolderManagerProps,
209
+ type ManagedFolder,
210
+ } from "./components/folder-manager.js";
211
+ export {
212
+ FolderRenameDialog,
213
+ type FolderRenameDialogProps,
214
+ } from "./components/folder-rename-dialog.js";
201
215
  export {
202
216
  canonicalRoleLabel,
203
217
  type FolderRole,
@@ -316,12 +330,6 @@ export {
316
330
  MobileSearchView,
317
331
  type MobileSearchViewProps,
318
332
  } from "./components/mobile-search-view.js";
319
- export {
320
- type MoveMailboxOption,
321
- MoveMailboxPicker,
322
- type MoveMailboxPickerLabels,
323
- type MoveMailboxPickerProps,
324
- } from "./components/move-mailbox-picker.js";
325
333
  export {
326
334
  NavSidebar,
327
335
  type NavSidebarProps,