@remit/ui 0.0.68 → 0.0.69

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.68",
3
+ "version": "0.0.69",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import {
6
+ FolderManageActions,
7
+ type FolderManageActionsProps,
8
+ } from "./folder-manage-actions.js";
9
+
10
+ const render = (props: Partial<FolderManageActionsProps>) =>
11
+ renderToString(
12
+ createElement(FolderManageActions, {
13
+ label: "Travel",
14
+ onRename: () => undefined,
15
+ onDelete: () => undefined,
16
+ ...props,
17
+ }),
18
+ );
19
+
20
+ describe("FolderManageActions", () => {
21
+ it("names both controls after the folder they act on", () => {
22
+ const html = render({});
23
+ assert.match(html, /aria-label="Rename Travel"/);
24
+ assert.match(html, /aria-label="Delete Travel"/);
25
+ });
26
+
27
+ it("leaves a deletable folder's delete pressable", () => {
28
+ assert.doesNotMatch(render({}), /disabled=""/);
29
+ });
30
+
31
+ it("states why a folder stays, in the control's own name", () => {
32
+ const html = render({
33
+ deleteBlockedReason: "The inbox can't be deleted.",
34
+ });
35
+ assert.match(
36
+ html,
37
+ /aria-label="Delete Travel — The inbox can&#x27;t be deleted."/,
38
+ );
39
+ assert.match(html, /disabled=""/);
40
+ assert.match(html, /title="The inbox can&#x27;t be deleted."/);
41
+ });
42
+
43
+ it("takes the surface's own wording for each control", () => {
44
+ const html = render({
45
+ renameLabel: (label) => `Hernoem ${label}`,
46
+ deleteLabel: (label) => `Verwijder ${label}`,
47
+ });
48
+ assert.match(html, /aria-label="Hernoem Travel"/);
49
+ assert.match(html, /aria-label="Verwijder Travel"/);
50
+ });
51
+ });
@@ -0,0 +1,94 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { ReactNode } from "react";
3
+ import { FolderManageActions } from "./folder-manage-actions.js";
4
+ import { FolderRow } from "./folder-row.js";
5
+
6
+ const meta: Meta<typeof FolderManageActions> = {
7
+ title: "Mail/FolderManageActions",
8
+ component: FolderManageActions,
9
+ parameters: { layout: "centered" },
10
+ };
11
+ export default meta;
12
+
13
+ type Story = StoryObj<typeof FolderManageActions>;
14
+
15
+ function List({ children }: { children: ReactNode }) {
16
+ return (
17
+ <div className="w-[360px] overflow-hidden rounded-lg border border-line bg-surface font-sans text-fg">
18
+ {children}
19
+ </div>
20
+ );
21
+ }
22
+
23
+ export const OnARow: Story = {
24
+ name: "Beside the folder it acts on",
25
+ render: () => (
26
+ <List>
27
+ <FolderRow
28
+ label="Travel"
29
+ depth={0}
30
+ expanded={false}
31
+ ariaLabel="Travel"
32
+ separated
33
+ actions={
34
+ <FolderManageActions
35
+ label="Travel"
36
+ onRename={() => {}}
37
+ onDelete={() => {}}
38
+ />
39
+ }
40
+ />
41
+ <FolderRow
42
+ label="Hotels"
43
+ depth={1}
44
+ expanded={false}
45
+ ariaLabel="Hotels"
46
+ actions={
47
+ <FolderManageActions
48
+ label="Hotels"
49
+ onRename={() => {}}
50
+ onDelete={() => {}}
51
+ />
52
+ }
53
+ />
54
+ </List>
55
+ ),
56
+ };
57
+
58
+ /** A folder the account depends on keeps rename and states why it stays. */
59
+ export const Blocked: Story = {
60
+ name: "A folder that can't be deleted",
61
+ render: () => (
62
+ <List>
63
+ <FolderRow
64
+ label="Inbox"
65
+ depth={0}
66
+ expanded={false}
67
+ ariaLabel="Inbox"
68
+ separated
69
+ actions={
70
+ <FolderManageActions
71
+ label="Inbox"
72
+ deleteBlockedReason="The inbox can't be deleted."
73
+ onRename={() => {}}
74
+ onDelete={() => {}}
75
+ />
76
+ }
77
+ />
78
+ <FolderRow
79
+ label="Trash"
80
+ depth={0}
81
+ expanded={false}
82
+ ariaLabel="Trash"
83
+ actions={
84
+ <FolderManageActions
85
+ label="Trash"
86
+ deleteBlockedReason="This folder is your Trash folder. Reassign that role before deleting it."
87
+ onRename={() => {}}
88
+ onDelete={() => {}}
89
+ />
90
+ }
91
+ />
92
+ </List>
93
+ ),
94
+ };
@@ -0,0 +1,54 @@
1
+ import { Pencil, Trash2 } from "lucide-react";
2
+ import { Button } from "./button.js";
3
+
4
+ export interface FolderManageActionsProps {
5
+ /** The folder as it reads on the row, so each control names its own target. */
6
+ label: string;
7
+ onRename: () => void;
8
+ onDelete: () => void;
9
+ /** Why this folder can't be deleted; absent means it can. */
10
+ deleteBlockedReason?: string;
11
+ renameLabel?: (label: string) => string;
12
+ deleteLabel?: (label: string) => string;
13
+ }
14
+
15
+ /**
16
+ * What a folder row offers beyond opening: rename it, or delete it. A folder
17
+ * the account depends on states why it stays — as the control's own name, so
18
+ * the reason is announced rather than only hovered.
19
+ */
20
+ export const FolderManageActions = ({
21
+ label,
22
+ onRename,
23
+ onDelete,
24
+ deleteBlockedReason,
25
+ renameLabel = (name) => `Rename ${name}`,
26
+ deleteLabel = (name) => `Delete ${name}`,
27
+ }: FolderManageActionsProps) => {
28
+ const deleteName = deleteBlockedReason
29
+ ? `${deleteLabel(label)} — ${deleteBlockedReason}`
30
+ : deleteLabel(label);
31
+ return (
32
+ <span className="flex shrink-0 items-center gap-0.5 pr-2">
33
+ <Button
34
+ variant="ghost"
35
+ size="sm"
36
+ icon={<Pencil className="size-3.5" />}
37
+ aria-label={renameLabel(label)}
38
+ onClick={onRename}
39
+ />
40
+ {/* The title sits on the wrapper: a disabled button takes no pointer
41
+ events, so a tooltip on it never shows. */}
42
+ <span title={deleteBlockedReason}>
43
+ <Button
44
+ variant="ghost"
45
+ size="sm"
46
+ icon={<Trash2 className="size-3.5" />}
47
+ aria-label={deleteName}
48
+ disabled={deleteBlockedReason !== undefined}
49
+ onClick={onDelete}
50
+ />
51
+ </span>
52
+ </span>
53
+ );
54
+ };
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { FolderManager, type ManagedFolder } from "./folder-manager.js";
6
+
7
+ const folders: ManagedFolder[] = [
8
+ {
9
+ id: "inbox",
10
+ label: "Inbox",
11
+ path: "INBOX",
12
+ deleteBlockedReason: "The inbox can't be deleted.",
13
+ },
14
+ { id: "travel", label: "Travel", path: "Travel" },
15
+ { id: "hotels", label: "Hotels", path: "Travel/Hotels" },
16
+ { id: "trash", label: "Trash", path: "Deleted Messages" },
17
+ ];
18
+
19
+ const render = (props: Partial<Parameters<typeof FolderManager>[0]> = {}) =>
20
+ renderToString(
21
+ createElement(FolderManager, {
22
+ folders,
23
+ onRename: () => undefined,
24
+ onDelete: () => undefined,
25
+ ...props,
26
+ }),
27
+ );
28
+
29
+ describe("FolderManager", () => {
30
+ it("browses the folders as a tree that starts at its top level", () => {
31
+ const html = render();
32
+ assert.match(html, /role="tree"/);
33
+ assert.equal(html.match(/role="treeitem"/g)?.length, 3);
34
+ assert.doesNotMatch(html, /aria-label="Hotels"/);
35
+ });
36
+
37
+ it("reads a row as the folder itself, not as a destination", () => {
38
+ const html = render();
39
+ assert.match(html, /aria-label="Travel"/);
40
+ assert.doesNotMatch(html, /Move to/);
41
+ });
42
+
43
+ it("labels a folder by its role while nesting it by its real path", () => {
44
+ const html = render();
45
+ assert.match(html, /aria-label="Trash"/);
46
+ assert.doesNotMatch(html, />Deleted Messages</);
47
+ });
48
+
49
+ it("offers rename and delete on every row", () => {
50
+ const html = render();
51
+ assert.match(html, /aria-label="Rename Travel"/);
52
+ assert.match(html, /aria-label="Delete Travel"/);
53
+ });
54
+
55
+ it("carries a folder's reason for staying onto its delete control", () => {
56
+ assert.match(
57
+ render(),
58
+ /aria-label="Delete Inbox — The inbox can&#x27;t be deleted."/,
59
+ );
60
+ });
61
+
62
+ it("offers no create affordance without onCreateFolder", () => {
63
+ assert.doesNotMatch(render(), /New folder/);
64
+ });
65
+
66
+ it("makes a folder where the user is looking once creating is wired", () => {
67
+ const html = render({
68
+ onCreateFolder: () =>
69
+ Promise.resolve({ id: "made", label: "Made", path: "Made" }),
70
+ });
71
+ assert.match(html, /aria-label="New folder"/);
72
+ });
73
+
74
+ it("takes the surface's own tree name", () => {
75
+ assert.match(
76
+ render({ labels: { treeAriaLabel: "All folders for a@b.example" } }),
77
+ /aria-label="All folders for a@b.example"/,
78
+ );
79
+ });
80
+ });
@@ -0,0 +1,184 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { type ReactNode, useState } from "react";
3
+ import type { FolderTreeNode } from "../lib/folder-tree.js";
4
+ import { FolderManager, type ManagedFolder } from "./folder-manager.js";
5
+ import { FolderRenameDialog } from "./folder-rename-dialog.js";
6
+
7
+ const folders: ManagedFolder[] = [
8
+ {
9
+ id: "mbx-inbox",
10
+ label: "Inbox",
11
+ path: "INBOX",
12
+ deleteBlockedReason: "The inbox can't be deleted.",
13
+ },
14
+ {
15
+ id: "mbx-archive",
16
+ label: "Archive",
17
+ path: "Archive",
18
+ deleteBlockedReason:
19
+ "This folder is your Archive folder. Reassign that role before deleting it.",
20
+ },
21
+ {
22
+ id: "mbx-trash",
23
+ label: "Trash",
24
+ path: "Deleted Messages",
25
+ deleteBlockedReason:
26
+ "This folder is your Trash folder. Reassign that role before deleting it.",
27
+ },
28
+ {
29
+ id: "mbx-travel",
30
+ label: "Travel",
31
+ path: "Travel",
32
+ deleteBlockedReason: "This folder has subfolders. Delete them first.",
33
+ },
34
+ { id: "mbx-travel-flights", label: "Flights", path: "Travel/Flights" },
35
+ { id: "mbx-travel-hotels", label: "Hotels", path: "Travel/Hotels" },
36
+ {
37
+ id: "mbx-travel-hotels-receipts",
38
+ label: "Receipts",
39
+ path: "Travel/Hotels/Receipts",
40
+ },
41
+ {
42
+ id: "mbx-finance",
43
+ label: "Finance",
44
+ path: "Finance",
45
+ deleteBlockedReason: "This folder has subfolders. Delete them first.",
46
+ },
47
+ { id: "mbx-finance-invoices", label: "Invoices", path: "Finance/Invoices" },
48
+ { id: "mbx-finance-tax", label: "Tax", path: "Finance/Tax" },
49
+ { id: "mbx-family", label: "Family", path: "Family" },
50
+ { id: "mbx-news", label: "Newsletters", path: "Newsletters" },
51
+ { id: "mbx-news-tech", label: "Tech", path: "Newsletters/Tech" },
52
+ ];
53
+
54
+ const meta: Meta<typeof FolderManager> = {
55
+ title: "Mail/FolderManager",
56
+ component: FolderManager,
57
+ parameters: { layout: "centered" },
58
+ };
59
+ export default meta;
60
+
61
+ type Story = StoryObj<typeof FolderManager>;
62
+
63
+ function Frame({
64
+ children,
65
+ className = "h-[520px] w-[420px] rounded-lg border border-line shadow-lg",
66
+ }: {
67
+ children: ReactNode;
68
+ className?: string;
69
+ }) {
70
+ return (
71
+ <div
72
+ className={`flex flex-col overflow-hidden bg-surface font-sans text-fg ${className}`}
73
+ >
74
+ {children}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ let createdSeq = 0;
80
+ const createFolder = (
81
+ name: string,
82
+ parentPath: string,
83
+ ): Promise<FolderTreeNode> =>
84
+ new Promise((resolve) => {
85
+ createdSeq += 1;
86
+ setTimeout(
87
+ () =>
88
+ resolve({
89
+ id: `mbx-created-${createdSeq}`,
90
+ label: name,
91
+ path: parentPath ? `${parentPath}/${name}` : name,
92
+ }),
93
+ 400,
94
+ );
95
+ });
96
+
97
+ function Manager({
98
+ renaming = false,
99
+ className,
100
+ }: {
101
+ renaming?: boolean;
102
+ className?: string;
103
+ }) {
104
+ const [known, setKnown] = useState<ManagedFolder[]>(folders);
105
+ const [renamed, setRenamed] = useState<ManagedFolder | null>(
106
+ renaming ? (folders[2] as ManagedFolder) : null,
107
+ );
108
+ const [draft, setDraft] = useState("Trash");
109
+ return (
110
+ <>
111
+ <Frame className={className}>
112
+ <FolderManager
113
+ folders={known}
114
+ onCreateFolder={(name, parentPath) =>
115
+ createFolder(name, parentPath).then((created) => {
116
+ setKnown((current) => [...current, created]);
117
+ return created;
118
+ })
119
+ }
120
+ onRename={(folder) => {
121
+ setRenamed(folder);
122
+ setDraft(folder.label);
123
+ }}
124
+ onDelete={(folder) =>
125
+ setKnown((current) =>
126
+ current.filter((entry) => entry.id !== folder.id),
127
+ )
128
+ }
129
+ labels={{ treeAriaLabel: "All folders for alice@northwind.example" }}
130
+ />
131
+ </Frame>
132
+ {renamed && (
133
+ <FolderRenameDialog
134
+ open
135
+ folderLabel={renamed.label}
136
+ defaultLabel="Deleted Messages"
137
+ name={draft}
138
+ onNameChange={setDraft}
139
+ onSubmit={() => setRenamed(null)}
140
+ onClose={() => setRenamed(null)}
141
+ />
142
+ )}
143
+ </>
144
+ );
145
+ }
146
+
147
+ /**
148
+ * The account's folders as they really nest, closed to the top level. Every row
149
+ * opens where you tap it and carries rename and delete; a folder the account
150
+ * depends on keeps rename and states why it stays.
151
+ */
152
+ export const Default: Story = {
153
+ name: "Default (collapsed to top level)",
154
+ render: () => <Manager />,
155
+ };
156
+
157
+ /** Renaming a folder: the name is Remit's own, and clearing it restores the server's. */
158
+ export const Renaming: Story = {
159
+ name: "Renaming a folder",
160
+ render: () => <Manager renaming />,
161
+ };
162
+
163
+ /** A first-run account with nothing but its inbox. */
164
+ export const JustTheInbox: Story = {
165
+ name: "A single folder",
166
+ render: () => (
167
+ <Frame>
168
+ <FolderManager
169
+ folders={[folders[0] as ManagedFolder]}
170
+ onCreateFolder={createFolder}
171
+ onRename={() => {}}
172
+ onDelete={() => {}}
173
+ />
174
+ </Frame>
175
+ ),
176
+ };
177
+
178
+ /** The surface as it sits on a phone: full width, one branch open at a time. */
179
+ export const Phone: Story = {
180
+ name: "Phone",
181
+ globals: { viewport: { value: "mobile" } },
182
+ parameters: { layout: "fullscreen" },
183
+ render: () => <Manager className="h-screen w-full" />,
184
+ };
@@ -0,0 +1,77 @@
1
+ import type { FolderTreeNode } from "../lib/folder-tree.js";
2
+ import { FolderManageActions } from "./folder-manage-actions.js";
3
+ import {
4
+ FolderTreePicker,
5
+ type FolderTreePickerLabels,
6
+ } from "./folder-tree-picker.js";
7
+
8
+ export interface ManagedFolder extends FolderTreeNode {
9
+ /** Why this folder can't be deleted; absent means it can. */
10
+ deleteBlockedReason?: string;
11
+ }
12
+
13
+ export interface FolderManagerLabels extends FolderTreePickerLabels {
14
+ rename?: (label: string) => string;
15
+ remove?: (label: string) => string;
16
+ }
17
+
18
+ export interface FolderManagerProps {
19
+ folders: readonly ManagedFolder[];
20
+ onRename: (folder: ManagedFolder) => void;
21
+ onDelete: (folder: ManagedFolder) => void;
22
+ /**
23
+ * Creating a folder is an IMAP mutation, so this resolves only once the mail
24
+ * server confirms it. Absent means no create affordance renders.
25
+ */
26
+ onCreateFolder?: (
27
+ name: string,
28
+ parentPath: string,
29
+ signal?: AbortSignal,
30
+ ) => Promise<FolderTreeNode>;
31
+ /** The provider's hierarchy separator. */
32
+ delimiter?: string;
33
+ labels?: FolderManagerLabels;
34
+ }
35
+
36
+ /**
37
+ * The folders as the account holds them, browsed the same way a destination is
38
+ * picked — open a folder to see what is inside it, filter to narrow, make a new
39
+ * one where you are looking — with rename and delete on each row. Nothing is
40
+ * chosen here, so a row opens and closes and nothing else.
41
+ */
42
+ export const FolderManager = ({
43
+ folders,
44
+ onRename,
45
+ onDelete,
46
+ onCreateFolder,
47
+ delimiter,
48
+ labels,
49
+ }: FolderManagerProps) => {
50
+ const byId = new Map(folders.map((folder) => [folder.id, folder]));
51
+ return (
52
+ <FolderTreePicker
53
+ folders={folders}
54
+ onCreateFolder={onCreateFolder}
55
+ delimiter={delimiter}
56
+ labels={{
57
+ treeAriaLabel: "Your folders",
58
+ optionLabel: (label) => label,
59
+ ...labels,
60
+ }}
61
+ rowActions={(folder) => {
62
+ const managed = byId.get(folder.id);
63
+ if (!managed) return null;
64
+ return (
65
+ <FolderManageActions
66
+ label={managed.label}
67
+ deleteBlockedReason={managed.deleteBlockedReason}
68
+ renameLabel={labels?.rename}
69
+ deleteLabel={labels?.remove}
70
+ onRename={() => onRename(managed)}
71
+ onDelete={() => onDelete(managed)}
72
+ />
73
+ );
74
+ }}
75
+ />
76
+ );
77
+ };
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import {
6
+ FolderRenameDialog,
7
+ type FolderRenameDialogProps,
8
+ } from "./folder-rename-dialog.js";
9
+
10
+ const render = (props: Partial<FolderRenameDialogProps>) =>
11
+ renderToString(
12
+ createElement(FolderRenameDialog, {
13
+ open: true,
14
+ folderLabel: "Trash",
15
+ defaultLabel: "Deleted Messages",
16
+ name: "Trash",
17
+ onNameChange: () => undefined,
18
+ onSubmit: () => undefined,
19
+ onClose: () => undefined,
20
+ ...props,
21
+ }),
22
+ );
23
+
24
+ describe("FolderRenameDialog", () => {
25
+ it("renders nothing when closed", () => {
26
+ assert.equal(render({ open: false }), "");
27
+ });
28
+
29
+ it("names the folder it is renaming", () => {
30
+ assert.match(render({}), /Rename Trash/);
31
+ });
32
+
33
+ it("states what clearing the name falls back to", () => {
34
+ const html = render({});
35
+ assert.match(html, /Leave it blank to use/);
36
+ assert.match(html, />Deleted Messages</);
37
+ });
38
+
39
+ it("holds the wait while the name is saved", () => {
40
+ const html = render({ pending: true });
41
+ assert.match(html, /Saving…/);
42
+ assert.match(html, /disabled/);
43
+ });
44
+
45
+ it("states a failure where it happened", () => {
46
+ assert.match(
47
+ render({ error: "Couldn't save that name." }),
48
+ /role="alert"[^>]*>Couldn&#x27;t save that name./,
49
+ );
50
+ });
51
+ });
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { FolderRenameDialog } from "./folder-rename-dialog.js";
4
+
5
+ const meta: Meta<typeof FolderRenameDialog> = {
6
+ title: "Mail/FolderRenameDialog",
7
+ component: FolderRenameDialog,
8
+ parameters: { layout: "fullscreen" },
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof FolderRenameDialog>;
13
+
14
+ function Live({
15
+ initialName = "Trash",
16
+ pending,
17
+ error,
18
+ }: {
19
+ initialName?: string;
20
+ pending?: boolean;
21
+ error?: string;
22
+ }) {
23
+ const [name, setName] = useState(initialName);
24
+ return (
25
+ <div className="h-screen bg-canvas font-sans">
26
+ <FolderRenameDialog
27
+ open
28
+ folderLabel="Trash"
29
+ defaultLabel="Deleted Messages"
30
+ name={name}
31
+ onNameChange={setName}
32
+ onSubmit={() => {}}
33
+ onClose={() => {}}
34
+ pending={pending}
35
+ error={error}
36
+ />
37
+ </div>
38
+ );
39
+ }
40
+
41
+ export const Default: Story = {
42
+ name: "Renaming an appointed folder",
43
+ render: () => <Live />,
44
+ };
45
+
46
+ /** Cleared: the folder goes back to what the mail server calls it. */
47
+ export const Cleared: Story = {
48
+ name: "Name cleared",
49
+ render: () => <Live initialName="" />,
50
+ };
51
+
52
+ export const Saving: Story = {
53
+ name: "Saving",
54
+ render: () => <Live pending />,
55
+ };
56
+
57
+ export const Failed: Story = {
58
+ name: "The save failed",
59
+ render: () => <Live error="Couldn't save that name. Please try again." />,
60
+ };
61
+
62
+ export const Phone: Story = {
63
+ name: "Phone",
64
+ globals: { viewport: { value: "mobile" } },
65
+ render: () => <Live />,
66
+ };
@@ -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
  );
@@ -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)}
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,