@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,44 @@
|
|
|
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
|
+
NewFolderAction,
|
|
7
|
+
type NewFolderActionProps,
|
|
8
|
+
} from "./new-folder-action.js";
|
|
9
|
+
|
|
10
|
+
const render = (props: Partial<NewFolderActionProps>) =>
|
|
11
|
+
renderToString(
|
|
12
|
+
createElement(NewFolderAction, {
|
|
13
|
+
label: "New folder",
|
|
14
|
+
ariaLabel: "New folder",
|
|
15
|
+
onOpen: () => {},
|
|
16
|
+
...props,
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describe("NewFolderAction", () => {
|
|
21
|
+
it("says what it makes, and where", () => {
|
|
22
|
+
const html = render({ ariaLabel: "New folder inside Travel" });
|
|
23
|
+
assert.match(html, /<button[^>]*aria-label="New folder inside Travel"/);
|
|
24
|
+
assert.match(html, />New folder</);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("takes the dashed add affordance where it is the pinned action", () => {
|
|
28
|
+
const html = render({});
|
|
29
|
+
assert.match(html, /data-prominence="prominent"/);
|
|
30
|
+
assert.match(html, /border-dashed/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("stays neutral where it is the last thing inside a folder", () => {
|
|
34
|
+
const html = render({ prominence: "quiet", depth: 1 });
|
|
35
|
+
assert.match(html, /data-prominence="quiet"/);
|
|
36
|
+
assert.doesNotMatch(html, /border-dashed/);
|
|
37
|
+
assert.match(html, /width:14px/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("insets the hairline to where the label starts", () => {
|
|
41
|
+
assert.match(render({ separated: true, depth: 1 }), /left:74px/);
|
|
42
|
+
assert.doesNotMatch(render({ depth: 1 }), /left:74px/);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { FolderRow } from "./folder-row.js";
|
|
4
|
+
import { NewFolderAction } from "./new-folder-action.js";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof NewFolderAction> = {
|
|
7
|
+
title: "Mail/NewFolderAction",
|
|
8
|
+
component: NewFolderAction,
|
|
9
|
+
parameters: { layout: "centered" },
|
|
10
|
+
};
|
|
11
|
+
export default meta;
|
|
12
|
+
|
|
13
|
+
type Story = StoryObj<typeof NewFolderAction>;
|
|
14
|
+
|
|
15
|
+
function List({ children }: { children: ReactNode }) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="w-[320px] overflow-hidden rounded-lg border border-line bg-surface font-sans text-fg">
|
|
18
|
+
{children}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Pinned above a list, where it is the loudest thing a folder tree offers. */
|
|
24
|
+
export const Prominent: Story = {
|
|
25
|
+
name: "Prominent (pinned above the list)",
|
|
26
|
+
render: () => (
|
|
27
|
+
<List>
|
|
28
|
+
<NewFolderAction
|
|
29
|
+
label="New folder"
|
|
30
|
+
ariaLabel="New folder"
|
|
31
|
+
onOpen={() => undefined}
|
|
32
|
+
/>
|
|
33
|
+
</List>
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The last thing inside an opened folder, subordinate to the folder it sits in. */
|
|
38
|
+
export const Quiet: Story = {
|
|
39
|
+
name: "Quiet (inside an opened folder)",
|
|
40
|
+
render: () => (
|
|
41
|
+
<List>
|
|
42
|
+
<FolderRow
|
|
43
|
+
label="Travel"
|
|
44
|
+
depth={0}
|
|
45
|
+
expanded
|
|
46
|
+
ariaLabel="Move to Travel"
|
|
47
|
+
separated
|
|
48
|
+
/>
|
|
49
|
+
<FolderRow
|
|
50
|
+
label="Hotels"
|
|
51
|
+
depth={1}
|
|
52
|
+
expanded={false}
|
|
53
|
+
ariaLabel="Move to Hotels"
|
|
54
|
+
separated
|
|
55
|
+
/>
|
|
56
|
+
<NewFolderAction
|
|
57
|
+
label="New folder"
|
|
58
|
+
ariaLabel="New folder inside Travel"
|
|
59
|
+
depth={1}
|
|
60
|
+
prominence="quiet"
|
|
61
|
+
onOpen={() => undefined}
|
|
62
|
+
/>
|
|
63
|
+
</List>
|
|
64
|
+
),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const BothTreatments: Story = {
|
|
68
|
+
name: "Both treatments at once",
|
|
69
|
+
render: () => (
|
|
70
|
+
<List>
|
|
71
|
+
<NewFolderAction
|
|
72
|
+
label="New folder"
|
|
73
|
+
ariaLabel="New folder"
|
|
74
|
+
onOpen={() => undefined}
|
|
75
|
+
/>
|
|
76
|
+
<FolderRow
|
|
77
|
+
label="Finance"
|
|
78
|
+
depth={0}
|
|
79
|
+
expanded
|
|
80
|
+
ariaLabel="Move to Finance"
|
|
81
|
+
separated
|
|
82
|
+
/>
|
|
83
|
+
<NewFolderAction
|
|
84
|
+
label="New folder"
|
|
85
|
+
ariaLabel="New folder inside Finance"
|
|
86
|
+
depth={1}
|
|
87
|
+
prominence="quiet"
|
|
88
|
+
onOpen={() => undefined}
|
|
89
|
+
/>
|
|
90
|
+
</List>
|
|
91
|
+
),
|
|
92
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { FolderPlus } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import {
|
|
4
|
+
FOLDER_ROW_BASE,
|
|
5
|
+
FolderRowIndent,
|
|
6
|
+
FolderRowSeparator,
|
|
7
|
+
} from "./folder-row.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How loudly a create action asks to be noticed. The pinned top-level one takes
|
|
11
|
+
* the kit's dashed "add" affordance; the one inside an opened folder stays
|
|
12
|
+
* neutral so it reads as the last thing in that folder rather than as a rival
|
|
13
|
+
* to the pinned one.
|
|
14
|
+
*/
|
|
15
|
+
export type NewFolderProminence = "prominent" | "quiet";
|
|
16
|
+
|
|
17
|
+
export interface NewFolderActionProps {
|
|
18
|
+
label: string;
|
|
19
|
+
/** Names the folder it makes one inside, e.g. `New folder inside Travel`. */
|
|
20
|
+
ariaLabel: string;
|
|
21
|
+
/** Indents it to sit among the children of the folder it belongs to. */
|
|
22
|
+
depth?: number;
|
|
23
|
+
prominence?: NewFolderProminence;
|
|
24
|
+
/** A hairline under the label, drawn for every row but the last. */
|
|
25
|
+
separated?: boolean;
|
|
26
|
+
onOpen: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Makes a folder where the user is looking, as a row in the list. */
|
|
30
|
+
export const NewFolderAction = ({
|
|
31
|
+
label,
|
|
32
|
+
ariaLabel,
|
|
33
|
+
depth = 0,
|
|
34
|
+
prominence = "prominent",
|
|
35
|
+
separated = false,
|
|
36
|
+
onOpen,
|
|
37
|
+
}: NewFolderActionProps) => (
|
|
38
|
+
<div className={cn("relative", prominence === "prominent" && "p-2")}>
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={onOpen}
|
|
42
|
+
aria-label={ariaLabel}
|
|
43
|
+
data-prominence={prominence}
|
|
44
|
+
className={cn(
|
|
45
|
+
FOLDER_ROW_BASE,
|
|
46
|
+
"w-full active:bg-surface-sunken",
|
|
47
|
+
prominence === "prominent"
|
|
48
|
+
? "rounded-lg border border-line-strong border-dashed font-medium text-fg-muted hover:border-accent-2 hover:text-accent-2"
|
|
49
|
+
: "text-fg-subtle hover:bg-surface-raised hover:text-fg-muted",
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<FolderRowIndent depth={depth} />
|
|
53
|
+
<span aria-hidden="true" className="size-4 shrink-0" />
|
|
54
|
+
<FolderPlus className="size-4 shrink-0" aria-hidden="true" />
|
|
55
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
56
|
+
</button>
|
|
57
|
+
{separated && <FolderRowSeparator depth={depth} />}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
@@ -0,0 +1,64 @@
|
|
|
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 { NewFolderForm, type NewFolderFormProps } from "./new-folder-form.js";
|
|
6
|
+
|
|
7
|
+
const render = (props: Partial<NewFolderFormProps>) =>
|
|
8
|
+
renderToString(
|
|
9
|
+
createElement(NewFolderForm, {
|
|
10
|
+
parentLabel: "Travel",
|
|
11
|
+
name: "",
|
|
12
|
+
onNameChange: () => {},
|
|
13
|
+
onSubmit: () => {},
|
|
14
|
+
onCancel: () => {},
|
|
15
|
+
...props,
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("NewFolderForm", () => {
|
|
20
|
+
it("asks for a name and states where the folder goes", () => {
|
|
21
|
+
const html = render({ name: "Car hire" });
|
|
22
|
+
assert.match(html, /Folder name/);
|
|
23
|
+
assert.match(html, /value="Car hire"/);
|
|
24
|
+
assert.match(html, /placeholder="Hotels"/);
|
|
25
|
+
assert.match(html, /Inside/);
|
|
26
|
+
assert.match(html, />Travel</);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("ties the label to the field it names", () => {
|
|
30
|
+
const html = render({});
|
|
31
|
+
const forId = html.match(/<label[^>]*for="([^"]+)"/)?.[1];
|
|
32
|
+
assert.ok(forId);
|
|
33
|
+
assert.match(html, new RegExp(`<input[^>]*id="${forId}"`));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("states the parent as text rather than a second choice", () => {
|
|
37
|
+
assert.doesNotMatch(render({}), /<select/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("holds the wait and refuses a second submit while it runs", () => {
|
|
41
|
+
const html = render({ pending: true });
|
|
42
|
+
assert.match(html, /Creating folder…/);
|
|
43
|
+
assert.match(html, /<button[^>]*disabled/);
|
|
44
|
+
assert.doesNotMatch(html, />Create folder</);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("states a failure where it happened", () => {
|
|
48
|
+
const html = render({ error: "The mail server refused that name." });
|
|
49
|
+
assert.match(html, /role="alert"[^>]*>The mail server refused that name\./);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("says nothing about a failure that has not happened", () => {
|
|
53
|
+
assert.doesNotMatch(render({}), /role="alert"/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("applies caller-supplied labels", () => {
|
|
57
|
+
const html = render({
|
|
58
|
+
labels: { create: "Map maken", cancel: "Annuleren", insideLabel: "In" },
|
|
59
|
+
});
|
|
60
|
+
assert.match(html, />Map maken</);
|
|
61
|
+
assert.match(html, />Annuleren</);
|
|
62
|
+
assert.doesNotMatch(html, /Inside/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
|
+
import { NewFolderForm } from "./new-folder-form.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof NewFolderForm> = {
|
|
6
|
+
title: "Mail/NewFolderForm",
|
|
7
|
+
component: NewFolderForm,
|
|
8
|
+
parameters: { layout: "centered" },
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
|
|
12
|
+
type Story = StoryObj<typeof NewFolderForm>;
|
|
13
|
+
|
|
14
|
+
function Frame({ 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
|
+
function Form({
|
|
23
|
+
parentLabel = "Travel",
|
|
24
|
+
initialName = "",
|
|
25
|
+
pending,
|
|
26
|
+
error,
|
|
27
|
+
}: {
|
|
28
|
+
parentLabel?: string;
|
|
29
|
+
initialName?: string;
|
|
30
|
+
pending?: boolean;
|
|
31
|
+
error?: string;
|
|
32
|
+
}) {
|
|
33
|
+
const [name, setName] = useState(initialName);
|
|
34
|
+
return (
|
|
35
|
+
<Frame>
|
|
36
|
+
<NewFolderForm
|
|
37
|
+
parentLabel={parentLabel}
|
|
38
|
+
name={name}
|
|
39
|
+
onNameChange={setName}
|
|
40
|
+
onSubmit={() => undefined}
|
|
41
|
+
onCancel={() => undefined}
|
|
42
|
+
pending={pending}
|
|
43
|
+
error={error}
|
|
44
|
+
/>
|
|
45
|
+
</Frame>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const Empty: Story = {
|
|
50
|
+
name: "Opened, waiting for a name",
|
|
51
|
+
render: () => <Form />,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const TopLevel: Story = {
|
|
55
|
+
name: "At the top level",
|
|
56
|
+
render: () => <Form parentLabel="Top level" initialName="Insurance" />,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Creating a folder is an IMAP mutation: the form holds until the server confirms. */
|
|
60
|
+
export const Pending: Story = {
|
|
61
|
+
name: "Waiting for the server",
|
|
62
|
+
render: () => <Form initialName="Car hire" pending />,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The failure is stated where it happened; the form stays open to retry. */
|
|
66
|
+
export const Failed: Story = {
|
|
67
|
+
name: "Failed (retry in place)",
|
|
68
|
+
render: () => (
|
|
69
|
+
<Form
|
|
70
|
+
initialName="Car hire"
|
|
71
|
+
error="The mail server refused the folder name. Try another one."
|
|
72
|
+
/>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useEffect, useId, useRef } from "react";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
import { FieldLabel } from "./field-label.js";
|
|
4
|
+
import { Input } from "./input.js";
|
|
5
|
+
|
|
6
|
+
export interface NewFolderFormLabels {
|
|
7
|
+
nameLabel?: string;
|
|
8
|
+
namePlaceholder?: string;
|
|
9
|
+
/** Precedes the parent, e.g. `Inside Travel`. */
|
|
10
|
+
insideLabel?: string;
|
|
11
|
+
create?: string;
|
|
12
|
+
cancel?: string;
|
|
13
|
+
createPending?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const defaultNewFolderFormLabels: Required<NewFolderFormLabels> = {
|
|
17
|
+
nameLabel: "Folder name",
|
|
18
|
+
namePlaceholder: "Hotels",
|
|
19
|
+
insideLabel: "Inside",
|
|
20
|
+
create: "Create folder",
|
|
21
|
+
cancel: "Cancel",
|
|
22
|
+
createPending: "Creating folder…",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface NewFolderFormProps {
|
|
26
|
+
/** Where the folder is made, stated as text rather than a second choice. */
|
|
27
|
+
parentLabel: string;
|
|
28
|
+
name: string;
|
|
29
|
+
onNameChange: (name: string) => void;
|
|
30
|
+
onSubmit: () => void;
|
|
31
|
+
onCancel: () => void;
|
|
32
|
+
/** The mail server has the request and has not confirmed it yet. */
|
|
33
|
+
pending?: boolean;
|
|
34
|
+
error?: string;
|
|
35
|
+
labels?: NewFolderFormLabels;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Names a folder and holds the wait while the mail server makes it. Opens
|
|
40
|
+
* focused and in view, states a failure in place, and refuses a second submit
|
|
41
|
+
* while the first is still out.
|
|
42
|
+
*/
|
|
43
|
+
export const NewFolderForm = ({
|
|
44
|
+
parentLabel,
|
|
45
|
+
name,
|
|
46
|
+
onNameChange,
|
|
47
|
+
onSubmit,
|
|
48
|
+
onCancel,
|
|
49
|
+
pending = false,
|
|
50
|
+
error,
|
|
51
|
+
labels,
|
|
52
|
+
}: NewFolderFormProps) => {
|
|
53
|
+
const text = { ...defaultNewFolderFormLabels, ...labels };
|
|
54
|
+
const fieldId = useId();
|
|
55
|
+
const nameRef = useRef<HTMLInputElement>(null);
|
|
56
|
+
const formRef = useRef<HTMLDivElement>(null);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The whole form is brought into view, buttons included — focusing the field
|
|
60
|
+
* would otherwise scroll to the field alone and leave Create below the fold,
|
|
61
|
+
* under a footer or a soft keyboard.
|
|
62
|
+
*/
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
nameRef.current?.focus({ preventScroll: true });
|
|
65
|
+
formRef.current?.scrollIntoView({ block: "nearest" });
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
ref={formRef}
|
|
71
|
+
className="space-y-3 border-y border-line bg-surface-sunken px-3 py-3"
|
|
72
|
+
>
|
|
73
|
+
<div>
|
|
74
|
+
<FieldLabel htmlFor={fieldId}>{text.nameLabel}</FieldLabel>
|
|
75
|
+
<Input
|
|
76
|
+
id={fieldId}
|
|
77
|
+
ref={nameRef}
|
|
78
|
+
value={name}
|
|
79
|
+
placeholder={text.namePlaceholder}
|
|
80
|
+
onChange={(event) => onNameChange(event.target.value)}
|
|
81
|
+
onKeyDown={(event) => {
|
|
82
|
+
if (event.key === "Enter") {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
onSubmit();
|
|
85
|
+
}
|
|
86
|
+
if (event.key === "Escape") {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
onCancel();
|
|
89
|
+
}
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<p className="text-xs text-fg-muted">
|
|
94
|
+
{text.insideLabel}{" "}
|
|
95
|
+
<span className="font-medium text-fg">{parentLabel}</span>
|
|
96
|
+
</p>
|
|
97
|
+
{error && (
|
|
98
|
+
<p className="text-xs text-danger" role="alert">
|
|
99
|
+
{error}
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
{/* Sized to their labels rather than to the pane: a full-width primary
|
|
103
|
+
here reads as loudly as the wizard's Continue below it, and making a
|
|
104
|
+
folder is the smaller commitment of the two. */}
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
<Button
|
|
107
|
+
variant="ghost"
|
|
108
|
+
size="md"
|
|
109
|
+
onClick={onCancel}
|
|
110
|
+
className="min-h-11 shrink-0"
|
|
111
|
+
>
|
|
112
|
+
{text.cancel}
|
|
113
|
+
</Button>
|
|
114
|
+
<Button
|
|
115
|
+
variant="secondary"
|
|
116
|
+
size="md"
|
|
117
|
+
onClick={onSubmit}
|
|
118
|
+
disabled={pending}
|
|
119
|
+
className="min-h-11 shrink-0"
|
|
120
|
+
>
|
|
121
|
+
{pending ? text.createPending : text.create}
|
|
122
|
+
</Button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -198,6 +198,20 @@ export {
|
|
|
198
198
|
type FilterSheetProps,
|
|
199
199
|
type FilterSheetSource,
|
|
200
200
|
} from "./components/filter-sheet.js";
|
|
201
|
+
export {
|
|
202
|
+
FolderManageActions,
|
|
203
|
+
type FolderManageActionsProps,
|
|
204
|
+
} from "./components/folder-manage-actions.js";
|
|
205
|
+
export {
|
|
206
|
+
FolderManager,
|
|
207
|
+
type FolderManagerLabels,
|
|
208
|
+
type FolderManagerProps,
|
|
209
|
+
type ManagedFolder,
|
|
210
|
+
} from "./components/folder-manager.js";
|
|
211
|
+
export {
|
|
212
|
+
FolderRenameDialog,
|
|
213
|
+
type FolderRenameDialogProps,
|
|
214
|
+
} from "./components/folder-rename-dialog.js";
|
|
201
215
|
export {
|
|
202
216
|
canonicalRoleLabel,
|
|
203
217
|
type FolderRole,
|
|
@@ -207,6 +221,10 @@ export {
|
|
|
207
221
|
type ResultFolder,
|
|
208
222
|
roleIcon,
|
|
209
223
|
} from "./components/folder-role.js";
|
|
224
|
+
export {
|
|
225
|
+
FolderRow,
|
|
226
|
+
type FolderRowProps,
|
|
227
|
+
} from "./components/folder-row.js";
|
|
210
228
|
export {
|
|
211
229
|
type FolderTreeNode,
|
|
212
230
|
FolderTreePicker,
|
|
@@ -322,6 +340,16 @@ export {
|
|
|
322
340
|
NavSidebar,
|
|
323
341
|
type NavSidebarProps,
|
|
324
342
|
} from "./components/nav-sidebar.js";
|
|
343
|
+
export {
|
|
344
|
+
NewFolderAction,
|
|
345
|
+
type NewFolderActionProps,
|
|
346
|
+
type NewFolderProminence,
|
|
347
|
+
} from "./components/new-folder-action.js";
|
|
348
|
+
export {
|
|
349
|
+
NewFolderForm,
|
|
350
|
+
type NewFolderFormLabels,
|
|
351
|
+
type NewFolderFormProps,
|
|
352
|
+
} from "./components/new-folder-form.js";
|
|
325
353
|
export { OutboxRow, type OutboxRowProps } from "./components/outbox-row.js";
|
|
326
354
|
export {
|
|
327
355
|
type OutboxStatus,
|
|
@@ -619,6 +647,25 @@ export {
|
|
|
619
647
|
sanitizeInlineStyle,
|
|
620
648
|
sanitizeStyleElementCss,
|
|
621
649
|
} from "./lib/email-sanitizer.js";
|
|
650
|
+
export {
|
|
651
|
+
collapseFolderTree,
|
|
652
|
+
filterFolderTree,
|
|
653
|
+
folderAncestors,
|
|
654
|
+
folderDepth,
|
|
655
|
+
folderParent,
|
|
656
|
+
matchesQuery,
|
|
657
|
+
orderFolderNodes,
|
|
658
|
+
queryExpandedPaths,
|
|
659
|
+
withCreateRows,
|
|
660
|
+
} from "./lib/folder-tree.js";
|
|
661
|
+
export {
|
|
662
|
+
findFirstFocusable,
|
|
663
|
+
findLastFocusable,
|
|
664
|
+
findNextFocusable,
|
|
665
|
+
findParentRow,
|
|
666
|
+
isFocusable,
|
|
667
|
+
isSelectable,
|
|
668
|
+
} from "./lib/folder-tree-focus.js";
|
|
622
669
|
export {
|
|
623
670
|
isLabelColorValue,
|
|
624
671
|
type LabelColorValue,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
collapseFolderTree,
|
|
5
|
+
type FolderTreeNode,
|
|
6
|
+
filterFolderTree,
|
|
7
|
+
orderFolderNodes,
|
|
8
|
+
} from "./folder-tree.js";
|
|
9
|
+
import {
|
|
10
|
+
findFirstFocusable,
|
|
11
|
+
findLastFocusable,
|
|
12
|
+
findNextFocusable,
|
|
13
|
+
findParentRow,
|
|
14
|
+
isSelectable,
|
|
15
|
+
} from "./folder-tree-focus.js";
|
|
16
|
+
|
|
17
|
+
const node = (
|
|
18
|
+
id: string,
|
|
19
|
+
label: string,
|
|
20
|
+
path: string,
|
|
21
|
+
isCurrent?: boolean,
|
|
22
|
+
): FolderTreeNode => ({ id, label, path, isCurrent });
|
|
23
|
+
|
|
24
|
+
const ordered = orderFolderNodes(
|
|
25
|
+
[
|
|
26
|
+
node("inbox", "Inbox", "INBOX", true),
|
|
27
|
+
node("hotels", "Hotels", "Travel/Hotels"),
|
|
28
|
+
node("archive", "Archive", "Archive"),
|
|
29
|
+
node("travel", "Travel", "Travel"),
|
|
30
|
+
node("receipts", "Receipts", "Travel/Hotels/Receipts"),
|
|
31
|
+
node("trash", "Trash", "Deleted Messages"),
|
|
32
|
+
],
|
|
33
|
+
"/",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
describe("roving focus", () => {
|
|
37
|
+
const rows = filterFolderTree(ordered, "receipts", "/");
|
|
38
|
+
|
|
39
|
+
it("skips context rows, which are branches rather than folders to open", () => {
|
|
40
|
+
assert.equal(findFirstFocusable(rows), 2);
|
|
41
|
+
assert.equal(findLastFocusable(rows), 2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("reaches the current folder, which cannot be picked but can be opened", () => {
|
|
45
|
+
const all = collapseFolderTree(ordered, new Set(), "/");
|
|
46
|
+
assert.equal(findFirstFocusable(all), 0);
|
|
47
|
+
assert.equal(findNextFocusable(all, 3, 1), 0);
|
|
48
|
+
assert.equal(findNextFocusable(all, 0, -1), 3);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("walks only what is on screen, so closed children are skipped", () => {
|
|
52
|
+
const closed = collapseFolderTree(ordered, new Set(), "/");
|
|
53
|
+
assert.equal(
|
|
54
|
+
closed[findNextFocusable(closed, 2, 1)]?.folder.path,
|
|
55
|
+
"Deleted Messages",
|
|
56
|
+
);
|
|
57
|
+
const open = collapseFolderTree(ordered, new Set(["Travel"]), "/");
|
|
58
|
+
assert.equal(
|
|
59
|
+
open[findNextFocusable(open, 2, 1)]?.folder.path,
|
|
60
|
+
"Travel/Hotels",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("walks from nowhere into the list from either end", () => {
|
|
65
|
+
const all = collapseFolderTree(ordered, new Set(), "/");
|
|
66
|
+
assert.equal(findNextFocusable(all, -1, 1), 0);
|
|
67
|
+
assert.equal(findNextFocusable(all, -1, -1), 3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("finds the parent to move focus to when a row closes", () => {
|
|
71
|
+
const open = collapseFolderTree(ordered, new Set(["Travel"]), "/");
|
|
72
|
+
assert.equal(findParentRow(open, 3, "/"), 2);
|
|
73
|
+
assert.equal(findParentRow(open, 2, "/"), -1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("finds no parent for a row that is not on screen", () => {
|
|
77
|
+
const open = collapseFolderTree(ordered, new Set(["Travel"]), "/");
|
|
78
|
+
assert.equal(findParentRow(open, 99, "/"), -1);
|
|
79
|
+
assert.equal(
|
|
80
|
+
findParentRow(filterFolderTree(ordered, "hotels", "/"), 1, "/"),
|
|
81
|
+
-1,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns -1 when there is nothing to walk", () => {
|
|
86
|
+
assert.equal(findFirstFocusable([]), -1);
|
|
87
|
+
assert.equal(findLastFocusable([]), -1);
|
|
88
|
+
assert.equal(findNextFocusable([], 0, 1), -1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps the current folder and a context branch out of the selection", () => {
|
|
92
|
+
const all = collapseFolderTree(ordered, new Set(), "/");
|
|
93
|
+
assert.equal(isSelectable(all[0]), false);
|
|
94
|
+
assert.equal(isSelectable(all[1]), true);
|
|
95
|
+
assert.equal(isSelectable(rows[0]), false);
|
|
96
|
+
assert.equal(isSelectable(undefined), false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type FolderTreeRow, folderParent } from "./folder-tree.js";
|
|
2
|
+
|
|
3
|
+
/** Every folder can hold a new one, so every row but a context row can open. */
|
|
4
|
+
export const isFocusable = (row: FolderTreeRow | undefined): boolean =>
|
|
5
|
+
row !== undefined && !row.context;
|
|
6
|
+
|
|
7
|
+
export const isSelectable = (row: FolderTreeRow | undefined): boolean =>
|
|
8
|
+
row !== undefined && !row.folder.isCurrent && !row.context;
|
|
9
|
+
|
|
10
|
+
export const findFirstFocusable = (rows: readonly FolderTreeRow[]): number => {
|
|
11
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
12
|
+
if (isFocusable(rows[i])) return i;
|
|
13
|
+
}
|
|
14
|
+
return -1;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const findLastFocusable = (rows: readonly FolderTreeRow[]): number => {
|
|
18
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
19
|
+
if (isFocusable(rows[i])) return i;
|
|
20
|
+
}
|
|
21
|
+
return -1;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const findNextFocusable = (
|
|
25
|
+
rows: readonly FolderTreeRow[],
|
|
26
|
+
from: number,
|
|
27
|
+
step: 1 | -1,
|
|
28
|
+
): number => {
|
|
29
|
+
const count = rows.length;
|
|
30
|
+
if (count <= 0) return -1;
|
|
31
|
+
const start = from < 0 ? (step === 1 ? -1 : count) : from;
|
|
32
|
+
for (let offset = 1; offset <= count; offset += 1) {
|
|
33
|
+
const candidate = (((start + step * offset) % count) + count) % count;
|
|
34
|
+
if (isFocusable(rows[candidate])) return candidate;
|
|
35
|
+
}
|
|
36
|
+
return -1;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const findParentRow = (
|
|
40
|
+
rows: readonly FolderTreeRow[],
|
|
41
|
+
from: number,
|
|
42
|
+
delimiter: string,
|
|
43
|
+
): number => {
|
|
44
|
+
const child = rows[from];
|
|
45
|
+
if (!child) return -1;
|
|
46
|
+
const parent = folderParent(child.folder.path, delimiter);
|
|
47
|
+
if (!parent) return -1;
|
|
48
|
+
for (let i = from - 1; i >= 0; i -= 1) {
|
|
49
|
+
if (rows[i]?.folder.path === parent) return isFocusable(rows[i]) ? i : -1;
|
|
50
|
+
}
|
|
51
|
+
return -1;
|
|
52
|
+
};
|