@remit/ui 0.0.69 → 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.
- package/package.json +1 -1
- package/src/components/folder-tree-picker.stories.tsx +12 -25
- package/src/components/rescue-from-spam-flow.render.test.ts +4 -4
- package/src/components/rescue-from-spam-flow.stories.tsx +9 -7
- package/src/components/rescue-from-spam-flow.tsx +20 -16
- package/src/components/selection-wizard.render.test.ts +14 -7
- package/src/components/selection-wizard.tsx +15 -15
- package/src/index.ts +0 -6
- package/src/components/move-mailbox-picker.create.test.ts +0 -210
- package/src/components/move-mailbox-picker.render.test.ts +0 -113
- package/src/components/move-mailbox-picker.stories.tsx +0 -313
- package/src/components/move-mailbox-picker.tsx +0 -404
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
|
|
|
2
2
|
import { type ReactNode, useState } from "react";
|
|
3
3
|
import type { StepId } from "../lib/wizard-steps.js";
|
|
4
4
|
import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
|
|
5
|
-
import { FooterNav, WizardScreen } from "./selection-wizard.js";
|
|
5
|
+
import { FolderStepBody, FooterNav, WizardScreen } from "./selection-wizard.js";
|
|
6
6
|
|
|
7
7
|
const folders: FolderTreeNode[] = [
|
|
8
8
|
{ id: "mbx-inbox", label: "Inbox", path: "INBOX", isCurrent: true },
|
|
@@ -332,30 +332,17 @@ function WizardFolderStep() {
|
|
|
332
332
|
/>
|
|
333
333
|
}
|
|
334
334
|
>
|
|
335
|
-
<
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
onCreateFolder={(name, parentPath) =>
|
|
347
|
-
createFolder(name, parentPath).then((created) => {
|
|
348
|
-
setKnown((current) => [...current, created]);
|
|
349
|
-
return created;
|
|
350
|
-
})
|
|
351
|
-
}
|
|
352
|
-
labels={{
|
|
353
|
-
createPending:
|
|
354
|
-
"Waiting for the mail server to confirm the folder…",
|
|
355
|
-
}}
|
|
356
|
-
/>
|
|
357
|
-
</div>
|
|
358
|
-
</div>
|
|
335
|
+
<FolderStepBody
|
|
336
|
+
folders={known}
|
|
337
|
+
mailboxId={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
|
+
/>
|
|
359
346
|
</WizardScreen>
|
|
360
347
|
);
|
|
361
348
|
}
|
|
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import { createElement } from "react";
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
5
|
-
import type {
|
|
5
|
+
import type { FolderTreeNode } from "./folder-tree-picker.js";
|
|
6
6
|
import type { RescueCandidate } from "./rescue-candidate-row.js";
|
|
7
7
|
import {
|
|
8
8
|
RescueFromSpamFlow,
|
|
@@ -34,9 +34,9 @@ const candidates: RescueCandidate[] = [
|
|
|
34
34
|
},
|
|
35
35
|
];
|
|
36
36
|
|
|
37
|
-
const folders:
|
|
38
|
-
{ id: "inbox", label: "Inbox" },
|
|
39
|
-
{ id: "spam", label: "Spam", isCurrent: true },
|
|
37
|
+
const folders: FolderTreeNode[] = [
|
|
38
|
+
{ id: "inbox", label: "Inbox", path: "INBOX" },
|
|
39
|
+
{ id: "spam", label: "Spam", path: "Junk", isCurrent: true },
|
|
40
40
|
];
|
|
41
41
|
|
|
42
42
|
const renderFlow = (open: boolean): string =>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { Button } from "./button.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { FolderTreeNode } from "./folder-tree-picker.js";
|
|
5
5
|
import type { RescueCandidate } from "./rescue-candidate-row.js";
|
|
6
6
|
import { RescueFromSpamFlow } from "./rescue-from-spam-flow.js";
|
|
7
7
|
|
|
@@ -54,12 +54,14 @@ const CANDIDATES: RescueCandidate[] = [
|
|
|
54
54
|
},
|
|
55
55
|
];
|
|
56
56
|
|
|
57
|
-
const FOLDERS:
|
|
58
|
-
{ id: "inbox", label: "Inbox" },
|
|
59
|
-
{ id: "spam", label: "Spam", isCurrent: true },
|
|
60
|
-
{ id: "receipts", label: "Receipts" },
|
|
61
|
-
{ id: "family", label: "Family" },
|
|
62
|
-
{ id: "work", label: "Work",
|
|
57
|
+
const FOLDERS: FolderTreeNode[] = [
|
|
58
|
+
{ id: "inbox", label: "Inbox", path: "INBOX" },
|
|
59
|
+
{ id: "spam", label: "Spam", path: "Junk", isCurrent: true },
|
|
60
|
+
{ id: "receipts", label: "Receipts", path: "Receipts" },
|
|
61
|
+
{ id: "family", label: "Family", path: "Family" },
|
|
62
|
+
{ id: "work", label: "Work", path: "Work" },
|
|
63
|
+
{ id: "work-clients", label: "Clients", path: "Work/Clients" },
|
|
64
|
+
{ id: "work-projects", label: "Projects", path: "Work/Projects" },
|
|
63
65
|
];
|
|
64
66
|
|
|
65
67
|
function Demo() {
|
|
@@ -3,10 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { Banner } from "./banner.js";
|
|
4
4
|
import { BottomSheet } from "./bottom-sheet.js";
|
|
5
5
|
import { Button } from "./button.js";
|
|
6
|
-
import {
|
|
7
|
-
type MoveMailboxOption,
|
|
8
|
-
MoveMailboxPicker,
|
|
9
|
-
} from "./move-mailbox-picker.js";
|
|
6
|
+
import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
|
|
10
7
|
import type { RescueCandidate } from "./rescue-candidate-row.js";
|
|
11
8
|
import {
|
|
12
9
|
groupRescueCandidatesBySender,
|
|
@@ -23,7 +20,9 @@ export interface RescueFromSpamFlowProps {
|
|
|
23
20
|
/** Where the move lands unless the user picks another folder. */
|
|
24
21
|
defaultDestinationId: string;
|
|
25
22
|
/** Folders the user can send the rescued mail to. */
|
|
26
|
-
availableFolders:
|
|
23
|
+
availableFolders: FolderTreeNode[];
|
|
24
|
+
/** The account's hierarchy separator, which the tree nests on. */
|
|
25
|
+
delimiter?: string;
|
|
27
26
|
/** Run the move. The flow shows the done state right after. */
|
|
28
27
|
onConfirmMove: (messageIds: string[], destinationId: string) => void;
|
|
29
28
|
/** Close the sheet without moving anything. */
|
|
@@ -42,7 +41,7 @@ export const rescueMoveConsequence = (
|
|
|
42
41
|
): string =>
|
|
43
42
|
`Moves ${count === 1 ? "this message" : `these ${count} ${plural(count)}`} out of Spam to ${destinationLabel} now. Nothing later.`;
|
|
44
43
|
|
|
45
|
-
const folderLabel = (folders:
|
|
44
|
+
const folderLabel = (folders: FolderTreeNode[], id: string): string =>
|
|
46
45
|
folders.find((f) => f.id === id)?.label ?? "Inbox";
|
|
47
46
|
|
|
48
47
|
function ExplainWhy() {
|
|
@@ -157,13 +156,15 @@ function ReviewStep({
|
|
|
157
156
|
function DestinationStep({
|
|
158
157
|
count,
|
|
159
158
|
folders,
|
|
159
|
+
delimiter,
|
|
160
160
|
destination,
|
|
161
161
|
onPick,
|
|
162
162
|
onMove,
|
|
163
163
|
onBack,
|
|
164
164
|
}: {
|
|
165
165
|
count: number;
|
|
166
|
-
folders:
|
|
166
|
+
folders: FolderTreeNode[];
|
|
167
|
+
delimiter?: string;
|
|
167
168
|
destination: string;
|
|
168
169
|
onPick: (id: string) => void;
|
|
169
170
|
onMove: () => void;
|
|
@@ -180,16 +181,17 @@ function DestinationStep({
|
|
|
180
181
|
</p>
|
|
181
182
|
</div>
|
|
182
183
|
|
|
183
|
-
<div className="min-h-0 flex-1 overflow-y-auto px-row-inset py-2">
|
|
184
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-row-inset py-2">
|
|
184
185
|
{picking ? (
|
|
185
|
-
<div className="overflow-hidden rounded-xl border border-line bg-surface">
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
186
|
+
<div className="flex min-h-0 flex-1 overflow-hidden rounded-xl border border-line bg-surface">
|
|
187
|
+
{/* Picking a folder also opens it, so the tree stays up: closing
|
|
188
|
+
on the first tap would put anything nested out of reach. The
|
|
189
|
+
consequence line below states the destination as it changes. */}
|
|
190
|
+
<FolderTreePicker
|
|
191
|
+
folders={folders}
|
|
192
|
+
selectedId={destination}
|
|
193
|
+
delimiter={delimiter}
|
|
194
|
+
onSelect={onPick}
|
|
193
195
|
onCancel={() => setPicking(false)}
|
|
194
196
|
/>
|
|
195
197
|
</div>
|
|
@@ -277,6 +279,7 @@ export function RescueFromSpamFlow({
|
|
|
277
279
|
candidates,
|
|
278
280
|
defaultDestinationId,
|
|
279
281
|
availableFolders,
|
|
282
|
+
delimiter,
|
|
280
283
|
onConfirmMove,
|
|
281
284
|
onCancel,
|
|
282
285
|
}: RescueFromSpamFlowProps) {
|
|
@@ -345,6 +348,7 @@ export function RescueFromSpamFlow({
|
|
|
345
348
|
<DestinationStep
|
|
346
349
|
count={selectedCount}
|
|
347
350
|
folders={availableFolders}
|
|
351
|
+
delimiter={delimiter}
|
|
348
352
|
destination={destination}
|
|
349
353
|
onPick={setDestination}
|
|
350
354
|
onMove={move}
|
|
@@ -76,14 +76,16 @@ const propertiesProps = {
|
|
|
76
76
|
};
|
|
77
77
|
|
|
78
78
|
const folderProps = {
|
|
79
|
-
|
|
80
|
-
{ id: "mbx-archive", label: "Archive" },
|
|
81
|
-
{ id: "mbx-travel", label: "Travel" },
|
|
82
|
-
{ id: "mbx-
|
|
79
|
+
folders: [
|
|
80
|
+
{ id: "mbx-archive", label: "Archive", path: "Archive" },
|
|
81
|
+
{ id: "mbx-travel", label: "Travel", path: "Travel" },
|
|
82
|
+
{ id: "mbx-travel-2026", label: "2026", path: "Travel/2026" },
|
|
83
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX", isCurrent: true },
|
|
83
84
|
],
|
|
84
85
|
mailboxId: "mbx-travel",
|
|
85
86
|
onSelect: noop,
|
|
86
|
-
onCreateFolder: () =>
|
|
87
|
+
onCreateFolder: () =>
|
|
88
|
+
Promise.resolve({ id: "mbx-new", label: "Hotels", path: "Travel/Hotels" }),
|
|
87
89
|
};
|
|
88
90
|
|
|
89
91
|
const draft = (over: Partial<WizardDraft> = {}): WizardDraft => ({
|
|
@@ -359,11 +361,16 @@ describe("FolderStepBody", () => {
|
|
|
359
361
|
assert.match(html, /Inbox \(current folder\)/);
|
|
360
362
|
});
|
|
361
363
|
|
|
362
|
-
it("
|
|
364
|
+
it("starts at the top level, with nested folders behind the one holding them", () => {
|
|
365
|
+
const html = renderToString(createElement(FolderStepBody, folderProps));
|
|
366
|
+
assert.doesNotMatch(html, /Move to 2026/);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("says how to reach a folder when none is chosen yet", () => {
|
|
363
370
|
const html = renderToString(
|
|
364
371
|
createElement(FolderStepBody, { ...folderProps, mailboxId: undefined }),
|
|
365
372
|
);
|
|
366
|
-
assert.match(text(html), /
|
|
373
|
+
assert.match(text(html), /Tap a folder to open it/);
|
|
367
374
|
});
|
|
368
375
|
});
|
|
369
376
|
|
|
@@ -69,11 +69,8 @@ import {
|
|
|
69
69
|
scopeLabel,
|
|
70
70
|
} from "./filter-rule.js";
|
|
71
71
|
import type { ClauseEditState } from "./filter-rule-editor.js";
|
|
72
|
+
import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
|
|
72
73
|
import { Input } from "./input.js";
|
|
73
|
-
import {
|
|
74
|
-
type MoveMailboxOption,
|
|
75
|
-
MoveMailboxPicker,
|
|
76
|
-
} from "./move-mailbox-picker.js";
|
|
77
74
|
import { ProgressBar } from "./progress-bar.js";
|
|
78
75
|
import type { SearchConversionNotice } from "./search-conversion.js";
|
|
79
76
|
import { SearchConversionNoticeView } from "./search-conversion-notice.js";
|
|
@@ -651,7 +648,7 @@ export interface FolderStepProps {
|
|
|
651
648
|
* (`buildMoveTargets`). The wizard does not reorder them: a second ordering
|
|
652
649
|
* beside the one the Move picker already applies would fight it.
|
|
653
650
|
*/
|
|
654
|
-
|
|
651
|
+
folders: readonly FolderTreeNode[];
|
|
655
652
|
/** The destination chosen so far. */
|
|
656
653
|
mailboxId?: string;
|
|
657
654
|
onSelect: (mailboxId: string) => void;
|
|
@@ -664,8 +661,11 @@ export interface FolderStepProps {
|
|
|
664
661
|
*/
|
|
665
662
|
onCreateFolder?: (
|
|
666
663
|
name: string,
|
|
664
|
+
parentPath: string,
|
|
667
665
|
signal?: AbortSignal,
|
|
668
|
-
) => Promise<
|
|
666
|
+
) => Promise<FolderTreeNode>;
|
|
667
|
+
/** The account's hierarchy separator, which the tree nests on. */
|
|
668
|
+
delimiter?: string;
|
|
669
669
|
/**
|
|
670
670
|
* Why there is no folder list to choose from — a selection spanning accounts
|
|
671
671
|
* has no single account whose folders these could be (#477 5.5). Said here
|
|
@@ -675,13 +675,14 @@ export interface FolderStepProps {
|
|
|
675
675
|
}
|
|
676
676
|
|
|
677
677
|
export function FolderStepBody({
|
|
678
|
-
|
|
678
|
+
folders,
|
|
679
679
|
mailboxId,
|
|
680
680
|
onSelect,
|
|
681
681
|
onCreateFolder,
|
|
682
|
+
delimiter,
|
|
682
683
|
restriction,
|
|
683
684
|
}: FolderStepProps) {
|
|
684
|
-
const chosen =
|
|
685
|
+
const chosen = folders.find((folder) => folder.id === mailboxId);
|
|
685
686
|
return (
|
|
686
687
|
<div className="flex min-h-0 flex-col gap-3">
|
|
687
688
|
<p
|
|
@@ -693,17 +694,16 @@ export function FolderStepBody({
|
|
|
693
694
|
{restriction ??
|
|
694
695
|
(chosen
|
|
695
696
|
? `Moving to ${chosen.label}.`
|
|
696
|
-
: "
|
|
697
|
+
: "Tap a folder to open it, or make a new one where you want it.")}
|
|
697
698
|
</p>
|
|
698
|
-
<div className="overflow-hidden rounded-lg border border-line bg-surface">
|
|
699
|
-
<
|
|
700
|
-
|
|
699
|
+
<div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-line bg-surface">
|
|
700
|
+
<FolderTreePicker
|
|
701
|
+
folders={folders}
|
|
702
|
+
selectedId={mailboxId}
|
|
701
703
|
onSelect={onSelect}
|
|
702
704
|
onCreateFolder={onCreateFolder}
|
|
703
|
-
|
|
705
|
+
delimiter={delimiter}
|
|
704
706
|
labels={{
|
|
705
|
-
searchPlaceholder: "Move to…",
|
|
706
|
-
optionLabel: (label) => `Move to ${label}`,
|
|
707
707
|
createPending: "Waiting for the mail server to confirm the folder…",
|
|
708
708
|
}}
|
|
709
709
|
/>
|
package/src/index.ts
CHANGED
|
@@ -330,12 +330,6 @@ export {
|
|
|
330
330
|
MobileSearchView,
|
|
331
331
|
type MobileSearchViewProps,
|
|
332
332
|
} from "./components/mobile-search-view.js";
|
|
333
|
-
export {
|
|
334
|
-
type MoveMailboxOption,
|
|
335
|
-
MoveMailboxPicker,
|
|
336
|
-
type MoveMailboxPickerLabels,
|
|
337
|
-
type MoveMailboxPickerProps,
|
|
338
|
-
} from "./components/move-mailbox-picker.js";
|
|
339
333
|
export {
|
|
340
334
|
NavSidebar,
|
|
341
335
|
type NavSidebarProps,
|
|
@@ -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
|
-
};
|
|
@@ -1,404 +0,0 @@
|
|
|
1
|
-
import { Search } from "lucide-react";
|
|
2
|
-
import {
|
|
3
|
-
type KeyboardEvent as ReactKeyboardEvent,
|
|
4
|
-
useCallback,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
useState,
|
|
9
|
-
} from "react";
|
|
10
|
-
import { isAbortError } from "../lib/abort.js";
|
|
11
|
-
import { cn } from "../lib/cn.js";
|
|
12
|
-
import { Input } from "./input.js";
|
|
13
|
-
|
|
14
|
-
export interface MoveMailboxOption {
|
|
15
|
-
/** Stable identity passed back to `onSelect`. */
|
|
16
|
-
id: string;
|
|
17
|
-
/** Display label shown in the row and matched against the search query. */
|
|
18
|
-
label: string;
|
|
19
|
-
/**
|
|
20
|
-
* The message's current folder. Rendered as a non-selectable row so the
|
|
21
|
-
* user sees where the message lives but can't move it onto itself.
|
|
22
|
-
*/
|
|
23
|
-
isCurrent?: boolean;
|
|
24
|
-
/**
|
|
25
|
-
* Optional secondary string matched alongside `label` (e.g. the full
|
|
26
|
-
* folder path so "gmail/" narrows nested folders). Never displayed.
|
|
27
|
-
*/
|
|
28
|
-
searchValue?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface MoveMailboxPickerLabels {
|
|
32
|
-
/** Search input placeholder. */
|
|
33
|
-
searchPlaceholder?: string;
|
|
34
|
-
/** Accessible label for the search input. */
|
|
35
|
-
searchAriaLabel?: string;
|
|
36
|
-
/** Accessible label for the listbox. */
|
|
37
|
-
listAriaLabel?: string;
|
|
38
|
-
/** Suffix announced for the current folder, e.g. `(current folder)`. */
|
|
39
|
-
currentSuffix?: string;
|
|
40
|
-
/** Inline tag shown on the current folder row. */
|
|
41
|
-
currentTag?: string;
|
|
42
|
-
/** Builds the empty-state message for a query that matches nothing. */
|
|
43
|
-
emptyMessage?: (query: string) => string;
|
|
44
|
-
/** Builds the accessible label for a selectable row, e.g. `Move to X`. */
|
|
45
|
-
optionLabel?: (label: string) => string;
|
|
46
|
-
/** Builds the label for the create-and-move row, e.g. `Create "Receipts"`. */
|
|
47
|
-
createLabel?: (query: string) => string;
|
|
48
|
-
/** Shown on the create row while the folder is being created. */
|
|
49
|
-
createPending?: string;
|
|
50
|
-
/** Shown when creating the folder fails. */
|
|
51
|
-
createError?: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface MoveMailboxPickerProps {
|
|
55
|
-
/**
|
|
56
|
-
* Destinations to show, already filtered, ordered and labeled by the app.
|
|
57
|
-
* The kit owns only search, focus and keyboard — never data shaping.
|
|
58
|
-
*/
|
|
59
|
-
mailboxes: readonly MoveMailboxOption[];
|
|
60
|
-
onSelect: (mailboxId: string) => void;
|
|
61
|
-
/**
|
|
62
|
-
* Create a folder named by the current search query. When provided and the
|
|
63
|
-
* query names no existing folder, a create-and-move row is offered at the
|
|
64
|
-
* bottom of the list; resolving it — once the mail server confirms the folder
|
|
65
|
-
* — yields the new folder, which is selected (moved into). The picker aborts
|
|
66
|
-
* the passed signal on unmount, so a folder that confirms after the picker is
|
|
67
|
-
* closed never fires the move. Absent means no create affordance renders.
|
|
68
|
-
*/
|
|
69
|
-
onCreateFolder?: (
|
|
70
|
-
name: string,
|
|
71
|
-
signal?: AbortSignal,
|
|
72
|
-
) => Promise<MoveMailboxOption>;
|
|
73
|
-
/**
|
|
74
|
-
* Called when the user dismisses the picker via Escape. Trigger consumers
|
|
75
|
-
* use this to close their popover/drawer; the picker never owns
|
|
76
|
-
* presentation, so it cannot close itself without help.
|
|
77
|
-
*/
|
|
78
|
-
onCancel?: () => void;
|
|
79
|
-
/**
|
|
80
|
-
* Mobile callers (bottom-sheet) pass `autoFocus` to focus the search input
|
|
81
|
-
* as soon as the sheet opens — keyboard accessory + immediate filter typing
|
|
82
|
-
* without an extra tap. Desktop dropdowns leave focus on the trigger so
|
|
83
|
-
* click-outside dismissal stays predictable.
|
|
84
|
-
*/
|
|
85
|
-
autoFocus?: boolean;
|
|
86
|
-
labels?: MoveMailboxPickerLabels;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const ROW_BASE =
|
|
90
|
-
"w-full text-left px-3 py-2.5 min-h-11 flex items-center gap-2 transition-colors text-sm rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
|
|
91
|
-
|
|
92
|
-
const defaultLabels: Required<MoveMailboxPickerLabels> = {
|
|
93
|
-
searchPlaceholder: "Move to…",
|
|
94
|
-
searchAriaLabel: "Filter folders",
|
|
95
|
-
listAriaLabel: "Destination folders",
|
|
96
|
-
currentSuffix: "(current folder)",
|
|
97
|
-
currentTag: "current",
|
|
98
|
-
emptyMessage: (query) => `No folders match "${query}"`,
|
|
99
|
-
optionLabel: (label) => `Move to ${label}`,
|
|
100
|
-
createLabel: (query) => `Create "${query}"`,
|
|
101
|
-
createPending: "Creating folder…",
|
|
102
|
-
createError: "Couldn't create that folder. Please try again.",
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const findFirstSelectable = (options: readonly MoveMailboxOption[]): number => {
|
|
106
|
-
for (let i = 0; i < options.length; i += 1) {
|
|
107
|
-
if (!options[i]?.isCurrent) return i;
|
|
108
|
-
}
|
|
109
|
-
return -1;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const findLastSelectable = (options: readonly MoveMailboxOption[]): number => {
|
|
113
|
-
for (let i = options.length - 1; i >= 0; i -= 1) {
|
|
114
|
-
if (!options[i]?.isCurrent) return i;
|
|
115
|
-
}
|
|
116
|
-
return -1;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const findNextSelectable = (
|
|
120
|
-
options: readonly MoveMailboxOption[],
|
|
121
|
-
from: number,
|
|
122
|
-
step: 1 | -1,
|
|
123
|
-
): number => {
|
|
124
|
-
const count = options.length;
|
|
125
|
-
if (count <= 0) return -1;
|
|
126
|
-
const start = from < 0 ? (step === 1 ? -1 : count) : from;
|
|
127
|
-
for (let offset = 1; offset <= count; offset += 1) {
|
|
128
|
-
const candidate = (((start + step * offset) % count) + count) % count;
|
|
129
|
-
if (!options[candidate]?.isCurrent) return candidate;
|
|
130
|
-
}
|
|
131
|
-
return -1;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const matchesQuery = (option: MoveMailboxOption, query: string): boolean => {
|
|
135
|
-
if (option.label.toLowerCase().includes(query)) return true;
|
|
136
|
-
return option.searchValue?.toLowerCase().includes(query) ?? false;
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Move-to-folder destination picker: an always-on search input over a
|
|
141
|
-
* roving-focus listbox. Data-agnostic — the app supplies pre-shaped,
|
|
142
|
-
* pre-ordered options and performs the move in `onSelect`; the kit owns search
|
|
143
|
-
* filtering, keyboard navigation and ARIA structure.
|
|
144
|
-
*/
|
|
145
|
-
export const MoveMailboxPicker = ({
|
|
146
|
-
mailboxes,
|
|
147
|
-
onSelect,
|
|
148
|
-
onCreateFolder,
|
|
149
|
-
onCancel,
|
|
150
|
-
autoFocus = false,
|
|
151
|
-
labels,
|
|
152
|
-
}: MoveMailboxPickerProps) => {
|
|
153
|
-
const text = { ...defaultLabels, ...labels };
|
|
154
|
-
const [query, setQuery] = useState("");
|
|
155
|
-
const [creating, setCreating] = useState(false);
|
|
156
|
-
const [createError, setCreateError] = useState<string>();
|
|
157
|
-
const [focusedIndex, setFocusedIndex] = useState<number>(() =>
|
|
158
|
-
findFirstSelectable(mailboxes),
|
|
159
|
-
);
|
|
160
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
161
|
-
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
162
|
-
// The create waits for the mail server to confirm the folder; abort it when
|
|
163
|
-
// the picker unmounts (its popover/drawer closes) so a late confirmation never
|
|
164
|
-
// fires the move after the picker is gone.
|
|
165
|
-
const createAbort = useRef<AbortController | null>(null);
|
|
166
|
-
useEffect(() => () => createAbort.current?.abort(), []);
|
|
167
|
-
|
|
168
|
-
useEffect(() => {
|
|
169
|
-
if (autoFocus) inputRef.current?.focus();
|
|
170
|
-
}, [autoFocus]);
|
|
171
|
-
|
|
172
|
-
const trimmedQuery = query.trim().toLowerCase();
|
|
173
|
-
const filtered = useMemo(() => {
|
|
174
|
-
if (!trimmedQuery) return [...mailboxes];
|
|
175
|
-
return mailboxes.filter((mailbox) => matchesQuery(mailbox, trimmedQuery));
|
|
176
|
-
}, [mailboxes, trimmedQuery]);
|
|
177
|
-
|
|
178
|
-
useEffect(() => {
|
|
179
|
-
setFocusedIndex((current) => {
|
|
180
|
-
if (
|
|
181
|
-
current >= 0 &&
|
|
182
|
-
current < filtered.length &&
|
|
183
|
-
!filtered[current]?.isCurrent
|
|
184
|
-
) {
|
|
185
|
-
return current;
|
|
186
|
-
}
|
|
187
|
-
return findFirstSelectable(filtered);
|
|
188
|
-
});
|
|
189
|
-
}, [filtered]);
|
|
190
|
-
|
|
191
|
-
useEffect(() => {
|
|
192
|
-
if (focusedIndex < 0) return;
|
|
193
|
-
const node = optionRefs.current[focusedIndex];
|
|
194
|
-
if (!node) return;
|
|
195
|
-
// Only steal focus if the user is already navigating the list — moving
|
|
196
|
-
// focus while they type in the filter input would trap them. The search
|
|
197
|
-
// input keeps focus until the first ArrowDown.
|
|
198
|
-
if (document.activeElement === inputRef.current) return;
|
|
199
|
-
node.focus();
|
|
200
|
-
}, [focusedIndex]);
|
|
201
|
-
|
|
202
|
-
const handleConfirm = useCallback(() => {
|
|
203
|
-
if (focusedIndex < 0) return;
|
|
204
|
-
const target = filtered[focusedIndex];
|
|
205
|
-
if (!target || target.isCurrent) return;
|
|
206
|
-
onSelect(target.id);
|
|
207
|
-
}, [focusedIndex, filtered, onSelect]);
|
|
208
|
-
|
|
209
|
-
const showCreate =
|
|
210
|
-
!!onCreateFolder &&
|
|
211
|
-
trimmedQuery.length > 0 &&
|
|
212
|
-
!mailboxes.some((mailbox) => mailbox.label.toLowerCase() === trimmedQuery);
|
|
213
|
-
|
|
214
|
-
const handleCreate = useCallback(() => {
|
|
215
|
-
if (!onCreateFolder) return;
|
|
216
|
-
const name = query.trim();
|
|
217
|
-
if (name === "") return;
|
|
218
|
-
setCreating(true);
|
|
219
|
-
setCreateError(undefined);
|
|
220
|
-
createAbort.current?.abort();
|
|
221
|
-
const controller = new AbortController();
|
|
222
|
-
createAbort.current = controller;
|
|
223
|
-
onCreateFolder(name, controller.signal)
|
|
224
|
-
.then((folder) => {
|
|
225
|
-
onSelect(folder.id);
|
|
226
|
-
setCreating(false);
|
|
227
|
-
})
|
|
228
|
-
.catch((error: unknown) => {
|
|
229
|
-
if (isAbortError(error)) return;
|
|
230
|
-
setCreateError(
|
|
231
|
-
error instanceof Error ? error.message : text.createError,
|
|
232
|
-
);
|
|
233
|
-
setCreating(false);
|
|
234
|
-
});
|
|
235
|
-
}, [onCreateFolder, query, onSelect, text.createError]);
|
|
236
|
-
|
|
237
|
-
const handleListKeyDown = useCallback(
|
|
238
|
-
(event: ReactKeyboardEvent<HTMLElement>) => {
|
|
239
|
-
switch (event.key) {
|
|
240
|
-
case "ArrowDown":
|
|
241
|
-
event.preventDefault();
|
|
242
|
-
setFocusedIndex((current) =>
|
|
243
|
-
findNextSelectable(filtered, current, 1),
|
|
244
|
-
);
|
|
245
|
-
return;
|
|
246
|
-
case "ArrowUp":
|
|
247
|
-
event.preventDefault();
|
|
248
|
-
setFocusedIndex((current) =>
|
|
249
|
-
findNextSelectable(filtered, current, -1),
|
|
250
|
-
);
|
|
251
|
-
return;
|
|
252
|
-
case "Home":
|
|
253
|
-
event.preventDefault();
|
|
254
|
-
setFocusedIndex(findFirstSelectable(filtered));
|
|
255
|
-
return;
|
|
256
|
-
case "End":
|
|
257
|
-
event.preventDefault();
|
|
258
|
-
setFocusedIndex(findLastSelectable(filtered));
|
|
259
|
-
return;
|
|
260
|
-
case "Enter":
|
|
261
|
-
case " ":
|
|
262
|
-
event.preventDefault();
|
|
263
|
-
handleConfirm();
|
|
264
|
-
return;
|
|
265
|
-
case "Escape":
|
|
266
|
-
event.preventDefault();
|
|
267
|
-
onCancel?.();
|
|
268
|
-
return;
|
|
269
|
-
default:
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
},
|
|
273
|
-
[filtered, handleConfirm, onCancel],
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
const handleInputKeyDown = useCallback(
|
|
277
|
-
(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
278
|
-
if (event.key === "ArrowDown") {
|
|
279
|
-
event.preventDefault();
|
|
280
|
-
if (filtered.length === 0) return;
|
|
281
|
-
const target = optionRefs.current[focusedIndex >= 0 ? focusedIndex : 0];
|
|
282
|
-
target?.focus();
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
if (event.key === "Escape") {
|
|
286
|
-
event.preventDefault();
|
|
287
|
-
onCancel?.();
|
|
288
|
-
}
|
|
289
|
-
},
|
|
290
|
-
[filtered.length, focusedIndex, onCancel],
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
return (
|
|
294
|
-
<div className="flex flex-col">
|
|
295
|
-
<Input
|
|
296
|
-
variant="inline"
|
|
297
|
-
className="border-b border-line px-3 py-2"
|
|
298
|
-
icon={<Search className="size-4" aria-hidden="true" />}
|
|
299
|
-
ref={inputRef}
|
|
300
|
-
type="search"
|
|
301
|
-
value={query}
|
|
302
|
-
onChange={(event) => setQuery(event.target.value)}
|
|
303
|
-
onKeyDown={handleInputKeyDown}
|
|
304
|
-
placeholder={text.searchPlaceholder}
|
|
305
|
-
aria-label={text.searchAriaLabel}
|
|
306
|
-
/>
|
|
307
|
-
<div
|
|
308
|
-
className="flex-1 overflow-y-auto py-1"
|
|
309
|
-
role="listbox"
|
|
310
|
-
aria-label={text.listAriaLabel}
|
|
311
|
-
onKeyDown={handleListKeyDown}
|
|
312
|
-
>
|
|
313
|
-
{filtered.length === 0 ? (
|
|
314
|
-
<div className="px-3 py-3 text-sm text-fg-muted" aria-live="polite">
|
|
315
|
-
{text.emptyMessage(query)}
|
|
316
|
-
</div>
|
|
317
|
-
) : (
|
|
318
|
-
filtered.map((mailbox, idx) => {
|
|
319
|
-
const isCurrent = mailbox.isCurrent ?? false;
|
|
320
|
-
const isFocused = idx === focusedIndex;
|
|
321
|
-
// The current folder is a "you are here" marker, not a target —
|
|
322
|
-
// rendered as a static option (never a disabled control) so the
|
|
323
|
-
// user can see where the message lives without a dead button.
|
|
324
|
-
// Selectable folders are real <button> options. Each interactive
|
|
325
|
-
// element IS the listbox option: a role="option" <div> wrapping a
|
|
326
|
-
// separately-interactive <button> is invalid ARIA.
|
|
327
|
-
if (isCurrent) {
|
|
328
|
-
return (
|
|
329
|
-
<div key={mailbox.id}>
|
|
330
|
-
{/* biome-ignore lint/a11y/useFocusableInteractive: focus managed by the parent listbox component */}
|
|
331
|
-
<div
|
|
332
|
-
role="option"
|
|
333
|
-
aria-selected={false}
|
|
334
|
-
aria-current="true"
|
|
335
|
-
aria-label={`${mailbox.label} ${text.currentSuffix}`}
|
|
336
|
-
className={cn(ROW_BASE, "opacity-60 bg-surface-sunken/40")}
|
|
337
|
-
>
|
|
338
|
-
<span className="truncate flex-1">{mailbox.label}</span>
|
|
339
|
-
<span className="text-xs text-fg-muted shrink-0">
|
|
340
|
-
{text.currentTag}
|
|
341
|
-
</span>
|
|
342
|
-
</div>
|
|
343
|
-
</div>
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
return (
|
|
347
|
-
<div key={mailbox.id}>
|
|
348
|
-
<button
|
|
349
|
-
ref={(node) => {
|
|
350
|
-
optionRefs.current[idx] = node;
|
|
351
|
-
}}
|
|
352
|
-
type="button"
|
|
353
|
-
role="option"
|
|
354
|
-
aria-selected={false}
|
|
355
|
-
tabIndex={isFocused ? 0 : -1}
|
|
356
|
-
onClick={() => onSelect(mailbox.id)}
|
|
357
|
-
onFocus={() => setFocusedIndex(idx)}
|
|
358
|
-
aria-label={text.optionLabel(mailbox.label)}
|
|
359
|
-
className={cn(ROW_BASE, "hover:bg-surface-raised")}
|
|
360
|
-
>
|
|
361
|
-
<span className="truncate flex-1">{mailbox.label}</span>
|
|
362
|
-
</button>
|
|
363
|
-
</div>
|
|
364
|
-
);
|
|
365
|
-
})
|
|
366
|
-
)}
|
|
367
|
-
</div>
|
|
368
|
-
{showCreate && (
|
|
369
|
-
<div className="border-t border-line p-1">
|
|
370
|
-
<button
|
|
371
|
-
type="button"
|
|
372
|
-
onClick={handleCreate}
|
|
373
|
-
disabled={creating}
|
|
374
|
-
className={cn(
|
|
375
|
-
ROW_BASE,
|
|
376
|
-
"font-medium text-accent-2 hover:bg-surface-raised disabled:opacity-60",
|
|
377
|
-
)}
|
|
378
|
-
>
|
|
379
|
-
<span className="truncate">
|
|
380
|
-
{creating ? text.createPending : text.createLabel(query.trim())}
|
|
381
|
-
</span>
|
|
382
|
-
</button>
|
|
383
|
-
{createError && (
|
|
384
|
-
<p className="px-3 py-1 text-xs text-danger" role="alert">
|
|
385
|
-
{createError}
|
|
386
|
-
</p>
|
|
387
|
-
)}
|
|
388
|
-
</div>
|
|
389
|
-
)}
|
|
390
|
-
</div>
|
|
391
|
-
);
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Pure selection/filter helpers exposed for unit testing the roving-focus and
|
|
396
|
-
* search logic without a DOM. Component consumers should use
|
|
397
|
-
* {@link MoveMailboxPicker} instead.
|
|
398
|
-
*/
|
|
399
|
-
export const moveMailboxPickerInternals = {
|
|
400
|
-
findFirstSelectable,
|
|
401
|
-
findLastSelectable,
|
|
402
|
-
findNextSelectable,
|
|
403
|
-
matchesQuery,
|
|
404
|
-
};
|