@remit/ui 0.0.67 → 0.0.69
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-manage-actions.render.test.ts +51 -0
- package/src/components/folder-manage-actions.stories.tsx +94 -0
- package/src/components/folder-manage-actions.tsx +54 -0
- package/src/components/folder-manager.render.test.ts +80 -0
- package/src/components/folder-manager.stories.tsx +184 -0
- package/src/components/folder-manager.tsx +77 -0
- package/src/components/folder-rename-dialog.render.test.ts +51 -0
- package/src/components/folder-rename-dialog.stories.tsx +66 -0
- package/src/components/folder-rename-dialog.tsx +95 -0
- package/src/components/folder-row.render.test.ts +88 -0
- package/src/components/folder-row.stories.tsx +118 -0
- package/src/components/folder-row.tsx +156 -0
- package/src/components/folder-tree-picker.create.test.ts +155 -8
- package/src/components/folder-tree-picker.render.test.ts +12 -284
- package/src/components/folder-tree-picker.stories.tsx +20 -7
- package/src/components/folder-tree-picker.tsx +130 -534
- package/src/components/new-folder-action.render.test.ts +44 -0
- package/src/components/new-folder-action.stories.tsx +92 -0
- package/src/components/new-folder-action.tsx +59 -0
- package/src/components/new-folder-form.render.test.ts +64 -0
- package/src/components/new-folder-form.stories.tsx +74 -0
- package/src/components/new-folder-form.tsx +126 -0
- package/src/index.ts +47 -0
- package/src/lib/folder-tree-focus.test.ts +98 -0
- package/src/lib/folder-tree-focus.ts +52 -0
- package/src/lib/folder-tree.test.ts +281 -0
- package/src/lib/folder-tree.ts +201 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
import { Dialog } from "./dialog.js";
|
|
5
|
+
import { FieldLabel } from "./field-label.js";
|
|
6
|
+
import { Input } from "./input.js";
|
|
7
|
+
|
|
8
|
+
export interface FolderRenameDialogProps {
|
|
9
|
+
open: boolean;
|
|
10
|
+
/** The folder as it reads now. */
|
|
11
|
+
folderLabel: string;
|
|
12
|
+
/** What the folder falls back to once the name is cleared. */
|
|
13
|
+
defaultLabel: string;
|
|
14
|
+
name: string;
|
|
15
|
+
onNameChange: (name: string) => void;
|
|
16
|
+
onSubmit: () => void;
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
pending?: boolean;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Renames a folder for this account. The name is Remit's own — the folder keeps
|
|
24
|
+
* its place and its path on the mail server — so clearing it puts the folder
|
|
25
|
+
* back to what the server calls it.
|
|
26
|
+
*/
|
|
27
|
+
export const FolderRenameDialog = ({
|
|
28
|
+
open,
|
|
29
|
+
folderLabel,
|
|
30
|
+
defaultLabel,
|
|
31
|
+
name,
|
|
32
|
+
onNameChange,
|
|
33
|
+
onSubmit,
|
|
34
|
+
onClose,
|
|
35
|
+
pending = false,
|
|
36
|
+
error,
|
|
37
|
+
}: FolderRenameDialogProps) => {
|
|
38
|
+
const fieldId = useId();
|
|
39
|
+
const title = `Rename ${folderLabel}`;
|
|
40
|
+
|
|
41
|
+
if (!open) return null;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Dialog open={open} onClose={onClose} title={title}>
|
|
45
|
+
<header className="flex items-center gap-2 border-b border-line px-5 py-3">
|
|
46
|
+
<span className="flex-1 text-sm font-semibold text-fg">{title}</span>
|
|
47
|
+
<Button
|
|
48
|
+
variant="ghost"
|
|
49
|
+
size="sm"
|
|
50
|
+
icon={<X className="size-3.5" />}
|
|
51
|
+
onClick={onClose}
|
|
52
|
+
aria-label="Cancel"
|
|
53
|
+
/>
|
|
54
|
+
</header>
|
|
55
|
+
<div className="space-y-3 px-5 py-4">
|
|
56
|
+
<div>
|
|
57
|
+
<FieldLabel htmlFor={fieldId}>Folder name</FieldLabel>
|
|
58
|
+
<Input
|
|
59
|
+
id={fieldId}
|
|
60
|
+
value={name}
|
|
61
|
+
placeholder={defaultLabel}
|
|
62
|
+
onChange={(event) => onNameChange(event.target.value)}
|
|
63
|
+
onKeyDown={(event) => {
|
|
64
|
+
if (event.key !== "Enter") return;
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
onSubmit();
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
<p className="text-xs text-fg-muted">
|
|
71
|
+
Shown everywhere in Remit. Leave it blank to use{" "}
|
|
72
|
+
<span className="font-medium text-fg">{defaultLabel}</span>.
|
|
73
|
+
</p>
|
|
74
|
+
{error && (
|
|
75
|
+
<p className="text-xs text-danger" role="alert">
|
|
76
|
+
{error}
|
|
77
|
+
</p>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
<footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
|
|
81
|
+
<Button variant="secondary" size="sm" onClick={onClose}>
|
|
82
|
+
Cancel
|
|
83
|
+
</Button>
|
|
84
|
+
<Button
|
|
85
|
+
variant="primary"
|
|
86
|
+
size="sm"
|
|
87
|
+
onClick={onSubmit}
|
|
88
|
+
disabled={pending}
|
|
89
|
+
>
|
|
90
|
+
{pending ? "Saving…" : "Save name"}
|
|
91
|
+
</Button>
|
|
92
|
+
</footer>
|
|
93
|
+
</Dialog>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
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 { FolderRow, type FolderRowProps } from "./folder-row.js";
|
|
6
|
+
|
|
7
|
+
const render = (props: Partial<FolderRowProps>) =>
|
|
8
|
+
renderToString(
|
|
9
|
+
createElement(FolderRow, {
|
|
10
|
+
label: "Travel",
|
|
11
|
+
depth: 0,
|
|
12
|
+
expanded: false,
|
|
13
|
+
ariaLabel: "Move to Travel",
|
|
14
|
+
...props,
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
describe("FolderRow", () => {
|
|
19
|
+
it("is a tree item that carries its nesting and its label", () => {
|
|
20
|
+
const html = render({ depth: 2 });
|
|
21
|
+
assert.match(html, /<button[^>]*role="treeitem"/);
|
|
22
|
+
assert.match(html, /aria-level="3"/);
|
|
23
|
+
assert.match(html, /aria-label="Move to Travel"/);
|
|
24
|
+
assert.match(html, /aria-expanded="false"/);
|
|
25
|
+
assert.match(html, />Travel</);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("indents by its depth and leaves a root flush", () => {
|
|
29
|
+
assert.match(render({ depth: 2 }), /width:28px/);
|
|
30
|
+
assert.doesNotMatch(render({ depth: 0 }), /style="width/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("turns the chevron once its children are on screen", () => {
|
|
34
|
+
assert.doesNotMatch(render({}), /rotate-90/);
|
|
35
|
+
assert.match(render({ expanded: true }), /rotate-90/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("checks the row that is the destination", () => {
|
|
39
|
+
const html = render({ selected: true });
|
|
40
|
+
assert.match(html, /aria-selected="true"/);
|
|
41
|
+
assert.match(html, /text-accent/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("leaves an unchosen row unselected and unchecked", () => {
|
|
45
|
+
const html = render({});
|
|
46
|
+
assert.match(html, /aria-selected="false"/);
|
|
47
|
+
assert.doesNotMatch(html, /text-accent/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("marks where the messages live now without disabling anything", () => {
|
|
51
|
+
const html = render({ current: true, currentTag: "current" });
|
|
52
|
+
assert.match(html, /aria-current="true"/);
|
|
53
|
+
assert.match(html, />current</);
|
|
54
|
+
assert.doesNotMatch(html, /disabled/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders a context branch as readable, not operable", () => {
|
|
58
|
+
const html = render({
|
|
59
|
+
context: true,
|
|
60
|
+
ariaLabel: "Travel (containing folder)",
|
|
61
|
+
});
|
|
62
|
+
assert.doesNotMatch(html, /<button/);
|
|
63
|
+
assert.match(html, /<div[^>]*role="treeitem"/);
|
|
64
|
+
assert.match(html, /aria-label="Travel \(containing folder\)"/);
|
|
65
|
+
assert.match(html, /opacity-60/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("insets the hairline to where the label starts", () => {
|
|
69
|
+
assert.match(render({ separated: true, depth: 1 }), /left:74px/);
|
|
70
|
+
assert.match(
|
|
71
|
+
render({ separated: true, depth: 1, context: true }),
|
|
72
|
+
/left:74px/,
|
|
73
|
+
);
|
|
74
|
+
assert.doesNotMatch(render({ depth: 1 }), /left:74px/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("keeps its actions beside the tree item rather than inside it", () => {
|
|
78
|
+
const html = render({
|
|
79
|
+
actions: createElement("button", { type: "button" }, "Delete"),
|
|
80
|
+
});
|
|
81
|
+
assert.ok(html.indexOf(">Delete<") > html.indexOf("</button>"));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("takes its place in a roving tab order", () => {
|
|
85
|
+
assert.match(render({ tabIndex: 0 }), /tabindex="0"/);
|
|
86
|
+
assert.match(render({ tabIndex: -1 }), /tabindex="-1"/);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { FolderRow } from "./folder-row.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof FolderRow> = {
|
|
6
|
+
title: "Mail/FolderRow",
|
|
7
|
+
component: FolderRow,
|
|
8
|
+
parameters: { layout: "centered" },
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
|
|
12
|
+
type Story = StoryObj<typeof FolderRow>;
|
|
13
|
+
|
|
14
|
+
function List({ children }: { children: ReactNode }) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="w-[320px] overflow-hidden rounded-lg border border-line bg-surface font-sans text-fg">
|
|
17
|
+
{children}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const Closed: Story = {
|
|
23
|
+
name: "Closed",
|
|
24
|
+
render: () => (
|
|
25
|
+
<List>
|
|
26
|
+
<FolderRow
|
|
27
|
+
label="Travel"
|
|
28
|
+
depth={0}
|
|
29
|
+
expanded={false}
|
|
30
|
+
ariaLabel="Move to Travel"
|
|
31
|
+
tabIndex={0}
|
|
32
|
+
/>
|
|
33
|
+
</List>
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const Open: Story = {
|
|
38
|
+
name: "Open, with its children indented under it",
|
|
39
|
+
render: () => (
|
|
40
|
+
<List>
|
|
41
|
+
<FolderRow
|
|
42
|
+
label="Travel"
|
|
43
|
+
depth={0}
|
|
44
|
+
expanded
|
|
45
|
+
ariaLabel="Move to Travel"
|
|
46
|
+
separated
|
|
47
|
+
/>
|
|
48
|
+
<FolderRow
|
|
49
|
+
label="Hotels"
|
|
50
|
+
depth={1}
|
|
51
|
+
expanded={false}
|
|
52
|
+
ariaLabel="Move to Hotels"
|
|
53
|
+
separated
|
|
54
|
+
/>
|
|
55
|
+
<FolderRow
|
|
56
|
+
label="Flights"
|
|
57
|
+
depth={1}
|
|
58
|
+
expanded={false}
|
|
59
|
+
ariaLabel="Move to Flights"
|
|
60
|
+
/>
|
|
61
|
+
</List>
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const Selected: Story = {
|
|
66
|
+
name: "The destination",
|
|
67
|
+
render: () => (
|
|
68
|
+
<List>
|
|
69
|
+
<FolderRow
|
|
70
|
+
label="Hotels"
|
|
71
|
+
depth={1}
|
|
72
|
+
expanded={false}
|
|
73
|
+
selected
|
|
74
|
+
ariaLabel="Move to Hotels"
|
|
75
|
+
/>
|
|
76
|
+
</List>
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Where the messages live now: a marker, never a disabled control. */
|
|
81
|
+
export const Current: Story = {
|
|
82
|
+
name: "The folder you are in",
|
|
83
|
+
render: () => (
|
|
84
|
+
<List>
|
|
85
|
+
<FolderRow
|
|
86
|
+
label="Inbox"
|
|
87
|
+
depth={0}
|
|
88
|
+
expanded={false}
|
|
89
|
+
current
|
|
90
|
+
currentTag="current"
|
|
91
|
+
ariaLabel="Inbox (current folder)"
|
|
92
|
+
/>
|
|
93
|
+
</List>
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** A branch on screen only to hold the match under it: it reads, it never operates. */
|
|
98
|
+
export const Context: Story = {
|
|
99
|
+
name: "A branch held open by a match below it",
|
|
100
|
+
render: () => (
|
|
101
|
+
<List>
|
|
102
|
+
<FolderRow
|
|
103
|
+
label="Travel"
|
|
104
|
+
depth={0}
|
|
105
|
+
expanded
|
|
106
|
+
context
|
|
107
|
+
ariaLabel="Travel (containing folder)"
|
|
108
|
+
separated
|
|
109
|
+
/>
|
|
110
|
+
<FolderRow
|
|
111
|
+
label="Hotels"
|
|
112
|
+
depth={1}
|
|
113
|
+
expanded={false}
|
|
114
|
+
ariaLabel="Move to Hotels"
|
|
115
|
+
/>
|
|
116
|
+
</List>
|
|
117
|
+
),
|
|
118
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Check, ChevronRight, Folder } from "lucide-react";
|
|
2
|
+
import type { ReactNode, Ref } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
|
|
5
|
+
export const FOLDER_ROW_BASE =
|
|
6
|
+
"flex min-h-11 min-w-0 flex-1 items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset";
|
|
7
|
+
|
|
8
|
+
export const FOLDER_INDENT_STEP = 14;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where a row's text starts: `px-3` plus the chevron and folder icon columns
|
|
12
|
+
* with their gaps. The hairline separator is inset to it, so the line begins
|
|
13
|
+
* under the label the way a native mobile list draws it.
|
|
14
|
+
*/
|
|
15
|
+
export const FOLDER_ROW_TEXT_INSET = 60;
|
|
16
|
+
|
|
17
|
+
export const FolderRowIndent = ({ depth }: { depth: number }) =>
|
|
18
|
+
depth > 0 ? (
|
|
19
|
+
<span
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
className="shrink-0"
|
|
22
|
+
style={{ width: depth * FOLDER_INDENT_STEP }}
|
|
23
|
+
/>
|
|
24
|
+
) : null;
|
|
25
|
+
|
|
26
|
+
export const FolderRowSeparator = ({ depth }: { depth: number }) => (
|
|
27
|
+
<span
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
className="pointer-events-none absolute right-0 bottom-0 h-px bg-line"
|
|
30
|
+
style={{ left: FOLDER_ROW_TEXT_INSET + depth * FOLDER_INDENT_STEP }}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export interface FolderRowProps {
|
|
35
|
+
label: string;
|
|
36
|
+
/** Indents the row and carries the nesting to `aria-level`. */
|
|
37
|
+
depth: number;
|
|
38
|
+
/** Its children are on screen. */
|
|
39
|
+
expanded: boolean;
|
|
40
|
+
/** What the row is announced as, e.g. `Move to Archive`. */
|
|
41
|
+
ariaLabel: string;
|
|
42
|
+
/** Chosen as the destination: checked, and announced as selected. */
|
|
43
|
+
selected?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* On screen only to hold a match below it. A branch reads and never
|
|
46
|
+
* operates, so it is neither focusable nor a target.
|
|
47
|
+
*/
|
|
48
|
+
context?: boolean;
|
|
49
|
+
/** Where the messages live now: a marker, never a disabled control. */
|
|
50
|
+
current?: boolean;
|
|
51
|
+
/** Inline tag shown on the current row, e.g. `current`. */
|
|
52
|
+
currentTag?: string;
|
|
53
|
+
/** A hairline under the label, drawn for every row but the last. */
|
|
54
|
+
separated?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Controls that operate on the folder rather than open it. They sit beside
|
|
57
|
+
* the row instead of inside it — a button nested in a button is invalid, and
|
|
58
|
+
* a tree item that swallows its own actions cannot be reached by keyboard.
|
|
59
|
+
*/
|
|
60
|
+
actions?: ReactNode;
|
|
61
|
+
tabIndex?: number;
|
|
62
|
+
onActivate?: () => void;
|
|
63
|
+
onFocus?: () => void;
|
|
64
|
+
ref?: Ref<HTMLButtonElement>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One folder in a tree: opens where you tap it, and says whether it is the
|
|
69
|
+
* destination, the folder you are in, or a branch on the way to a match.
|
|
70
|
+
*/
|
|
71
|
+
export const FolderRow = ({
|
|
72
|
+
label,
|
|
73
|
+
depth,
|
|
74
|
+
expanded,
|
|
75
|
+
ariaLabel,
|
|
76
|
+
selected = false,
|
|
77
|
+
context = false,
|
|
78
|
+
current = false,
|
|
79
|
+
currentTag,
|
|
80
|
+
separated = false,
|
|
81
|
+
actions,
|
|
82
|
+
tabIndex,
|
|
83
|
+
onActivate,
|
|
84
|
+
onFocus,
|
|
85
|
+
ref,
|
|
86
|
+
}: FolderRowProps) => {
|
|
87
|
+
const chevron = (
|
|
88
|
+
<ChevronRight
|
|
89
|
+
className={cn(
|
|
90
|
+
"size-4 shrink-0 text-fg-subtle transition-transform",
|
|
91
|
+
expanded && "rotate-90",
|
|
92
|
+
)}
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
const icon = (
|
|
97
|
+
<Folder className="size-4 shrink-0 text-fg-subtle" aria-hidden="true" />
|
|
98
|
+
);
|
|
99
|
+
if (context) {
|
|
100
|
+
return (
|
|
101
|
+
<div className="relative flex items-center">
|
|
102
|
+
{/* biome-ignore lint/a11y/useFocusableInteractive: an ancestor held on screen by a match below it — a branch, not a destination */}
|
|
103
|
+
<div
|
|
104
|
+
role="treeitem"
|
|
105
|
+
aria-level={depth + 1}
|
|
106
|
+
aria-selected={false}
|
|
107
|
+
aria-expanded={expanded}
|
|
108
|
+
aria-label={ariaLabel}
|
|
109
|
+
className={cn(FOLDER_ROW_BASE, "opacity-60")}
|
|
110
|
+
>
|
|
111
|
+
<FolderRowIndent depth={depth} />
|
|
112
|
+
{chevron}
|
|
113
|
+
{icon}
|
|
114
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
115
|
+
</div>
|
|
116
|
+
{actions}
|
|
117
|
+
{separated && <FolderRowSeparator depth={depth} />}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return (
|
|
122
|
+
<div className="relative flex items-center">
|
|
123
|
+
<button
|
|
124
|
+
ref={ref}
|
|
125
|
+
type="button"
|
|
126
|
+
role="treeitem"
|
|
127
|
+
aria-level={depth + 1}
|
|
128
|
+
aria-selected={selected}
|
|
129
|
+
aria-expanded={expanded}
|
|
130
|
+
aria-current={current ? "true" : undefined}
|
|
131
|
+
aria-label={ariaLabel}
|
|
132
|
+
tabIndex={tabIndex}
|
|
133
|
+
onClick={onActivate}
|
|
134
|
+
onFocus={onFocus}
|
|
135
|
+
className={cn(
|
|
136
|
+
FOLDER_ROW_BASE,
|
|
137
|
+
"hover:bg-surface-raised active:bg-surface-sunken",
|
|
138
|
+
current && "text-fg-muted",
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
141
|
+
<FolderRowIndent depth={depth} />
|
|
142
|
+
{chevron}
|
|
143
|
+
{icon}
|
|
144
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
145
|
+
{current && currentTag && (
|
|
146
|
+
<span className="shrink-0 text-xs text-fg-muted">{currentTag}</span>
|
|
147
|
+
)}
|
|
148
|
+
{selected && (
|
|
149
|
+
<Check className="size-4 shrink-0 text-accent" aria-hidden="true" />
|
|
150
|
+
)}
|
|
151
|
+
</button>
|
|
152
|
+
{actions}
|
|
153
|
+
{separated && <FolderRowSeparator depth={depth} />}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
@@ -24,10 +24,13 @@ const folders: FolderTreeNode[] = [
|
|
|
24
24
|
{ id: "inbox", label: "Inbox", path: "INBOX", isCurrent: true },
|
|
25
25
|
{ id: "travel", label: "Travel", path: "Travel" },
|
|
26
26
|
{ id: "hotels", label: "Hotels", path: "Travel/Hotels" },
|
|
27
|
+
{ id: "receipts", label: "Receipts", path: "Travel/Hotels/Receipts" },
|
|
28
|
+
{ id: "flights", label: "Flights", path: "Travel/Flights" },
|
|
27
29
|
{ id: "archive", label: "Archive", path: "Archive" },
|
|
28
30
|
];
|
|
29
31
|
|
|
30
32
|
let dom: JSDOM;
|
|
33
|
+
const scrolledIntoView: Element[] = [];
|
|
31
34
|
let container: HTMLElement;
|
|
32
35
|
let root: Root;
|
|
33
36
|
let act: typeof reactAct;
|
|
@@ -54,6 +57,12 @@ before(async () => {
|
|
|
54
57
|
(
|
|
55
58
|
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
56
59
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
60
|
+
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
|
|
61
|
+
configurable: true,
|
|
62
|
+
value: function scrollIntoView(this: Element) {
|
|
63
|
+
scrolledIntoView.push(this);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
57
66
|
|
|
58
67
|
const react = await import("react");
|
|
59
68
|
act = react.act;
|
|
@@ -68,6 +77,7 @@ after(() => {
|
|
|
68
77
|
});
|
|
69
78
|
|
|
70
79
|
beforeEach(() => {
|
|
80
|
+
scrolledIntoView.length = 0;
|
|
71
81
|
container = dom.window.document.createElement("div");
|
|
72
82
|
dom.window.document.body.append(container);
|
|
73
83
|
root = createRoot(container);
|
|
@@ -154,9 +164,13 @@ const open = async (...labels: string[]) => {
|
|
|
154
164
|
}
|
|
155
165
|
};
|
|
156
166
|
|
|
157
|
-
/**
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
/**
|
|
168
|
+
* The block a folder's create action lives in — and, while the form is open,
|
|
169
|
+
* the form that took its place. Keyed on the anchor rather than the action, so
|
|
170
|
+
* it still resolves once the action has given way.
|
|
171
|
+
*/
|
|
172
|
+
const createBlockOf = (anchor: string): Element | null =>
|
|
173
|
+
container.querySelector(`[data-create-anchor="${anchor}"]`);
|
|
160
174
|
|
|
161
175
|
const rowOf = (label: string): Element | null =>
|
|
162
176
|
byAriaLabel(`Move to ${label}`)?.closest('[role="none"]') ?? null;
|
|
@@ -229,6 +243,84 @@ describe("opening folders", () => {
|
|
|
229
243
|
});
|
|
230
244
|
});
|
|
231
245
|
|
|
246
|
+
describe("one open branch", () => {
|
|
247
|
+
it("closes the folder that was open when another opens", async () => {
|
|
248
|
+
await mount({});
|
|
249
|
+
await open("Travel");
|
|
250
|
+
assert.ok(byAriaLabel("Move to Hotels"));
|
|
251
|
+
await open("Archive");
|
|
252
|
+
assert.equal(byAriaLabel("Move to Hotels"), null);
|
|
253
|
+
assert.equal(
|
|
254
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
|
|
255
|
+
"false",
|
|
256
|
+
);
|
|
257
|
+
assert.equal(
|
|
258
|
+
byAriaLabel("Move to Archive")?.getAttribute("aria-expanded"),
|
|
259
|
+
"true",
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("keeps the ancestors of the folder being opened open", async () => {
|
|
264
|
+
await mount({});
|
|
265
|
+
await open("Travel", "Hotels");
|
|
266
|
+
assert.ok(byAriaLabel("Move to Receipts"));
|
|
267
|
+
assert.equal(
|
|
268
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
|
|
269
|
+
"true",
|
|
270
|
+
);
|
|
271
|
+
assert.equal(
|
|
272
|
+
byAriaLabel("Move to Hotels")?.getAttribute("aria-expanded"),
|
|
273
|
+
"true",
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("closes a sibling branch without closing the folder holding both", async () => {
|
|
278
|
+
await mount({});
|
|
279
|
+
await open("Travel", "Hotels");
|
|
280
|
+
await open("Flights");
|
|
281
|
+
assert.equal(byAriaLabel("Move to Receipts"), null);
|
|
282
|
+
assert.equal(
|
|
283
|
+
byAriaLabel("Move to Hotels")?.getAttribute("aria-expanded"),
|
|
284
|
+
"false",
|
|
285
|
+
);
|
|
286
|
+
assert.equal(
|
|
287
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
|
|
288
|
+
"true",
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("leaves one create action inside the branch that stayed open", async () => {
|
|
293
|
+
await mount({ onCreateFolder: resolving });
|
|
294
|
+
await open("Travel", "Hotels");
|
|
295
|
+
await open("Archive");
|
|
296
|
+
assert.equal(byAriaLabel("New folder inside Travel"), null);
|
|
297
|
+
assert.equal(byAriaLabel("New folder inside Hotels"), null);
|
|
298
|
+
assert.ok(byAriaLabel("New folder inside Archive"));
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("create action prominence", () => {
|
|
303
|
+
it("keeps the pinned action prominent and the nested one quiet", async () => {
|
|
304
|
+
await mount({ onCreateFolder: resolving });
|
|
305
|
+
assert.equal(byAriaLabel("New folder")?.dataset.prominence, "prominent");
|
|
306
|
+
await open("Travel");
|
|
307
|
+
assert.equal(
|
|
308
|
+
byAriaLabel("New folder inside Travel")?.dataset.prominence,
|
|
309
|
+
"quiet",
|
|
310
|
+
);
|
|
311
|
+
assert.equal(byAriaLabel("New folder")?.dataset.prominence, "prominent");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("stays the same action either way", async () => {
|
|
315
|
+
await mount({ onCreateFolder: resolving });
|
|
316
|
+
await open("Travel");
|
|
317
|
+
const pinned = byAriaLabel("New folder");
|
|
318
|
+
const nested = byAriaLabel("New folder inside Travel");
|
|
319
|
+
assert.equal(pinned?.tagName, nested?.tagName);
|
|
320
|
+
assert.equal(pinned?.textContent, nested?.textContent);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
232
324
|
describe("create form anchoring", () => {
|
|
233
325
|
it("offers the action at the end of an opened folder's children", async () => {
|
|
234
326
|
await mount({ onCreateFolder: resolving });
|
|
@@ -242,7 +334,7 @@ describe("create form anchoring", () => {
|
|
|
242
334
|
await mount({ onCreateFolder: resolving });
|
|
243
335
|
await open("Travel");
|
|
244
336
|
await click(byAriaLabel("New folder inside Travel"));
|
|
245
|
-
assert.ok(createBlockOf("
|
|
337
|
+
assert.ok(createBlockOf("travel")?.querySelector("input"));
|
|
246
338
|
assert.equal(rowOf("Travel")?.querySelector("input"), null);
|
|
247
339
|
});
|
|
248
340
|
|
|
@@ -250,7 +342,7 @@ describe("create form anchoring", () => {
|
|
|
250
342
|
await mount({ onCreateFolder: resolving });
|
|
251
343
|
await open("Travel");
|
|
252
344
|
await click(byAriaLabel("New folder inside Travel"));
|
|
253
|
-
assert.match(createBlockOf("
|
|
345
|
+
assert.match(createBlockOf("travel")?.textContent ?? "", /Inside\s*Travel/);
|
|
254
346
|
assert.equal(container.querySelector("select"), null);
|
|
255
347
|
});
|
|
256
348
|
|
|
@@ -259,8 +351,8 @@ describe("create form anchoring", () => {
|
|
|
259
351
|
await open("Travel", "Hotels");
|
|
260
352
|
await click(byAriaLabel("New folder inside Travel"));
|
|
261
353
|
await click(byAriaLabel("New folder inside Hotels"));
|
|
262
|
-
assert.equal(createBlockOf("
|
|
263
|
-
assert.ok(createBlockOf("
|
|
354
|
+
assert.equal(createBlockOf("travel")?.querySelector("input"), null);
|
|
355
|
+
assert.ok(createBlockOf("hotels")?.querySelector("input"));
|
|
264
356
|
});
|
|
265
357
|
|
|
266
358
|
it("creates at top level from the action pinned above the tree", async () => {
|
|
@@ -285,6 +377,61 @@ describe("create form anchoring", () => {
|
|
|
285
377
|
});
|
|
286
378
|
});
|
|
287
379
|
|
|
380
|
+
describe("the form takes the place of the action that opened it", () => {
|
|
381
|
+
it("replaces the pinned action", async () => {
|
|
382
|
+
await mount({ onCreateFolder: resolving });
|
|
383
|
+
await click(byAriaLabel("New folder"));
|
|
384
|
+
assert.equal(byAriaLabel("New folder"), null);
|
|
385
|
+
assert.ok(createBlockOf("top")?.querySelector("input"));
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("replaces a nested action and leaves the others alone", async () => {
|
|
389
|
+
await mount({ onCreateFolder: resolving });
|
|
390
|
+
await open("Travel", "Hotels");
|
|
391
|
+
await click(byAriaLabel("New folder inside Hotels"));
|
|
392
|
+
assert.equal(byAriaLabel("New folder inside Hotels"), null);
|
|
393
|
+
assert.ok(byAriaLabel("New folder inside Travel"));
|
|
394
|
+
assert.ok(byAriaLabel("New folder"));
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("puts the action back once the form closes", async () => {
|
|
398
|
+
await mount({ onCreateFolder: resolving });
|
|
399
|
+
await open("Travel");
|
|
400
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
401
|
+
await click(byText("Cancel"));
|
|
402
|
+
assert.ok(byAriaLabel("New folder inside Travel"));
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("an opened form is reachable", () => {
|
|
407
|
+
it("brings the whole form into view, buttons included", async () => {
|
|
408
|
+
await mount({ onCreateFolder: resolving });
|
|
409
|
+
await open("Archive");
|
|
410
|
+
await click(byAriaLabel("New folder inside Archive"));
|
|
411
|
+
const scrolled = scrolledIntoView.at(-1);
|
|
412
|
+
assert.ok(scrolled, "nothing was scrolled into view");
|
|
413
|
+
assert.ok(scrolled.contains(nameField()));
|
|
414
|
+
assert.ok(scrolled.contains(byText("Create folder") ?? null));
|
|
415
|
+
assert.ok(scrolled.contains(byText("Cancel") ?? null));
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("scrolls the list rather than the page", async () => {
|
|
419
|
+
await mount({ onCreateFolder: resolving });
|
|
420
|
+
await open("Archive");
|
|
421
|
+
await click(byAriaLabel("New folder inside Archive"));
|
|
422
|
+
const tree = container.querySelector('[role="tree"]');
|
|
423
|
+
assert.ok(tree?.contains(scrolledIntoView.at(-1) ?? null));
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("keeps room below the last row for a form opened from it", async () => {
|
|
427
|
+
await mount({ onCreateFolder: resolving });
|
|
428
|
+
assert.match(
|
|
429
|
+
container.querySelector('[role="tree"]')?.className ?? "",
|
|
430
|
+
/\bpb-\d/,
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
288
435
|
describe("create wait", () => {
|
|
289
436
|
it("passes the opened folder's path as the parent and selects what comes back", async () => {
|
|
290
437
|
const calls: Array<[string, string]> = [];
|
|
@@ -335,7 +482,7 @@ describe("create wait", () => {
|
|
|
335
482
|
await click(byText("Create folder"));
|
|
336
483
|
const alert = container.querySelector('[role="alert"]');
|
|
337
484
|
assert.equal(alert?.textContent, "The mail server refused that name.");
|
|
338
|
-
assert.ok(createBlockOf("
|
|
485
|
+
assert.ok(createBlockOf("travel")?.querySelector("input"));
|
|
339
486
|
});
|
|
340
487
|
|
|
341
488
|
it("says what is missing instead of going dead on an empty name", async () => {
|