@remit/ui 0.0.64 → 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.
- package/package.json +1 -1
- package/src/components/folder-tree-picker.create.test.ts +362 -0
- package/src/components/folder-tree-picker.render.test.ts +241 -0
- package/src/components/folder-tree-picker.stories.tsx +329 -0
- package/src/components/folder-tree-picker.tsx +610 -0
- package/src/components/selection-wizard.render.test.ts +36 -0
- package/src/components/selection-wizard.tsx +33 -1
- package/src/index.ts +7 -0
- package/src/lib/wizard-steps.ts +8 -0
|
@@ -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
|
+
};
|