@remit/ui 0.0.65 → 0.0.66

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,329 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { type ReactNode, useState } from "react";
3
+ import type { StepId } from "../lib/wizard-steps.js";
4
+ import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
5
+ import { FooterNav, WizardScreen } from "./selection-wizard.js";
6
+
7
+ const folders: FolderTreeNode[] = [
8
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX", isCurrent: true },
9
+ { id: "mbx-archive", label: "Archive", path: "Archive" },
10
+ { id: "mbx-sent", label: "Sent", path: "Sent Items" },
11
+ { id: "mbx-spam", label: "Spam", path: "Junk" },
12
+ { id: "mbx-trash", label: "Trash", path: "Deleted Messages" },
13
+ { id: "mbx-travel", label: "Travel", path: "Travel" },
14
+ { id: "mbx-travel-flights", label: "Flights", path: "Travel/Flights" },
15
+ { id: "mbx-travel-hotels", label: "Hotels", path: "Travel/Hotels" },
16
+ {
17
+ id: "mbx-travel-hotels-receipts",
18
+ label: "Receipts",
19
+ path: "Travel/Hotels/Receipts",
20
+ },
21
+ { id: "mbx-travel-trains", label: "Trains", path: "Travel/Trains" },
22
+ { id: "mbx-finance", label: "Finance", path: "Finance" },
23
+ { id: "mbx-finance-invoices", label: "Invoices", path: "Finance/Invoices" },
24
+ {
25
+ id: "mbx-finance-invoices-2025",
26
+ label: "2025",
27
+ path: "Finance/Invoices/2025",
28
+ },
29
+ {
30
+ id: "mbx-finance-invoices-2026",
31
+ label: "2026",
32
+ path: "Finance/Invoices/2026",
33
+ },
34
+ { id: "mbx-finance-tax", label: "Tax", path: "Finance/Tax" },
35
+ { id: "mbx-family", label: "Family", path: "Family" },
36
+ { id: "mbx-news", label: "Newsletters", path: "Newsletters" },
37
+ { id: "mbx-news-tech", label: "Tech", path: "Newsletters/Tech" },
38
+ { id: "mbx-news-design", label: "Design", path: "Newsletters/Design" },
39
+ { id: "mbx-work", label: "Work", path: "Work" },
40
+ { id: "mbx-work-projects", label: "Projects", path: "Work/Projects" },
41
+ { id: "mbx-work-apollo", label: "Apollo", path: "Work/Projects/Apollo" },
42
+ {
43
+ id: "mbx-work-borealis",
44
+ label: "Borealis",
45
+ path: "Work/Projects/Borealis",
46
+ },
47
+ { id: "mbx-work-recruiting", label: "Recruiting", path: "Work/Recruiting" },
48
+ ];
49
+
50
+ const longFolders: FolderTreeNode[] = [
51
+ ...folders,
52
+ ...Array.from({ length: 36 }, (_, i) => ({
53
+ id: `mbx-client-${i}`,
54
+ label: `Client ${String(i + 1).padStart(2, "0")}`,
55
+ path: `Work/Projects/Client ${String(i + 1).padStart(2, "0")}`,
56
+ })),
57
+ ];
58
+
59
+ const meta: Meta<typeof FolderTreePicker> = {
60
+ title: "Mail/FolderTreePicker",
61
+ component: FolderTreePicker,
62
+ parameters: { layout: "centered" },
63
+ };
64
+ export default meta;
65
+
66
+ type Story = StoryObj<typeof FolderTreePicker>;
67
+
68
+ function Frame({ children }: { children: ReactNode }) {
69
+ return (
70
+ <div className="flex h-[560px] w-[360px] flex-col overflow-hidden rounded-lg border border-line bg-surface font-sans text-fg shadow-lg">
71
+ {children}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ let createdSeq = 0;
77
+ const createFolder = (
78
+ name: string,
79
+ parentPath: string,
80
+ ): Promise<FolderTreeNode> =>
81
+ new Promise((resolve) => {
82
+ createdSeq += 1;
83
+ setTimeout(
84
+ () =>
85
+ resolve({
86
+ id: `mbx-created-${createdSeq}`,
87
+ label: name,
88
+ path: parentPath ? `${parentPath}/${name}` : name,
89
+ }),
90
+ 400,
91
+ );
92
+ });
93
+
94
+ const neverResolves = (): Promise<FolderTreeNode> =>
95
+ new Promise<FolderTreeNode>(() => undefined);
96
+
97
+ const rejects = (message: string) => (): Promise<FolderTreeNode> =>
98
+ Promise.reject(new Error(message));
99
+
100
+ function Picker({
101
+ options = folders,
102
+ onCreateFolder = createFolder,
103
+ }: {
104
+ options?: FolderTreeNode[];
105
+ onCreateFolder?: (
106
+ name: string,
107
+ parentPath: string,
108
+ signal?: AbortSignal,
109
+ ) => Promise<FolderTreeNode>;
110
+ }) {
111
+ const [selected, setSelected] = useState<string>();
112
+ const [known, setKnown] = useState(options);
113
+ return (
114
+ <Frame>
115
+ <FolderTreePicker
116
+ folders={known}
117
+ selectedId={selected}
118
+ onSelect={setSelected}
119
+ onCreateFolder={(name, parentPath, signal) =>
120
+ onCreateFolder(name, parentPath, signal).then((created) => {
121
+ setKnown((current) => [...current, created]);
122
+ return created;
123
+ })
124
+ }
125
+ />
126
+ </Frame>
127
+ );
128
+ }
129
+
130
+ export const Default: Story = {
131
+ name: "Default (browse the tree)",
132
+ render: () => <Picker />,
133
+ };
134
+
135
+ export const WithoutCreate: Story = {
136
+ name: "No create affordance",
137
+ render: () => {
138
+ const ReadOnly = () => {
139
+ const [selected, setSelected] = useState<string>();
140
+ return (
141
+ <Frame>
142
+ <FolderTreePicker
143
+ folders={folders}
144
+ selectedId={selected}
145
+ onSelect={setSelected}
146
+ />
147
+ </Frame>
148
+ );
149
+ };
150
+ return <ReadOnly />;
151
+ },
152
+ };
153
+
154
+ export const LongList: Story = {
155
+ name: "Long list (scrolls)",
156
+ render: () => <Picker options={longFolders} />,
157
+ };
158
+
159
+ export const Empty: Story = {
160
+ name: "No folders",
161
+ render: () => <Picker options={[]} />,
162
+ };
163
+
164
+ const setInputValue = (input: HTMLInputElement, value: string) => {
165
+ const setter = Object.getOwnPropertyDescriptor(
166
+ HTMLInputElement.prototype,
167
+ "value",
168
+ )?.set;
169
+ setter?.call(input, value);
170
+ input.dispatchEvent(new Event("input", { bubbles: true }));
171
+ };
172
+
173
+ const typeFilter = (canvasElement: HTMLElement, value: string) => {
174
+ const input = canvasElement.querySelector<HTMLInputElement>(
175
+ 'input[type="search"]',
176
+ );
177
+ if (input) setInputValue(input, value);
178
+ };
179
+
180
+ const clickAriaLabel = (canvasElement: HTMLElement, label: string) => {
181
+ canvasElement
182
+ .querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
183
+ ?.click();
184
+ };
185
+
186
+ const clickText = (canvasElement: HTMLElement, label: string) => {
187
+ Array.from(canvasElement.querySelectorAll<HTMLButtonElement>("button"))
188
+ .find((button) => button.textContent?.trim() === label)
189
+ ?.click();
190
+ };
191
+
192
+ const typeFolderName = (canvasElement: HTMLElement, name: string) => {
193
+ const input = canvasElement.querySelector<HTMLInputElement>(
194
+ 'input:not([type="search"])',
195
+ );
196
+ if (input) setInputValue(input, name);
197
+ };
198
+
199
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
200
+
201
+ /**
202
+ * "rec" matches two folders nested three levels down. Their parents stay on
203
+ * screen so the branch still reads as a branch — dimmed, and not a destination.
204
+ */
205
+ export const Filtered: Story = {
206
+ name: "Filtered (ancestors kept for context)",
207
+ render: () => <Picker />,
208
+ play: async ({ canvasElement }) => {
209
+ typeFilter(canvasElement, "rec");
210
+ },
211
+ };
212
+
213
+ export const CreateTopLevel: Story = {
214
+ name: "Create a top-level folder",
215
+ render: () => <Picker />,
216
+ play: async ({ canvasElement }) => {
217
+ clickText(canvasElement, "New folder");
218
+ await tick();
219
+ typeFolderName(canvasElement, "Insurance");
220
+ },
221
+ };
222
+
223
+ /**
224
+ * The row you tapped is the answer to "inside where": the form opens under it
225
+ * and states the parent as text, so there is no second choice to make.
226
+ */
227
+ export const CreateSubfolder: Story = {
228
+ name: "Create a subfolder from a row",
229
+ render: () => <Picker />,
230
+ play: async ({ canvasElement }) => {
231
+ clickAriaLabel(canvasElement, "New folder inside Travel");
232
+ await tick();
233
+ typeFolderName(canvasElement, "Car hire");
234
+ },
235
+ };
236
+
237
+ /**
238
+ * Creating a folder is an IMAP mutation: the form holds until the mail server
239
+ * confirms the folder, and refuses a second submit while it waits.
240
+ */
241
+ export const CreatePending: Story = {
242
+ name: "Create — waiting for the server",
243
+ render: () => <Picker onCreateFolder={neverResolves} />,
244
+ play: async ({ canvasElement }) => {
245
+ clickAriaLabel(canvasElement, "New folder inside Finance");
246
+ await tick();
247
+ typeFolderName(canvasElement, "Mortgage");
248
+ await tick();
249
+ clickText(canvasElement, "Create folder");
250
+ },
251
+ };
252
+
253
+ /** The failure is stated where it happened; the form stays open to retry. */
254
+ export const CreateFailed: Story = {
255
+ name: "Create — failed (retry in place)",
256
+ render: () => (
257
+ <Picker
258
+ onCreateFolder={rejects(
259
+ "The mail server refused the folder name. Try another one.",
260
+ )}
261
+ />
262
+ ),
263
+ play: async ({ canvasElement }) => {
264
+ clickAriaLabel(canvasElement, "New folder inside Finance");
265
+ await tick();
266
+ typeFolderName(canvasElement, "Mortgage");
267
+ await tick();
268
+ clickText(canvasElement, "Create folder");
269
+ },
270
+ };
271
+
272
+ const wizardSteps: StepId[] = ["match", "folder", "rule", "review"];
273
+
274
+ function WizardFolderStep() {
275
+ const [selected, setSelected] = useState<string>();
276
+ const [known, setKnown] = useState(folders);
277
+ const chosen = known.find((folder) => folder.id === selected);
278
+ return (
279
+ <WizardScreen
280
+ title="Move to"
281
+ subtitle="Pick a destination"
282
+ steps={wizardSteps}
283
+ step="folder"
284
+ onBack={() => undefined}
285
+ onExit={() => undefined}
286
+ footer={
287
+ <FooterNav
288
+ onBack={() => undefined}
289
+ nextLabel="Continue"
290
+ onNext={() => undefined}
291
+ blockedReason={chosen ? undefined : "Pick a folder to move into."}
292
+ />
293
+ }
294
+ >
295
+ <div className="flex min-h-0 flex-col gap-3">
296
+ <p className="px-1 text-xs text-fg-muted">
297
+ {chosen
298
+ ? `Moving to ${chosen.label}.`
299
+ : "Browse to a folder, or make a new one where you want it."}
300
+ </p>
301
+ <div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-line bg-surface">
302
+ <FolderTreePicker
303
+ folders={known}
304
+ selectedId={selected}
305
+ onSelect={setSelected}
306
+ onCreateFolder={(name, parentPath) =>
307
+ createFolder(name, parentPath).then((created) => {
308
+ setKnown((current) => [...current, created]);
309
+ return created;
310
+ })
311
+ }
312
+ labels={{
313
+ createPending:
314
+ "Waiting for the mail server to confirm the folder…",
315
+ }}
316
+ />
317
+ </div>
318
+ </div>
319
+ </WizardScreen>
320
+ );
321
+ }
322
+
323
+ /** The pattern at the size it is used: the wizard's folder step on a phone. */
324
+ export const PhoneWizardStep: Story = {
325
+ name: "Phone — wizard folder step",
326
+ parameters: { layout: "fullscreen" },
327
+ globals: { viewport: { value: "mobile" } },
328
+ render: () => <WizardFolderStep />,
329
+ };