@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.
@@ -1,210 +0,0 @@
1
- /**
2
- * The create-and-move affordance: a search that names no existing folder offers
3
- * a create row only when `onCreateFolder` is wired; resolving it selects the new
4
- * folder. Mounted against jsdom since it turns on typed search state and an
5
- * async resolve. React is imported after the jsdom globals are installed so the
6
- * controlled-input value tracker binds to jsdom's prototypes.
7
- */
8
- import assert from "node:assert/strict";
9
- import { after, afterEach, before, beforeEach, describe, it } from "node:test";
10
- import type { JSDOM } from "jsdom";
11
- import type {
12
- act as reactAct,
13
- createElement as reactCreateElement,
14
- } from "react";
15
- import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
16
- import type {
17
- MoveMailboxOption,
18
- MoveMailboxPicker as MoveMailboxPickerType,
19
- } from "./move-mailbox-picker.js";
20
-
21
- const mailboxes: MoveMailboxOption[] = [
22
- { id: "archive", label: "Archive" },
23
- { id: "trash", label: "Trash" },
24
- ];
25
-
26
- let dom: JSDOM;
27
- let container: HTMLElement;
28
- let root: Root;
29
- let act: typeof reactAct;
30
- let createElement: typeof reactCreateElement;
31
- let createRoot: typeof reactCreateRoot;
32
- let MoveMailboxPicker: typeof MoveMailboxPickerType;
33
-
34
- before(async () => {
35
- const { JSDOM: JSDOMCtor } = await import("jsdom");
36
- dom = new JSDOMCtor(
37
- "<!doctype html><html><body><div id=root></div></body></html>",
38
- { url: "http://localhost/", pretendToBeVisual: true },
39
- );
40
- globalThis.window = dom.window as unknown as typeof globalThis.window;
41
- globalThis.document = dom.window.document;
42
- globalThis.HTMLElement = dom.window.HTMLElement;
43
- globalThis.Element = dom.window.Element;
44
- globalThis.Event = dom.window.Event;
45
- Object.defineProperty(globalThis, "navigator", {
46
- value: dom.window.navigator,
47
- configurable: true,
48
- });
49
- (
50
- globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
51
- ).IS_REACT_ACT_ENVIRONMENT = true;
52
-
53
- const react = await import("react");
54
- act = react.act;
55
- createElement = react.createElement;
56
- ({ createRoot } = await import("react-dom/client"));
57
- ({ MoveMailboxPicker } = await import("./move-mailbox-picker.js"));
58
- });
59
-
60
- after(() => {
61
- dom.window.close();
62
- });
63
-
64
- beforeEach(() => {
65
- container = dom.window.document.getElementById(
66
- "root",
67
- ) as unknown as HTMLElement;
68
- container.innerHTML = "";
69
- root = createRoot(container);
70
- });
71
-
72
- afterEach(() => {
73
- act(() => {
74
- root.unmount();
75
- });
76
- });
77
-
78
- function typeSearch(value: string) {
79
- const input = container.querySelector(
80
- 'input[aria-label="Filter folders"]',
81
- ) as HTMLInputElement;
82
- const setter = Object.getOwnPropertyDescriptor(
83
- dom.window.HTMLInputElement.prototype,
84
- "value",
85
- )?.set;
86
- setter?.call(input, value);
87
- act(() => {
88
- input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
89
- });
90
- }
91
-
92
- function createRow(): HTMLButtonElement | null {
93
- return (
94
- Array.from(container.querySelectorAll("button")).find((button) =>
95
- button.textContent?.startsWith("Create "),
96
- ) ?? null
97
- );
98
- }
99
-
100
- describe("MoveMailboxPicker create-and-move", () => {
101
- it("offers no create row without onCreateFolder, even when nothing matches", () => {
102
- act(() => {
103
- root.render(
104
- createElement(MoveMailboxPicker, {
105
- mailboxes,
106
- onSelect: () => {},
107
- }),
108
- );
109
- });
110
- typeSearch("Taxes");
111
- assert.equal(createRow(), null);
112
- });
113
-
114
- it("offers a create row when the query names no existing folder", () => {
115
- act(() => {
116
- root.render(
117
- createElement(MoveMailboxPicker, {
118
- mailboxes,
119
- onSelect: () => {},
120
- onCreateFolder: async () => ({ id: "new", label: "Taxes" }),
121
- }),
122
- );
123
- });
124
- typeSearch("Taxes");
125
- const row = createRow();
126
- assert.ok(row, "the create row is present");
127
- assert.match(row?.textContent ?? "", /Create "Taxes"/);
128
- });
129
-
130
- it("hides the create row when the query exactly names an existing folder", () => {
131
- act(() => {
132
- root.render(
133
- createElement(MoveMailboxPicker, {
134
- mailboxes,
135
- onSelect: () => {},
136
- onCreateFolder: async () => ({ id: "new", label: "Archive" }),
137
- }),
138
- );
139
- });
140
- typeSearch("archive");
141
- assert.equal(createRow(), null);
142
- });
143
-
144
- it("creates the folder and selects it in one step", async () => {
145
- const selected: string[] = [];
146
- act(() => {
147
- root.render(
148
- createElement(MoveMailboxPicker, {
149
- mailboxes,
150
- onSelect: (id: string) => selected.push(id),
151
- onCreateFolder: async (name: string) => ({
152
- id: "created-taxes",
153
- label: name,
154
- }),
155
- }),
156
- );
157
- });
158
- typeSearch("Taxes");
159
- await act(async () => {
160
- createRow()?.click();
161
- });
162
- assert.deepEqual(selected, ["created-taxes"]);
163
- });
164
-
165
- it("surfaces a validation rejection message inline without selecting anything", async () => {
166
- const selected: string[] = [];
167
- act(() => {
168
- root.render(
169
- createElement(MoveMailboxPicker, {
170
- mailboxes,
171
- onSelect: (id: string) => selected.push(id),
172
- onCreateFolder: async () => {
173
- throw new Error('A folder name can\'t contain "/".');
174
- },
175
- }),
176
- );
177
- });
178
- typeSearch("Work/Receipts");
179
- await act(async () => {
180
- createRow()?.click();
181
- });
182
- assert.deepEqual(selected, []);
183
- assert.match(
184
- container.querySelector('[role="alert"]')?.textContent ?? "",
185
- /can't contain/,
186
- );
187
- });
188
-
189
- it("falls back to the generic message for a non-Error rejection", async () => {
190
- act(() => {
191
- root.render(
192
- createElement(MoveMailboxPicker, {
193
- mailboxes,
194
- onSelect: () => {},
195
- onCreateFolder: async () => {
196
- throw "opaque";
197
- },
198
- }),
199
- );
200
- });
201
- typeSearch("Taxes");
202
- await act(async () => {
203
- createRow()?.click();
204
- });
205
- assert.match(
206
- container.querySelector('[role="alert"]')?.textContent ?? "",
207
- /Couldn't create that folder/,
208
- );
209
- });
210
- });
@@ -1,113 +0,0 @@
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
- type MoveMailboxOption,
7
- MoveMailboxPicker,
8
- moveMailboxPickerInternals,
9
- } from "./move-mailbox-picker.js";
10
-
11
- const {
12
- findFirstSelectable,
13
- findLastSelectable,
14
- findNextSelectable,
15
- matchesQuery,
16
- } = moveMailboxPickerInternals;
17
-
18
- const options: MoveMailboxOption[] = [
19
- { id: "inbox", label: "Inbox", isCurrent: true },
20
- { id: "archive", label: "Archive" },
21
- { id: "trash", label: "Trash" },
22
- ];
23
-
24
- describe("roving-focus selection", () => {
25
- it("skips the current folder when finding the first selectable", () => {
26
- assert.equal(findFirstSelectable(options), 1);
27
- });
28
-
29
- it("finds the last selectable", () => {
30
- assert.equal(findLastSelectable(options), 2);
31
- });
32
-
33
- it("wraps forward over the non-selectable current folder", () => {
34
- assert.equal(findNextSelectable(options, 2, 1), 1);
35
- });
36
-
37
- it("wraps backward over the non-selectable current folder", () => {
38
- assert.equal(findNextSelectable(options, 1, -1), 2);
39
- });
40
-
41
- it("returns -1 when every option is the current folder", () => {
42
- const allCurrent: MoveMailboxOption[] = [
43
- { id: "a", label: "A", isCurrent: true },
44
- ];
45
- assert.equal(findFirstSelectable(allCurrent), -1);
46
- assert.equal(findNextSelectable(allCurrent, 0, 1), -1);
47
- });
48
- });
49
-
50
- describe("query matching", () => {
51
- it("matches on the display label, case-insensitively", () => {
52
- assert.equal(matchesQuery({ id: "a", label: "Archive" }, "arch"), true);
53
- });
54
-
55
- it("matches on the hidden searchValue (nested path)", () => {
56
- assert.equal(
57
- matchesQuery(
58
- { id: "r", label: "Receipts", searchValue: "finance/receipts" },
59
- "finance/",
60
- ),
61
- true,
62
- );
63
- });
64
-
65
- it("does not match unrelated text", () => {
66
- assert.equal(matchesQuery({ id: "a", label: "Archive" }, "spam"), false);
67
- });
68
- });
69
-
70
- describe("MoveMailboxPicker render", () => {
71
- it("renders each mailbox as a listbox option", () => {
72
- const html = renderToString(
73
- createElement(MoveMailboxPicker, {
74
- mailboxes: options,
75
- onSelect: () => {},
76
- }),
77
- );
78
- assert.match(html, /role="listbox"/);
79
- const optionCount = html.match(/role="option"/g)?.length ?? 0;
80
- assert.equal(optionCount, 3);
81
- });
82
-
83
- it("marks the current folder as a non-interactive marker, never a disabled button", () => {
84
- const html = renderToString(
85
- createElement(MoveMailboxPicker, {
86
- mailboxes: options,
87
- onSelect: () => {},
88
- }),
89
- );
90
- assert.match(html, /aria-label="Inbox \(current folder\)"/);
91
- assert.match(html, /aria-current="true"/);
92
- assert.match(html, /aria-label="Move to Archive"/);
93
- assert.doesNotMatch(html, /disabled/);
94
- });
95
-
96
- it("renders the empty state when there are no mailboxes", () => {
97
- const html = renderToString(
98
- createElement(MoveMailboxPicker, { mailboxes: [], onSelect: () => {} }),
99
- );
100
- assert.match(html, /No folders match/);
101
- });
102
-
103
- it("applies caller-supplied labels", () => {
104
- const html = renderToString(
105
- createElement(MoveMailboxPicker, {
106
- mailboxes: options,
107
- onSelect: () => {},
108
- labels: { optionLabel: (label) => `Verplaats naar ${label}` },
109
- }),
110
- );
111
- assert.match(html, /aria-label="Verplaats naar Archive"/);
112
- });
113
- });
@@ -1,313 +0,0 @@
1
- import type { Meta, StoryObj } from "@storybook/react";
2
- import { useState } from "react";
3
- import {
4
- type MoveMailboxOption,
5
- MoveMailboxPicker,
6
- } from "./move-mailbox-picker.js";
7
-
8
- const mailboxes: MoveMailboxOption[] = [
9
- { id: "inbox", label: "Inbox", isCurrent: true },
10
- { id: "archive", label: "Archive" },
11
- { id: "trash", label: "Trash" },
12
- { id: "spam", label: "Spam" },
13
- { id: "receipts", label: "Receipts", searchValue: "finance/receipts" },
14
- { id: "travel", label: "Travel", searchValue: "finance/travel" },
15
- { id: "newsletters", label: "Newsletters" },
16
- ];
17
-
18
- const manyMailboxes: MoveMailboxOption[] = [
19
- { id: "inbox", label: "Inbox", isCurrent: true },
20
- ...Array.from({ length: 24 }, (_, i) => ({
21
- id: `folder-${i}`,
22
- label: `Project ${String(i + 1).padStart(2, "0")}`,
23
- })),
24
- ];
25
-
26
- const meta: Meta<typeof MoveMailboxPicker> = {
27
- title: "Mail/MoveMailboxPicker",
28
- component: MoveMailboxPicker,
29
- parameters: { layout: "centered" },
30
- decorators: [
31
- (Story) => (
32
- <div className="w-72 max-h-96 overflow-hidden rounded-md border border-line bg-surface shadow-lg">
33
- <Story />
34
- </div>
35
- ),
36
- ],
37
- };
38
- export default meta;
39
-
40
- type Story = StoryObj<typeof MoveMailboxPicker>;
41
-
42
- const Picker = ({ options }: { options: MoveMailboxOption[] }) => {
43
- const [moved, setMoved] = useState<string | null>(null);
44
- return (
45
- <div className="flex flex-col">
46
- <MoveMailboxPicker mailboxes={options} onSelect={setMoved} />
47
- {moved && (
48
- <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
49
- Moved to {moved}
50
- </p>
51
- )}
52
- </div>
53
- );
54
- };
55
-
56
- export const Default: Story = {
57
- name: "Default (current folder marked)",
58
- render: () => <Picker options={mailboxes} />,
59
- };
60
-
61
- export const ManyMailboxes: Story = {
62
- name: "Many mailboxes (scrolls)",
63
- render: () => <Picker options={manyMailboxes} />,
64
- };
65
-
66
- export const Empty: Story = {
67
- name: "Empty list",
68
- render: () => <Picker options={[]} />,
69
- };
70
-
71
- export const Autofocus: Story = {
72
- name: "Autofocus search (mobile sheet)",
73
- render: () => (
74
- <MoveMailboxPicker mailboxes={mailboxes} onSelect={() => {}} autoFocus />
75
- ),
76
- };
77
-
78
- let createdSeq = 0;
79
- const mockCreateFolder = (name: string): Promise<MoveMailboxOption> =>
80
- new Promise((resolve) => {
81
- createdSeq += 1;
82
- setTimeout(
83
- () => resolve({ id: `created-${createdSeq}`, label: name }),
84
- 400,
85
- );
86
- });
87
-
88
- const CreatePicker = () => {
89
- const [moved, setMoved] = useState<string | null>(null);
90
- return (
91
- <div className="flex flex-col">
92
- <MoveMailboxPicker
93
- mailboxes={mailboxes}
94
- onSelect={setMoved}
95
- onCreateFolder={mockCreateFolder}
96
- />
97
- {moved && (
98
- <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
99
- Moved to {moved}
100
- </p>
101
- )}
102
- </div>
103
- );
104
- };
105
-
106
- /**
107
- * With `onCreateFolder` wired, a search that names no existing folder offers a
108
- * create-and-move row at the bottom. Type e.g. "Taxes", then pick the create
109
- * row — the folder is created and the message moved into it in one step.
110
- */
111
- export const CreateAndMove: Story = {
112
- name: "Create folder from search",
113
- render: () => <CreatePicker />,
114
- };
115
-
116
- /**
117
- * Type a folder name into the search box and press the create-and-move row —
118
- * used by the pending and error stories so each lands in its state without a
119
- * manual click-through.
120
- */
121
- async function typeAndCreate(canvasElement: HTMLElement, folderName: string) {
122
- const setInputValue = Object.getOwnPropertyDescriptor(
123
- HTMLInputElement.prototype,
124
- "value",
125
- )?.set;
126
- const input = canvasElement.querySelector<HTMLInputElement>(
127
- 'input[type="search"]',
128
- );
129
- if (!input) return;
130
- setInputValue?.call(input, folderName);
131
- input.dispatchEvent(new Event("input", { bubbles: true }));
132
- const createButton = Array.from(
133
- canvasElement.querySelectorAll<HTMLButtonElement>("button"),
134
- ).find((button) => button.textContent?.includes(`Create "${folderName}"`));
135
- createButton?.click();
136
- }
137
-
138
- /** Mirrors the web-client wait's honest timeout copy. */
139
- const TIMEOUT_MESSAGE =
140
- "The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
141
-
142
- const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
143
-
144
- const neverResolvesCreateFolder = (): Promise<MoveMailboxOption> =>
145
- new Promise<MoveMailboxOption>(() => undefined);
146
-
147
- const rejectingCreateFolder =
148
- (message: string) => (): Promise<MoveMailboxOption> =>
149
- Promise.reject(new Error(message));
150
-
151
- /** Rejects the first attempt, resolves the retry — the resume the hook performs. */
152
- const failThenSucceedCreateFolder = () => {
153
- let attempts = 0;
154
- return (name: string): Promise<MoveMailboxOption> => {
155
- attempts += 1;
156
- return attempts === 1
157
- ? Promise.reject(new Error(TIMEOUT_MESSAGE))
158
- : Promise.resolve({ id: "mbx-created", label: name });
159
- };
160
- };
161
-
162
- /** Never resolves on its own; rejects with an AbortError when the signal aborts. */
163
- const abortAwareCreateFolder = (
164
- _name: string,
165
- signal?: AbortSignal,
166
- ): Promise<MoveMailboxOption> =>
167
- new Promise<MoveMailboxOption>((_resolve, reject) => {
168
- signal?.addEventListener("abort", () =>
169
- reject(new DOMException("Aborted", "AbortError")),
170
- );
171
- });
172
-
173
- /**
174
- * The move is a dependent write on the folder: the create-and-move row does not
175
- * resolve until the mail server confirms the folder, so the move never races the
176
- * folder into existence. The wait shows as "Creating folder…".
177
- */
178
- export const CreateFolderInFlight: Story = {
179
- name: "Create folder — waiting for the server",
180
- render: () => (
181
- <MoveMailboxPicker
182
- mailboxes={mailboxes}
183
- onSelect={() => undefined}
184
- onCreateFolder={neverResolvesCreateFolder}
185
- />
186
- ),
187
- play: async ({ canvasElement }) => {
188
- await typeAndCreate(canvasElement, "Taxes");
189
- },
190
- };
191
-
192
- /**
193
- * The folder create failed on the mail server. No move runs; the error is shown
194
- * inline and the create row can be pressed again to retry.
195
- */
196
- export const CreateFolderFailed: Story = {
197
- name: "Create folder — failed (retry)",
198
- render: () => (
199
- <MoveMailboxPicker
200
- mailboxes={mailboxes}
201
- onSelect={() => undefined}
202
- onCreateFolder={rejectingCreateFolder(
203
- "The folder couldn't be created on the mail server. Please try again.",
204
- )}
205
- />
206
- ),
207
- play: async ({ canvasElement }) => {
208
- await typeAndCreate(canvasElement, "Taxes");
209
- },
210
- };
211
-
212
- /**
213
- * The folder create was never confirmed within the wait bound — the timeout is
214
- * named distinctly, and no move runs.
215
- */
216
- export const CreateFolderTimedOut: Story = {
217
- name: "Create folder — timed out (retry)",
218
- render: () => (
219
- <MoveMailboxPicker
220
- mailboxes={mailboxes}
221
- onSelect={() => undefined}
222
- onCreateFolder={rejectingCreateFolder(TIMEOUT_MESSAGE)}
223
- />
224
- ),
225
- play: async ({ canvasElement }) => {
226
- await typeAndCreate(canvasElement, "Taxes");
227
- },
228
- };
229
-
230
- /**
231
- * Retry is a resume: the first create times out, and pressing the create row
232
- * again with the same name resolves and moves — the hook re-waits on the folder
233
- * it already made rather than re-creating it.
234
- */
235
- export const CreateFolderRetrySucceeds: Story = {
236
- name: "Create folder — retry resumes and moves",
237
- render: () => {
238
- const RetryStage = () => {
239
- const [moved, setMoved] = useState<string | null>(null);
240
- return (
241
- <div className="flex flex-col">
242
- <MoveMailboxPicker
243
- mailboxes={mailboxes}
244
- onSelect={setMoved}
245
- onCreateFolder={failThenSucceedCreateFolder()}
246
- />
247
- {moved && (
248
- <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
249
- Moved to {moved}
250
- </p>
251
- )}
252
- </div>
253
- );
254
- };
255
- return <RetryStage />;
256
- },
257
- play: async ({ canvasElement }) => {
258
- await typeAndCreate(canvasElement, "Taxes");
259
- await tick();
260
- const retry = Array.from(
261
- canvasElement.querySelectorAll<HTMLButtonElement>("button"),
262
- ).find((button) => button.textContent?.includes('Create "Taxes"'));
263
- retry?.click();
264
- },
265
- };
266
-
267
- /**
268
- * Closing the picker while "Creating folder…" is in flight aborts the wait: the
269
- * create promise rejects with an AbortError, so a folder that would confirm later
270
- * never fires the move after the picker is gone. Here "Close picker" unmounts it
271
- * mid-wait; no "Moved to" line appears.
272
- */
273
- export const CreateFolderClosedMidWait: Story = {
274
- name: "Create folder — closing aborts the move",
275
- render: () => {
276
- const AbortStage = () => {
277
- const [open, setOpen] = useState(true);
278
- const [moved, setMoved] = useState<string | null>(null);
279
- return (
280
- <div className="flex flex-col">
281
- <button
282
- type="button"
283
- onClick={() => setOpen(false)}
284
- className="border-b border-line px-3 py-2 text-left text-xs text-fg-muted"
285
- >
286
- Close picker
287
- </button>
288
- {open && (
289
- <MoveMailboxPicker
290
- mailboxes={mailboxes}
291
- onSelect={setMoved}
292
- onCreateFolder={abortAwareCreateFolder}
293
- />
294
- )}
295
- {moved && (
296
- <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
297
- Moved to {moved}
298
- </p>
299
- )}
300
- </div>
301
- );
302
- };
303
- return <AbortStage />;
304
- },
305
- play: async ({ canvasElement }) => {
306
- await typeAndCreate(canvasElement, "Taxes");
307
- await tick();
308
- const close = Array.from(
309
- canvasElement.querySelectorAll<HTMLButtonElement>("button"),
310
- ).find((button) => button.textContent?.trim() === "Close picker");
311
- close?.click();
312
- },
313
- };