@remit/ui 0.0.66 → 0.0.68
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-row.render.test.ts +81 -0
- package/src/components/folder-row.stories.tsx +118 -0
- package/src/components/folder-row.tsx +147 -0
- package/src/components/folder-tree-picker.create.test.ts +341 -63
- package/src/components/folder-tree-picker.render.test.ts +21 -155
- package/src/components/folder-tree-picker.stories.tsx +56 -10
- package/src/components/folder-tree-picker.tsx +210 -340
- 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 +33 -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
package/package.json
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
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("takes its place in a roving tab order", () => {
|
|
78
|
+
assert.match(render({ tabIndex: 0 }), /tabindex="0"/);
|
|
79
|
+
assert.match(render({ tabIndex: -1 }), /tabindex="-1"/);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -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,147 @@
|
|
|
1
|
+
import { Check, ChevronRight, Folder } from "lucide-react";
|
|
2
|
+
import type { 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
|
+
tabIndex?: number;
|
|
56
|
+
onActivate?: () => void;
|
|
57
|
+
onFocus?: () => void;
|
|
58
|
+
ref?: Ref<HTMLButtonElement>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One folder in a tree: opens where you tap it, and says whether it is the
|
|
63
|
+
* destination, the folder you are in, or a branch on the way to a match.
|
|
64
|
+
*/
|
|
65
|
+
export const FolderRow = ({
|
|
66
|
+
label,
|
|
67
|
+
depth,
|
|
68
|
+
expanded,
|
|
69
|
+
ariaLabel,
|
|
70
|
+
selected = false,
|
|
71
|
+
context = false,
|
|
72
|
+
current = false,
|
|
73
|
+
currentTag,
|
|
74
|
+
separated = false,
|
|
75
|
+
tabIndex,
|
|
76
|
+
onActivate,
|
|
77
|
+
onFocus,
|
|
78
|
+
ref,
|
|
79
|
+
}: FolderRowProps) => {
|
|
80
|
+
const chevron = (
|
|
81
|
+
<ChevronRight
|
|
82
|
+
className={cn(
|
|
83
|
+
"size-4 shrink-0 text-fg-subtle transition-transform",
|
|
84
|
+
expanded && "rotate-90",
|
|
85
|
+
)}
|
|
86
|
+
aria-hidden="true"
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
const icon = (
|
|
90
|
+
<Folder className="size-4 shrink-0 text-fg-subtle" aria-hidden="true" />
|
|
91
|
+
);
|
|
92
|
+
if (context) {
|
|
93
|
+
return (
|
|
94
|
+
<div className="relative flex items-center">
|
|
95
|
+
{/* biome-ignore lint/a11y/useFocusableInteractive: an ancestor held on screen by a match below it — a branch, not a destination */}
|
|
96
|
+
<div
|
|
97
|
+
role="treeitem"
|
|
98
|
+
aria-level={depth + 1}
|
|
99
|
+
aria-selected={false}
|
|
100
|
+
aria-expanded={expanded}
|
|
101
|
+
aria-label={ariaLabel}
|
|
102
|
+
className={cn(FOLDER_ROW_BASE, "opacity-60")}
|
|
103
|
+
>
|
|
104
|
+
<FolderRowIndent depth={depth} />
|
|
105
|
+
{chevron}
|
|
106
|
+
{icon}
|
|
107
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
108
|
+
</div>
|
|
109
|
+
{separated && <FolderRowSeparator depth={depth} />}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
<div className="relative flex items-center">
|
|
115
|
+
<button
|
|
116
|
+
ref={ref}
|
|
117
|
+
type="button"
|
|
118
|
+
role="treeitem"
|
|
119
|
+
aria-level={depth + 1}
|
|
120
|
+
aria-selected={selected}
|
|
121
|
+
aria-expanded={expanded}
|
|
122
|
+
aria-current={current ? "true" : undefined}
|
|
123
|
+
aria-label={ariaLabel}
|
|
124
|
+
tabIndex={tabIndex}
|
|
125
|
+
onClick={onActivate}
|
|
126
|
+
onFocus={onFocus}
|
|
127
|
+
className={cn(
|
|
128
|
+
FOLDER_ROW_BASE,
|
|
129
|
+
"hover:bg-surface-raised active:bg-surface-sunken",
|
|
130
|
+
current && "text-fg-muted",
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
<FolderRowIndent depth={depth} />
|
|
134
|
+
{chevron}
|
|
135
|
+
{icon}
|
|
136
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
137
|
+
{current && currentTag && (
|
|
138
|
+
<span className="shrink-0 text-xs text-fg-muted">{currentTag}</span>
|
|
139
|
+
)}
|
|
140
|
+
{selected && (
|
|
141
|
+
<Check className="size-4 shrink-0 text-accent" aria-hidden="true" />
|
|
142
|
+
)}
|
|
143
|
+
</button>
|
|
144
|
+
{separated && <FolderRowSeparator depth={depth} />}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
};
|