@remit/ui 0.0.69 → 0.0.71
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/auto-moved-badge.tsx +1 -3
- package/src/components/brief-sections.tsx +0 -13
- package/src/components/dialog.tsx +2 -5
- package/src/components/filter-rule-editor.tsx +0 -43
- package/src/components/filter-rule.render.test.ts +0 -49
- package/src/components/folder-tree-picker.stories.tsx +12 -25
- package/src/components/primitives.stories.tsx +0 -30
- 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/search-chip-input.tsx +3 -15
- package/src/components/selection-wizard.render.test.ts +14 -7
- package/src/components/selection-wizard.tsx +16 -18
- package/src/index.ts +0 -15
- package/src/components/list-item.tsx +0 -39
- package/src/components/message-header.render.test.ts +0 -58
- package/src/components/message-header.stories.tsx +0 -79
- package/src/components/message-header.tsx +0 -59
- 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/src/index.ts
CHANGED
|
@@ -184,12 +184,8 @@ export {
|
|
|
184
184
|
} from "./components/filter-rule.js";
|
|
185
185
|
export {
|
|
186
186
|
type ClauseEditState,
|
|
187
|
-
FilterRuleDialog,
|
|
188
|
-
type FilterRuleDialogProps,
|
|
189
187
|
FilterRuleEditor,
|
|
190
188
|
type FilterRuleEditorProps,
|
|
191
|
-
FilterRuleSheet,
|
|
192
|
-
type FilterRuleSheetProps,
|
|
193
189
|
} from "./components/filter-rule-editor.js";
|
|
194
190
|
export {
|
|
195
191
|
FilterSheet,
|
|
@@ -273,7 +269,6 @@ export {
|
|
|
273
269
|
type LabelChipData,
|
|
274
270
|
type LabelChipProps,
|
|
275
271
|
} from "./components/label-chip.js";
|
|
276
|
-
export { ListItem, type ListItemProps } from "./components/list-item.js";
|
|
277
272
|
export {
|
|
278
273
|
type MailAction,
|
|
279
274
|
MailActionToolbar,
|
|
@@ -288,10 +283,6 @@ export {
|
|
|
288
283
|
MessageBodyView,
|
|
289
284
|
type MessageBodyViewProps,
|
|
290
285
|
} from "./components/message-body-view.js";
|
|
291
|
-
export {
|
|
292
|
-
MessageHeader,
|
|
293
|
-
type MessageHeaderProps,
|
|
294
|
-
} from "./components/message-header.js";
|
|
295
286
|
export { MessageListPane } from "./components/message-list-pane.js";
|
|
296
287
|
export {
|
|
297
288
|
type FilterReach,
|
|
@@ -330,12 +321,6 @@ export {
|
|
|
330
321
|
MobileSearchView,
|
|
331
322
|
type MobileSearchViewProps,
|
|
332
323
|
} 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
324
|
export {
|
|
340
325
|
NavSidebar,
|
|
341
326
|
type NavSidebarProps,
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
-
import { cn } from "../lib/cn.js";
|
|
3
|
-
|
|
4
|
-
export interface ListItemProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
-
leading?: ReactNode;
|
|
6
|
-
trailing?: ReactNode;
|
|
7
|
-
active?: boolean;
|
|
8
|
-
unread?: boolean;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function ListItem({
|
|
12
|
-
leading,
|
|
13
|
-
trailing,
|
|
14
|
-
active,
|
|
15
|
-
unread,
|
|
16
|
-
className,
|
|
17
|
-
children,
|
|
18
|
-
...props
|
|
19
|
-
}: ListItemProps) {
|
|
20
|
-
return (
|
|
21
|
-
<div
|
|
22
|
-
className={cn(
|
|
23
|
-
"group relative flex cursor-pointer items-start gap-3 px-4 py-3 transition-colors",
|
|
24
|
-
active ? "bg-accent-2-soft" : "hover:bg-surface-sunken",
|
|
25
|
-
className,
|
|
26
|
-
)}
|
|
27
|
-
{...props}
|
|
28
|
-
>
|
|
29
|
-
{unread && (
|
|
30
|
-
<span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
|
|
31
|
-
)}
|
|
32
|
-
{leading && <div className="shrink-0 pt-0.5">{leading}</div>}
|
|
33
|
-
<div className="min-w-0 flex-1">{children}</div>
|
|
34
|
-
{trailing && (
|
|
35
|
-
<div className="shrink-0 text-2xs text-fg-subtle">{trailing}</div>
|
|
36
|
-
)}
|
|
37
|
-
</div>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
@@ -1,58 +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 type { EnvelopeAddress } from "./address-display.js";
|
|
6
|
-
import { MessageHeader, type MessageHeaderProps } from "./message-header.js";
|
|
7
|
-
|
|
8
|
-
const ada: EnvelopeAddress = {
|
|
9
|
-
displayName: "Ada Lovelace",
|
|
10
|
-
normalizedEmail: "ada@example.com",
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const base: MessageHeaderProps = {
|
|
14
|
-
subject: "Quarterly numbers",
|
|
15
|
-
from: [ada],
|
|
16
|
-
to: [{ normalizedEmail: "team@example.com" }],
|
|
17
|
-
date: "Mon, 23 Jun 2026, 14:00",
|
|
18
|
-
senderTrust: "wellknown",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
describe("MessageHeader", () => {
|
|
22
|
-
it("renders subject, addresses and the formatted date", () => {
|
|
23
|
-
const html = renderToString(createElement(MessageHeader, base));
|
|
24
|
-
assert.match(html, /Quarterly numbers/);
|
|
25
|
-
assert.match(html, /Ada Lovelace/);
|
|
26
|
-
assert.match(html, /team@example.com/);
|
|
27
|
-
assert.match(html, /Mon, 23 Jun 2026, 14:00/);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("shows a fallback for a missing subject", () => {
|
|
31
|
-
const html = renderToString(
|
|
32
|
-
createElement(MessageHeader, { ...base, subject: undefined }),
|
|
33
|
-
);
|
|
34
|
-
assert.match(html, /\(No subject\)/);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("renders the category badge and trust indicator", () => {
|
|
38
|
-
const html = renderToString(
|
|
39
|
-
createElement(MessageHeader, {
|
|
40
|
-
...base,
|
|
41
|
-
category: "newsletter",
|
|
42
|
-
senderTrust: "vip",
|
|
43
|
-
}),
|
|
44
|
-
);
|
|
45
|
-
assert.match(html, /aria-label="Category: newsletter"/);
|
|
46
|
-
assert.match(html, /aria-label="VIP sender"/);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("renders an actions slot when provided", () => {
|
|
50
|
-
const html = renderToString(
|
|
51
|
-
createElement(MessageHeader, {
|
|
52
|
-
...base,
|
|
53
|
-
actions: createElement("button", { type: "button" }, "Menu"),
|
|
54
|
-
}),
|
|
55
|
-
);
|
|
56
|
-
assert.match(html, /Menu/);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Menu } from "lucide-react";
|
|
3
|
-
import type { EnvelopeAddress } from "./address-display.js";
|
|
4
|
-
import { MessageHeader } from "./message-header.js";
|
|
5
|
-
|
|
6
|
-
const meta: Meta<typeof MessageHeader> = {
|
|
7
|
-
title: "Mail/MessageHeader",
|
|
8
|
-
component: MessageHeader,
|
|
9
|
-
parameters: { layout: "fullscreen" },
|
|
10
|
-
};
|
|
11
|
-
export default meta;
|
|
12
|
-
|
|
13
|
-
type Story = StoryObj<typeof MessageHeader>;
|
|
14
|
-
|
|
15
|
-
const named = (name: string, email: string): EnvelopeAddress => ({
|
|
16
|
-
displayName: name,
|
|
17
|
-
normalizedEmail: email,
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const trustedFrom: EnvelopeAddress = {
|
|
21
|
-
...named("Ada Lovelace", "ada@example.com"),
|
|
22
|
-
flags: { trusted: { value: true } },
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const date = "Mon, 23 Jun 2026, 14:00";
|
|
26
|
-
|
|
27
|
-
export const TrustedSender: Story = {
|
|
28
|
-
args: {
|
|
29
|
-
subject: "Quarterly numbers are in",
|
|
30
|
-
from: [trustedFrom],
|
|
31
|
-
to: [named("The team", "team@example.com")],
|
|
32
|
-
date,
|
|
33
|
-
senderTrust: "wellknown",
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export const NewSenderNewsletter: Story = {
|
|
38
|
-
name: "New sender + newsletter",
|
|
39
|
-
args: {
|
|
40
|
-
subject: "Welcome to the weekly digest",
|
|
41
|
-
from: [named("Digest", "hello@digest.example.com")],
|
|
42
|
-
to: [{ normalizedEmail: "you@example.com" }],
|
|
43
|
-
date,
|
|
44
|
-
category: "newsletter",
|
|
45
|
-
senderTrust: "unknown",
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
export const VipWithManyRecipients: Story = {
|
|
50
|
-
name: "VIP + many recipients",
|
|
51
|
-
args: {
|
|
52
|
-
subject: "Board meeting follow-up",
|
|
53
|
-
from: [{ ...named("Grace Hopper", "grace@example.com") }],
|
|
54
|
-
to: [
|
|
55
|
-
named("Alan Turing", "alan@example.com"),
|
|
56
|
-
named("Katherine Johnson", "katherine@example.com"),
|
|
57
|
-
named("Dorothy Vaughan", "dorothy@example.com"),
|
|
58
|
-
named("Mary Jackson", "mary@example.com"),
|
|
59
|
-
],
|
|
60
|
-
cc: [named("Margaret Hamilton", "margaret@example.com")],
|
|
61
|
-
date,
|
|
62
|
-
senderTrust: "vip",
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
export const NoSubjectWithActions: Story = {
|
|
67
|
-
name: "No subject + actions slot",
|
|
68
|
-
args: {
|
|
69
|
-
from: [named("Someone", "someone@example.com")],
|
|
70
|
-
to: [{ normalizedEmail: "you@example.com" }],
|
|
71
|
-
date,
|
|
72
|
-
senderTrust: "wellknown",
|
|
73
|
-
actions: (
|
|
74
|
-
<button type="button" aria-label="Menu" className="text-fg-muted">
|
|
75
|
-
<Menu className="size-5" />
|
|
76
|
-
</button>
|
|
77
|
-
),
|
|
78
|
-
},
|
|
79
|
-
};
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
|
-
import { AddressList, type EnvelopeAddress } from "./address-display.js";
|
|
3
|
-
import { CategoryBadge, type MessageCategory } from "./category-badge.js";
|
|
4
|
-
import {
|
|
5
|
-
type SenderTrust,
|
|
6
|
-
SenderTrustIndicator,
|
|
7
|
-
} from "./sender-trust-indicator.js";
|
|
8
|
-
|
|
9
|
-
export interface MessageHeaderProps {
|
|
10
|
-
subject?: string;
|
|
11
|
-
from: EnvelopeAddress[];
|
|
12
|
-
to: EnvelopeAddress[];
|
|
13
|
-
cc?: EnvelopeAddress[];
|
|
14
|
-
/** Pre-formatted, human-readable date. The consumer owns formatting. */
|
|
15
|
-
date: string;
|
|
16
|
-
category?: MessageCategory;
|
|
17
|
-
senderTrust: SenderTrust;
|
|
18
|
-
/**
|
|
19
|
-
* Optional slot for surfaces (hamburger menu, etc) rendered inline on the
|
|
20
|
-
* right of the subject line. Kept generic so the header doesn't need to
|
|
21
|
-
* know what action set is in play.
|
|
22
|
-
*/
|
|
23
|
-
actions?: ReactNode;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const MessageHeader = ({
|
|
27
|
-
subject,
|
|
28
|
-
from,
|
|
29
|
-
to,
|
|
30
|
-
cc = [],
|
|
31
|
-
date,
|
|
32
|
-
category,
|
|
33
|
-
senderTrust,
|
|
34
|
-
actions,
|
|
35
|
-
}: MessageHeaderProps) => {
|
|
36
|
-
return (
|
|
37
|
-
<div className="border-b border-line p-4">
|
|
38
|
-
<div className="flex items-start justify-between gap-2 mb-3">
|
|
39
|
-
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
40
|
-
<h1 className="text-xl font-semibold truncate">
|
|
41
|
-
{subject || "(No subject)"}
|
|
42
|
-
</h1>
|
|
43
|
-
<CategoryBadge category={category} size="md" />
|
|
44
|
-
<SenderTrustIndicator senderTrust={senderTrust} size="md" />
|
|
45
|
-
</div>
|
|
46
|
-
{actions && <div className="shrink-0">{actions}</div>}
|
|
47
|
-
</div>
|
|
48
|
-
<div className="space-y-1">
|
|
49
|
-
<AddressList label="From" addresses={from} showTrustedBadge />
|
|
50
|
-
<AddressList label="To" addresses={to} />
|
|
51
|
-
{cc.length > 0 && <AddressList label="Cc" addresses={cc} />}
|
|
52
|
-
<div className="flex gap-2 text-sm">
|
|
53
|
-
<span className="text-fg-muted shrink-0 w-12">Date:</span>
|
|
54
|
-
<span className="text-fg">{date}</span>
|
|
55
|
-
</div>
|
|
56
|
-
</div>
|
|
57
|
-
</div>
|
|
58
|
-
);
|
|
59
|
-
};
|
|
@@ -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
|
-
});
|