@remit/ui 0.0.65 → 0.0.67
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,362 @@
|
|
|
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
|
+
/** The list at rest: top level only, one tap deep into anything. */
|
|
131
|
+
export const Default: Story = {
|
|
132
|
+
name: "Default (collapsed to top level)",
|
|
133
|
+
render: () => <Picker />,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const WithoutCreate: Story = {
|
|
137
|
+
name: "No create affordance",
|
|
138
|
+
render: () => {
|
|
139
|
+
const ReadOnly = () => {
|
|
140
|
+
const [selected, setSelected] = useState<string>();
|
|
141
|
+
return (
|
|
142
|
+
<Frame>
|
|
143
|
+
<FolderTreePicker
|
|
144
|
+
folders={folders}
|
|
145
|
+
selectedId={selected}
|
|
146
|
+
onSelect={setSelected}
|
|
147
|
+
/>
|
|
148
|
+
</Frame>
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
return <ReadOnly />;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const LongList: Story = {
|
|
156
|
+
name: "Long list (scrolls)",
|
|
157
|
+
render: () => <Picker options={longFolders} />,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const Empty: Story = {
|
|
161
|
+
name: "No folders",
|
|
162
|
+
render: () => <Picker options={[]} />,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const setInputValue = (input: HTMLInputElement, value: string) => {
|
|
166
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
167
|
+
HTMLInputElement.prototype,
|
|
168
|
+
"value",
|
|
169
|
+
)?.set;
|
|
170
|
+
setter?.call(input, value);
|
|
171
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const typeFilter = (canvasElement: HTMLElement, value: string) => {
|
|
175
|
+
const input = canvasElement.querySelector<HTMLInputElement>(
|
|
176
|
+
'input[type="search"]',
|
|
177
|
+
);
|
|
178
|
+
if (input) setInputValue(input, value);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const clickAriaLabel = (canvasElement: HTMLElement, label: string) => {
|
|
182
|
+
canvasElement
|
|
183
|
+
.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
|
184
|
+
?.click();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const clickText = (canvasElement: HTMLElement, label: string) => {
|
|
188
|
+
Array.from(canvasElement.querySelectorAll<HTMLButtonElement>("button"))
|
|
189
|
+
.find((button) => button.textContent?.trim() === label)
|
|
190
|
+
?.click();
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const typeFolderName = (canvasElement: HTMLElement, name: string) => {
|
|
194
|
+
const input = canvasElement.querySelector<HTMLInputElement>(
|
|
195
|
+
'input:not([type="search"])',
|
|
196
|
+
);
|
|
197
|
+
if (input) setInputValue(input, name);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
|
|
201
|
+
|
|
202
|
+
const expand = async (canvasElement: HTMLElement, ...labels: string[]) => {
|
|
203
|
+
for (const label of labels) {
|
|
204
|
+
clickAriaLabel(canvasElement, `Move to ${label}`);
|
|
205
|
+
await tick();
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* One tap picks the destination and opens it. The children arrive indented,
|
|
211
|
+
* with "New folder" sitting at the end of them as the last folder inside.
|
|
212
|
+
*/
|
|
213
|
+
export const Expanded: Story = {
|
|
214
|
+
name: "A folder opened (children and its New folder)",
|
|
215
|
+
render: () => <Picker />,
|
|
216
|
+
play: async ({ canvasElement }) => {
|
|
217
|
+
await expand(canvasElement, "Travel");
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** Three levels open at once, each with its own place to add a folder. */
|
|
222
|
+
export const DeepExpansion: Story = {
|
|
223
|
+
name: "Opened three levels deep",
|
|
224
|
+
render: () => <Picker />,
|
|
225
|
+
play: async ({ canvasElement }) => {
|
|
226
|
+
await expand(canvasElement, "Travel", "Hotels", "Receipts");
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* "rec" matches two nested folders. Their parents open to put them on screen
|
|
232
|
+
* and read as the branches they are, not as an answer to what was typed.
|
|
233
|
+
*/
|
|
234
|
+
export const Filtered: Story = {
|
|
235
|
+
name: "Filtered (ancestors opened for context)",
|
|
236
|
+
render: () => <Picker />,
|
|
237
|
+
play: async ({ canvasElement }) => {
|
|
238
|
+
typeFilter(canvasElement, "rec");
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export const CreateTopLevel: Story = {
|
|
243
|
+
name: "Create a top-level folder",
|
|
244
|
+
render: () => <Picker />,
|
|
245
|
+
play: async ({ canvasElement }) => {
|
|
246
|
+
clickAriaLabel(canvasElement, "New folder");
|
|
247
|
+
await tick();
|
|
248
|
+
typeFolderName(canvasElement, "Insurance");
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The folder you opened is the answer to "inside where": the action sits among
|
|
254
|
+
* its children and the form states the parent as text, so there is no second
|
|
255
|
+
* choice to make.
|
|
256
|
+
*/
|
|
257
|
+
export const CreateSubfolder: Story = {
|
|
258
|
+
name: "Create a folder inside an opened one",
|
|
259
|
+
render: () => <Picker />,
|
|
260
|
+
play: async ({ canvasElement }) => {
|
|
261
|
+
await expand(canvasElement, "Travel");
|
|
262
|
+
clickAriaLabel(canvasElement, "New folder inside Travel");
|
|
263
|
+
await tick();
|
|
264
|
+
typeFolderName(canvasElement, "Car hire");
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Creating a folder is an IMAP mutation: the form holds until the mail server
|
|
270
|
+
* confirms the folder, and refuses a second submit while it waits.
|
|
271
|
+
*/
|
|
272
|
+
export const CreatePending: Story = {
|
|
273
|
+
name: "Create — waiting for the server",
|
|
274
|
+
render: () => <Picker onCreateFolder={neverResolves} />,
|
|
275
|
+
play: async ({ canvasElement }) => {
|
|
276
|
+
await expand(canvasElement, "Finance");
|
|
277
|
+
clickAriaLabel(canvasElement, "New folder inside Finance");
|
|
278
|
+
await tick();
|
|
279
|
+
typeFolderName(canvasElement, "Mortgage");
|
|
280
|
+
await tick();
|
|
281
|
+
clickText(canvasElement, "Create folder");
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/** The failure is stated where it happened; the form stays open to retry. */
|
|
286
|
+
export const CreateFailed: Story = {
|
|
287
|
+
name: "Create — failed (retry in place)",
|
|
288
|
+
render: () => (
|
|
289
|
+
<Picker
|
|
290
|
+
onCreateFolder={rejects(
|
|
291
|
+
"The mail server refused the folder name. Try another one.",
|
|
292
|
+
)}
|
|
293
|
+
/>
|
|
294
|
+
),
|
|
295
|
+
play: async ({ canvasElement }) => {
|
|
296
|
+
await expand(canvasElement, "Finance");
|
|
297
|
+
clickAriaLabel(canvasElement, "New folder inside Finance");
|
|
298
|
+
await tick();
|
|
299
|
+
typeFolderName(canvasElement, "Mortgage");
|
|
300
|
+
await tick();
|
|
301
|
+
clickText(canvasElement, "Create folder");
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const wizardSteps: StepId[] = ["match", "folder", "rule", "review"];
|
|
306
|
+
|
|
307
|
+
function WizardFolderStep() {
|
|
308
|
+
const [selected, setSelected] = useState<string>();
|
|
309
|
+
const [known, setKnown] = useState(folders);
|
|
310
|
+
const chosen = known.find((folder) => folder.id === selected);
|
|
311
|
+
return (
|
|
312
|
+
<WizardScreen
|
|
313
|
+
title="Move to"
|
|
314
|
+
subtitle="Pick a destination"
|
|
315
|
+
steps={wizardSteps}
|
|
316
|
+
step="folder"
|
|
317
|
+
onBack={() => undefined}
|
|
318
|
+
onExit={() => undefined}
|
|
319
|
+
footer={
|
|
320
|
+
<FooterNav
|
|
321
|
+
onBack={() => undefined}
|
|
322
|
+
nextLabel="Continue"
|
|
323
|
+
onNext={() => undefined}
|
|
324
|
+
blockedReason={chosen ? undefined : "Pick a folder to move into."}
|
|
325
|
+
/>
|
|
326
|
+
}
|
|
327
|
+
>
|
|
328
|
+
<div className="flex min-h-0 flex-col gap-3">
|
|
329
|
+
<p className="px-1 text-xs text-fg-muted">
|
|
330
|
+
{chosen
|
|
331
|
+
? `Moving to ${chosen.label}.`
|
|
332
|
+
: "Tap a folder to open it, or make a new one where you want it."}
|
|
333
|
+
</p>
|
|
334
|
+
<div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-line bg-surface">
|
|
335
|
+
<FolderTreePicker
|
|
336
|
+
folders={known}
|
|
337
|
+
selectedId={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
|
+
labels={{
|
|
346
|
+
createPending:
|
|
347
|
+
"Waiting for the mail server to confirm the folder…",
|
|
348
|
+
}}
|
|
349
|
+
/>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
</WizardScreen>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The pattern at the size it is used: the wizard's folder step on a phone. */
|
|
357
|
+
export const PhoneWizardStep: Story = {
|
|
358
|
+
name: "Phone — wizard folder step",
|
|
359
|
+
parameters: { layout: "fullscreen" },
|
|
360
|
+
globals: { viewport: { value: "mobile" } },
|
|
361
|
+
render: () => <WizardFolderStep />,
|
|
362
|
+
};
|