@sproutsocial/seeds-react-tree 0.3.1
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/.turbo/turbo-build.log +21 -0
- package/CHANGELOG.md +112 -0
- package/README.md +51 -0
- package/dist/esm/index.js +887 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/index.d.mts +102 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +926 -0
- package/dist/index.js.map +1 -0
- package/jest.config.js +12 -0
- package/package.json +61 -0
- package/src/Common/filterTree.ts +51 -0
- package/src/Common/treeContext.ts +52 -0
- package/src/Common/treeNavigation.ts +171 -0
- package/src/Common/types.ts +23 -0
- package/src/Common/useTreeKeyboard.ts +124 -0
- package/src/Common/useTreeState.ts +107 -0
- package/src/Tree.stories.tsx +231 -0
- package/src/Tree.tsx +181 -0
- package/src/TreeCombobox.stories.tsx +100 -0
- package/src/TreeCombobox.tsx +291 -0
- package/src/TreeItem.tsx +218 -0
- package/src/TreeStyles.tsx +114 -0
- package/src/__tests__/Tree.test.tsx +199 -0
- package/src/__tests__/TreeCombobox.test.tsx +230 -0
- package/src/index.ts +10 -0
- package/src/storyData.tsx +166 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
3
|
+
import { Tree, TreeItem } from "..";
|
|
4
|
+
|
|
5
|
+
function renderBasicTree(
|
|
6
|
+
props: Partial<React.ComponentProps<typeof Tree>> = {}
|
|
7
|
+
) {
|
|
8
|
+
return render(
|
|
9
|
+
<Tree aria-label="Files" defaultExpanded={["src"]} {...props}>
|
|
10
|
+
<TreeItem id="src" label="src">
|
|
11
|
+
<TreeItem id="Button.tsx" label="Button.tsx" />
|
|
12
|
+
<TreeItem id="Modal.tsx" label="Modal.tsx" />
|
|
13
|
+
</TreeItem>
|
|
14
|
+
<TreeItem id="package.json" label="package.json" />
|
|
15
|
+
</Tree>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("Tree", () => {
|
|
20
|
+
it("renders WAI-ARIA roles and aria-level on items", () => {
|
|
21
|
+
renderBasicTree();
|
|
22
|
+
expect(screen.getByRole("tree", { name: "Files" })).toBeInTheDocument();
|
|
23
|
+
|
|
24
|
+
const srcItem = screen.getByRole("treeitem", { name: /src/ });
|
|
25
|
+
expect(srcItem).toHaveAttribute("aria-level", "1");
|
|
26
|
+
expect(srcItem).toHaveAttribute("aria-expanded", "true");
|
|
27
|
+
|
|
28
|
+
const childItem = screen.getByRole("treeitem", { name: /Button\.tsx/ });
|
|
29
|
+
expect(childItem).toHaveAttribute("aria-level", "2");
|
|
30
|
+
expect(childItem).not.toHaveAttribute("aria-expanded");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("hides children of collapsed branches", () => {
|
|
34
|
+
renderBasicTree({ defaultExpanded: [] });
|
|
35
|
+
const srcItem = screen.getByRole("treeitem", { name: /src/ });
|
|
36
|
+
expect(srcItem).toHaveAttribute("aria-expanded", "false");
|
|
37
|
+
// Group should be hidden — query without checking visibility filter.
|
|
38
|
+
const childItem = screen.queryByRole("treeitem", {
|
|
39
|
+
name: /Button\.tsx/,
|
|
40
|
+
hidden: true,
|
|
41
|
+
});
|
|
42
|
+
expect(childItem).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("uses roving tabindex with the first item tabbable by default", async () => {
|
|
46
|
+
renderBasicTree();
|
|
47
|
+
// Wait for the mount-time effect that assigns initial roving target.
|
|
48
|
+
const srcItem = screen.getByRole("treeitem", { name: /src/ });
|
|
49
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
50
|
+
expect(srcItem).toHaveAttribute("tabindex", "0");
|
|
51
|
+
expect(
|
|
52
|
+
screen.getByRole("treeitem", { name: /package\.json/ })
|
|
53
|
+
).toHaveAttribute("tabindex", "-1");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ArrowRight expands a collapsed branch", async () => {
|
|
57
|
+
const { user } = renderBasicTree({ defaultExpanded: [] });
|
|
58
|
+
const srcItem = screen.getByRole("treeitem", { name: /src/ });
|
|
59
|
+
srcItem.focus();
|
|
60
|
+
await user.keyboard("{ArrowRight}");
|
|
61
|
+
expect(srcItem).toHaveAttribute("aria-expanded", "true");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("ArrowLeft collapses an expanded branch", async () => {
|
|
65
|
+
const { user } = renderBasicTree({ defaultExpanded: ["src"] });
|
|
66
|
+
const srcItem = screen.getByRole("treeitem", { name: /src/ });
|
|
67
|
+
srcItem.focus();
|
|
68
|
+
await user.keyboard("{ArrowLeft}");
|
|
69
|
+
expect(srcItem).toHaveAttribute("aria-expanded", "false");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ArrowDown moves focus to the next visible item", async () => {
|
|
73
|
+
const { user } = renderBasicTree();
|
|
74
|
+
const srcItem = screen.getByRole("treeitem", { name: /src/ });
|
|
75
|
+
srcItem.focus();
|
|
76
|
+
await user.keyboard("{ArrowDown}");
|
|
77
|
+
expect(screen.getByRole("treeitem", { name: /Button\.tsx/ })).toHaveFocus();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("Home/End jump to first/last visible item", async () => {
|
|
81
|
+
const { user } = renderBasicTree();
|
|
82
|
+
const last = screen.getByRole("treeitem", { name: /package\.json/ });
|
|
83
|
+
last.focus();
|
|
84
|
+
await user.keyboard("{Home}");
|
|
85
|
+
expect(screen.getByRole("treeitem", { name: /src/ })).toHaveFocus();
|
|
86
|
+
await user.keyboard("{End}");
|
|
87
|
+
expect(last).toHaveFocus();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("Enter toggles selection in single-select mode", async () => {
|
|
91
|
+
const onChange = jest.fn();
|
|
92
|
+
const { user } = render(
|
|
93
|
+
<Tree
|
|
94
|
+
aria-label="Files"
|
|
95
|
+
selectionMode="single"
|
|
96
|
+
onSelectionChange={onChange}
|
|
97
|
+
>
|
|
98
|
+
<TreeItem id="a" label="A" />
|
|
99
|
+
<TreeItem id="b" label="B" />
|
|
100
|
+
</Tree>
|
|
101
|
+
);
|
|
102
|
+
const a = screen.getByRole("treeitem", { name: "A" });
|
|
103
|
+
a.focus();
|
|
104
|
+
await user.keyboard("{Enter}");
|
|
105
|
+
expect(onChange).toHaveBeenCalledWith(["a"]);
|
|
106
|
+
expect(a).toHaveAttribute("aria-selected", "true");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("multi-select uses aria-checked and accumulates selections", async () => {
|
|
110
|
+
const { user } = render(
|
|
111
|
+
<Tree aria-label="Files" selectionMode="multiple">
|
|
112
|
+
<TreeItem id="a" label="A" />
|
|
113
|
+
<TreeItem id="b" label="B" />
|
|
114
|
+
</Tree>
|
|
115
|
+
);
|
|
116
|
+
const a = screen.getByRole("treeitem", { name: "A" });
|
|
117
|
+
const b = screen.getByRole("treeitem", { name: "B" });
|
|
118
|
+
a.focus();
|
|
119
|
+
await user.keyboard("{Enter}");
|
|
120
|
+
b.focus();
|
|
121
|
+
await user.keyboard(" ");
|
|
122
|
+
expect(a).toHaveAttribute("aria-checked", "true");
|
|
123
|
+
expect(b).toHaveAttribute("aria-checked", "true");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('selectableNodes="leaves" hides the selection indicator on parents', () => {
|
|
127
|
+
render(
|
|
128
|
+
<Tree
|
|
129
|
+
aria-label="Files"
|
|
130
|
+
selectionMode="single"
|
|
131
|
+
selectableNodes="leaves"
|
|
132
|
+
defaultExpanded={["src"]}
|
|
133
|
+
renderSelectionIndicator={() => (
|
|
134
|
+
<span data-testid="indicator">indicator</span>
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
<TreeItem id="src" label="src">
|
|
138
|
+
<TreeItem id="leaf" label="Leaf" />
|
|
139
|
+
</TreeItem>
|
|
140
|
+
</Tree>
|
|
141
|
+
);
|
|
142
|
+
// Only the leaf gets an indicator; the parent doesn't.
|
|
143
|
+
const indicators = screen.getAllByTestId("indicator");
|
|
144
|
+
expect(indicators).toHaveLength(1);
|
|
145
|
+
const leaf = screen.getByRole("treeitem", { name: /Leaf/ });
|
|
146
|
+
expect(leaf).toContainElement(indicators[0]!);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('selectableNodes="leaves" blocks parent selection', async () => {
|
|
150
|
+
const onChange = jest.fn();
|
|
151
|
+
const { user } = render(
|
|
152
|
+
<Tree
|
|
153
|
+
aria-label="Files"
|
|
154
|
+
selectionMode="single"
|
|
155
|
+
selectableNodes="leaves"
|
|
156
|
+
defaultExpanded={["src"]}
|
|
157
|
+
onSelectionChange={onChange}
|
|
158
|
+
>
|
|
159
|
+
<TreeItem id="src" label="src">
|
|
160
|
+
<TreeItem id="leaf" label="Leaf" />
|
|
161
|
+
</TreeItem>
|
|
162
|
+
</Tree>
|
|
163
|
+
);
|
|
164
|
+
const branch = screen.getByRole("treeitem", { name: /src/ });
|
|
165
|
+
branch.focus();
|
|
166
|
+
await user.keyboard("{Enter}");
|
|
167
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
168
|
+
|
|
169
|
+
const leaf = screen.getByRole("treeitem", { name: /Leaf/ });
|
|
170
|
+
leaf.focus();
|
|
171
|
+
await user.keyboard("{Enter}");
|
|
172
|
+
expect(onChange).toHaveBeenCalledWith(["leaf"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("disabled items are skipped and not selectable", async () => {
|
|
176
|
+
const onChange = jest.fn();
|
|
177
|
+
const { user } = render(
|
|
178
|
+
<Tree
|
|
179
|
+
aria-label="Files"
|
|
180
|
+
selectionMode="single"
|
|
181
|
+
onSelectionChange={onChange}
|
|
182
|
+
>
|
|
183
|
+
<TreeItem id="a" label="A" />
|
|
184
|
+
<TreeItem id="b" label="B" disabled />
|
|
185
|
+
<TreeItem id="c" label="C" />
|
|
186
|
+
</Tree>
|
|
187
|
+
);
|
|
188
|
+
const a = screen.getByRole("treeitem", { name: "A" });
|
|
189
|
+
a.focus();
|
|
190
|
+
await user.keyboard("{ArrowDown}");
|
|
191
|
+
// B is disabled, so focus skips to C.
|
|
192
|
+
expect(screen.getByRole("treeitem", { name: "C" })).toHaveFocus();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("passes axe a11y check", async () => {
|
|
196
|
+
const { runA11yCheck } = renderBasicTree({ selectionMode: "single" });
|
|
197
|
+
await runA11yCheck();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
3
|
+
import { TreeCombobox } from "../TreeCombobox";
|
|
4
|
+
import type { TreeItemData } from "../Common/types";
|
|
5
|
+
|
|
6
|
+
const data: TreeItemData[] = [
|
|
7
|
+
{
|
|
8
|
+
id: "src",
|
|
9
|
+
label: "src",
|
|
10
|
+
children: [
|
|
11
|
+
{ id: "Button.tsx", label: "Button.tsx" },
|
|
12
|
+
{ id: "Modal.tsx", label: "Modal.tsx" },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "tests",
|
|
17
|
+
label: "tests",
|
|
18
|
+
children: [{ id: "Button.test.tsx", label: "Button.test.tsx" }],
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
describe("TreeCombobox", () => {
|
|
23
|
+
it("renders the combobox role with the WAI-ARIA combobox wiring", () => {
|
|
24
|
+
render(<TreeCombobox aria-label="Files" items={data} />);
|
|
25
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
26
|
+
expect(input).toHaveAttribute("aria-autocomplete", "list");
|
|
27
|
+
expect(input).toHaveAttribute("aria-haspopup", "tree");
|
|
28
|
+
expect(input).toHaveAttribute("aria-expanded", "true");
|
|
29
|
+
|
|
30
|
+
const tree = screen.getByRole("tree", { name: "Files" });
|
|
31
|
+
expect(input.getAttribute("aria-controls")).toBe(tree.id);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renders all items when query is empty", () => {
|
|
35
|
+
render(<TreeCombobox aria-label="Files" items={data} />);
|
|
36
|
+
expect(screen.getByRole("treeitem", { name: /src/ })).toBeInTheDocument();
|
|
37
|
+
expect(screen.getByRole("treeitem", { name: /tests/ })).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("filters items by label and auto-expands ancestors of matches", async () => {
|
|
41
|
+
const { user } = render(<TreeCombobox aria-label="Files" items={data} />);
|
|
42
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
43
|
+
await user.type(input, "Modal");
|
|
44
|
+
|
|
45
|
+
expect(
|
|
46
|
+
screen.getByRole("treeitem", { name: /Modal\.tsx/ })
|
|
47
|
+
).toBeInTheDocument();
|
|
48
|
+
expect(
|
|
49
|
+
screen.queryByRole("treeitem", { name: /^Button\.tsx$/ })
|
|
50
|
+
).not.toBeInTheDocument();
|
|
51
|
+
|
|
52
|
+
const src = screen.getByRole("treeitem", { name: /src/ });
|
|
53
|
+
expect(src).toHaveAttribute("aria-expanded", "true");
|
|
54
|
+
expect(
|
|
55
|
+
screen.queryByRole("treeitem", { name: /^tests$/ })
|
|
56
|
+
).not.toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("restores user expansion state when query clears", async () => {
|
|
60
|
+
const { user } = render(
|
|
61
|
+
<TreeCombobox aria-label="Files" items={data} defaultExpanded={[]} />
|
|
62
|
+
);
|
|
63
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
64
|
+
await user.type(input, "Modal");
|
|
65
|
+
expect(screen.getByRole("treeitem", { name: /src/ })).toHaveAttribute(
|
|
66
|
+
"aria-expanded",
|
|
67
|
+
"true"
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await user.clear(input);
|
|
71
|
+
expect(screen.getByRole("treeitem", { name: /src/ })).toHaveAttribute(
|
|
72
|
+
"aria-expanded",
|
|
73
|
+
"false"
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("shows the empty state and sets aria-expanded=false when no items match", async () => {
|
|
78
|
+
const { user } = render(
|
|
79
|
+
<TreeCombobox aria-label="Files" items={data} emptyText="Nothing here" />
|
|
80
|
+
);
|
|
81
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
82
|
+
await user.type(input, "zzzzz");
|
|
83
|
+
expect(screen.getByText("Nothing here")).toBeInTheDocument();
|
|
84
|
+
expect(input).toHaveAttribute("aria-expanded", "false");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("Escape clears a non-empty query and the active descendant", async () => {
|
|
88
|
+
const { user } = render(<TreeCombobox aria-label="Files" items={data} />);
|
|
89
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
90
|
+
await user.click(input);
|
|
91
|
+
await user.type(input, "Mod");
|
|
92
|
+
await user.keyboard("{ArrowDown}");
|
|
93
|
+
expect(input).toHaveAttribute("aria-activedescendant");
|
|
94
|
+
|
|
95
|
+
await user.keyboard("{Escape}");
|
|
96
|
+
expect(input).toHaveValue("");
|
|
97
|
+
expect(input).not.toHaveAttribute("aria-activedescendant");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("ArrowDown sets aria-activedescendant to the first visible item; subsequent arrows navigate", async () => {
|
|
101
|
+
const { user } = render(
|
|
102
|
+
<TreeCombobox aria-label="Files" items={data} defaultExpanded={["src"]} />
|
|
103
|
+
);
|
|
104
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
105
|
+
await user.click(input);
|
|
106
|
+
await user.keyboard("{ArrowDown}");
|
|
107
|
+
expect(input).toHaveAttribute("aria-activedescendant", "src__item");
|
|
108
|
+
|
|
109
|
+
await user.keyboard("{ArrowDown}");
|
|
110
|
+
expect(input).toHaveAttribute("aria-activedescendant", "Button.tsx__item");
|
|
111
|
+
|
|
112
|
+
await user.keyboard("{ArrowUp}");
|
|
113
|
+
expect(input).toHaveAttribute("aria-activedescendant", "src__item");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("ArrowRight expands a collapsed branch; ArrowLeft collapses an expanded branch", async () => {
|
|
117
|
+
const { user } = render(
|
|
118
|
+
<TreeCombobox aria-label="Files" items={data} defaultExpanded={[]} />
|
|
119
|
+
);
|
|
120
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
121
|
+
await user.click(input);
|
|
122
|
+
await user.keyboard("{ArrowDown}");
|
|
123
|
+
expect(screen.getByRole("treeitem", { name: /src/ })).toHaveAttribute(
|
|
124
|
+
"aria-expanded",
|
|
125
|
+
"false"
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await user.keyboard("{ArrowRight}");
|
|
129
|
+
expect(screen.getByRole("treeitem", { name: /src/ })).toHaveAttribute(
|
|
130
|
+
"aria-expanded",
|
|
131
|
+
"true"
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
await user.keyboard("{ArrowLeft}");
|
|
135
|
+
expect(screen.getByRole("treeitem", { name: /src/ })).toHaveAttribute(
|
|
136
|
+
"aria-expanded",
|
|
137
|
+
"false"
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("DOM focus stays on the input across arrow keys", async () => {
|
|
142
|
+
const { user } = render(<TreeCombobox aria-label="Files" items={data} />);
|
|
143
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
144
|
+
await user.click(input);
|
|
145
|
+
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowUp}");
|
|
146
|
+
expect(input).toHaveFocus();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("treeitems are not in the tab order in combobox mode", () => {
|
|
150
|
+
render(
|
|
151
|
+
<TreeCombobox aria-label="Files" items={data} defaultExpanded={["src"]} />
|
|
152
|
+
);
|
|
153
|
+
const items = screen.getAllByRole("treeitem");
|
|
154
|
+
items.forEach((el) => {
|
|
155
|
+
expect(el).toHaveAttribute("tabindex", "-1");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("Enter on the active item toggles single-select and fires onSelectionChange", async () => {
|
|
160
|
+
const onChange = jest.fn();
|
|
161
|
+
const { user } = render(
|
|
162
|
+
<TreeCombobox
|
|
163
|
+
aria-label="Files"
|
|
164
|
+
items={data}
|
|
165
|
+
defaultExpanded={["src"]}
|
|
166
|
+
selectionMode="single"
|
|
167
|
+
onSelectionChange={onChange}
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
170
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
171
|
+
await user.click(input);
|
|
172
|
+
await user.keyboard("{ArrowDown}{ArrowDown}"); // src → Button.tsx
|
|
173
|
+
await user.keyboard("{Enter}");
|
|
174
|
+
expect(onChange).toHaveBeenLastCalledWith(["Button.tsx"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("Enter on multi-select accumulates selections without closing or clearing the query", async () => {
|
|
178
|
+
const onChange = jest.fn();
|
|
179
|
+
const { user } = render(
|
|
180
|
+
<TreeCombobox
|
|
181
|
+
aria-label="Files"
|
|
182
|
+
items={data}
|
|
183
|
+
defaultExpanded={["src"]}
|
|
184
|
+
selectionMode="multiple"
|
|
185
|
+
onSelectionChange={onChange}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
189
|
+
await user.click(input);
|
|
190
|
+
await user.type(input, "tsx");
|
|
191
|
+
await user.keyboard("{ArrowDown}{ArrowDown}"); // src → Button.tsx
|
|
192
|
+
await user.keyboard("{Enter}");
|
|
193
|
+
expect(onChange).toHaveBeenLastCalledWith(["Button.tsx"]);
|
|
194
|
+
|
|
195
|
+
await user.keyboard("{ArrowDown}"); // Modal.tsx
|
|
196
|
+
await user.keyboard("{Enter}");
|
|
197
|
+
expect(onChange).toHaveBeenLastCalledWith(
|
|
198
|
+
expect.arrayContaining(["Button.tsx", "Modal.tsx"])
|
|
199
|
+
);
|
|
200
|
+
// Query and active descendant persist after Enter.
|
|
201
|
+
expect(input).toHaveValue("tsx");
|
|
202
|
+
expect(input).toHaveAttribute("aria-activedescendant", "Modal.tsx__item");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("clicking a treeitem keeps focus on the input", async () => {
|
|
206
|
+
const onChange = jest.fn();
|
|
207
|
+
const { user } = render(
|
|
208
|
+
<TreeCombobox
|
|
209
|
+
aria-label="Files"
|
|
210
|
+
items={data}
|
|
211
|
+
defaultExpanded={["src"]}
|
|
212
|
+
selectionMode="single"
|
|
213
|
+
onSelectionChange={onChange}
|
|
214
|
+
/>
|
|
215
|
+
);
|
|
216
|
+
const input = screen.getByRole("combobox", { name: "Files" });
|
|
217
|
+
await user.click(input);
|
|
218
|
+
const button = screen.getByRole("treeitem", { name: /Button\.tsx/ });
|
|
219
|
+
await user.click(button);
|
|
220
|
+
expect(input).toHaveFocus();
|
|
221
|
+
expect(onChange).toHaveBeenLastCalledWith(["Button.tsx"]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("passes axe a11y check", async () => {
|
|
225
|
+
const { runA11yCheck } = render(
|
|
226
|
+
<TreeCombobox aria-label="Files" items={data} />
|
|
227
|
+
);
|
|
228
|
+
await runA11yCheck();
|
|
229
|
+
});
|
|
230
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Tree, type TreeProps } from "./Tree";
|
|
2
|
+
export { TreeItem, type TreeItemProps } from "./TreeItem";
|
|
3
|
+
export { TreeCombobox, type TreeComboboxProps } from "./TreeCombobox";
|
|
4
|
+
export type {
|
|
5
|
+
TreeItemData,
|
|
6
|
+
TreeSelectionMode,
|
|
7
|
+
TreeSelectableNodes,
|
|
8
|
+
TreeSelectionIndicator,
|
|
9
|
+
TreeSelectionIndicatorState,
|
|
10
|
+
} from "./Common/types";
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import styled from "styled-components";
|
|
2
|
+
import { Icon } from "@sproutsocial/seeds-react-icon";
|
|
3
|
+
import type { TreeItemData, TreeSelectionIndicator } from "./Common/types";
|
|
4
|
+
|
|
5
|
+
export const collections: TreeItemData[] = [
|
|
6
|
+
{
|
|
7
|
+
id: "customer-care",
|
|
8
|
+
label: "Customer care",
|
|
9
|
+
icon: <Icon name="headset-outline" size="small" />,
|
|
10
|
+
children: [
|
|
11
|
+
{ id: "questions", label: "Questions & How-To", icon: <span>❓</span> },
|
|
12
|
+
{ id: "bug-reports", label: "Bug Reports", icon: <span>🐛</span> },
|
|
13
|
+
{
|
|
14
|
+
id: "feature-requests",
|
|
15
|
+
label: "Feature Requests",
|
|
16
|
+
icon: <span>💡</span>,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "complaints",
|
|
20
|
+
label: "Complaints & Escalations",
|
|
21
|
+
icon: <span>😞</span>,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "collection-b",
|
|
27
|
+
label: "Collection B",
|
|
28
|
+
icon: <Icon name="headset-outline" size="small" />,
|
|
29
|
+
children: [
|
|
30
|
+
{ id: "b-1", label: "Topic One", icon: <span>📌</span> },
|
|
31
|
+
{ id: "b-2", label: "Topic Two", icon: <span>📎</span> },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "collection-c",
|
|
36
|
+
label: "Collection C",
|
|
37
|
+
icon: <Icon name="headset-outline" size="small" />,
|
|
38
|
+
children: [
|
|
39
|
+
{ id: "c-1", label: "Alpha", icon: <span>🔤</span> },
|
|
40
|
+
{ id: "c-2", label: "Beta", icon: <span>🔣</span> },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export const fileTree: TreeItemData[] = [
|
|
46
|
+
{
|
|
47
|
+
id: "src",
|
|
48
|
+
label: "src",
|
|
49
|
+
children: [
|
|
50
|
+
{
|
|
51
|
+
id: "components",
|
|
52
|
+
label: "components",
|
|
53
|
+
children: [
|
|
54
|
+
{ id: "Button.tsx", label: "Button.tsx" },
|
|
55
|
+
{ id: "Modal.tsx", label: "Modal.tsx" },
|
|
56
|
+
{ id: "Tree.tsx", label: "Tree.tsx" },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "hooks",
|
|
61
|
+
label: "hooks",
|
|
62
|
+
children: [
|
|
63
|
+
{ id: "useTree.ts", label: "useTree.ts" },
|
|
64
|
+
{ id: "useKey.ts", label: "useKey.ts" },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{ id: "index.ts", label: "index.ts" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "package.json",
|
|
72
|
+
label: "package.json",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "tsconfig.json",
|
|
76
|
+
label: "tsconfig.json",
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const RadioOuter = styled.span<{ $selected: boolean; $disabled: boolean }>`
|
|
81
|
+
display: inline-flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
justify-content: center;
|
|
84
|
+
flex-shrink: 0;
|
|
85
|
+
width: 18px;
|
|
86
|
+
height: 18px;
|
|
87
|
+
border-radius: 50%;
|
|
88
|
+
border: 2px solid
|
|
89
|
+
${({ theme, $selected }) =>
|
|
90
|
+
$selected
|
|
91
|
+
? theme.colors.form.border.selected
|
|
92
|
+
: theme.colors.form.border.base};
|
|
93
|
+
background: ${({ theme }) => theme.colors.form.background.base};
|
|
94
|
+
opacity: ${({ $disabled }) => ($disabled ? 0.4 : 1)};
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
const RadioInner = styled.span`
|
|
98
|
+
width: 10px;
|
|
99
|
+
height: 10px;
|
|
100
|
+
border-radius: 50%;
|
|
101
|
+
background: ${({ theme }) => theme.colors.form.border.selected};
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
export const radioIndicator: TreeSelectionIndicator = ({
|
|
105
|
+
selected,
|
|
106
|
+
disabled,
|
|
107
|
+
}) => (
|
|
108
|
+
<RadioOuter $selected={selected} $disabled={disabled}>
|
|
109
|
+
{selected ? <RadioInner /> : null}
|
|
110
|
+
</RadioOuter>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const CheckboxOuter = styled.span<{ $selected: boolean; $disabled: boolean }>`
|
|
114
|
+
display: inline-flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
justify-content: center;
|
|
117
|
+
flex-shrink: 0;
|
|
118
|
+
width: 18px;
|
|
119
|
+
height: 18px;
|
|
120
|
+
border-radius: 4px;
|
|
121
|
+
border: 2px solid
|
|
122
|
+
${({ theme, $selected }) =>
|
|
123
|
+
$selected
|
|
124
|
+
? theme.colors.form.border.selected
|
|
125
|
+
: theme.colors.form.border.base};
|
|
126
|
+
background: ${({ theme, $selected }) =>
|
|
127
|
+
$selected
|
|
128
|
+
? theme.colors.form.border.selected
|
|
129
|
+
: theme.colors.form.background.base};
|
|
130
|
+
color: ${({ theme }) => theme.colors.text.inverse};
|
|
131
|
+
opacity: ${({ $disabled }) => ($disabled ? 0.4 : 1)};
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
export const checkboxIndicator: TreeSelectionIndicator = ({
|
|
135
|
+
selected,
|
|
136
|
+
disabled,
|
|
137
|
+
}) => (
|
|
138
|
+
<CheckboxOuter $selected={selected} $disabled={disabled}>
|
|
139
|
+
{selected ? <Icon name="check-outline" size="small" /> : null}
|
|
140
|
+
</CheckboxOuter>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const ReadoutPanel = styled.div`
|
|
144
|
+
margin-top: ${({ theme }) => theme.space[400]};
|
|
145
|
+
padding: ${({ theme }) => theme.space[300]} ${({ theme }) => theme.space[400]};
|
|
146
|
+
border-radius: ${({ theme }) => theme.radii[400]};
|
|
147
|
+
background: ${({ theme }) => theme.colors.container.background.base};
|
|
148
|
+
border: 1px solid ${({ theme }) => theme.colors.container.border.base};
|
|
149
|
+
font-family: ${({ theme }) => theme.fontFamily};
|
|
150
|
+
${({ theme }) => theme.typography[200]}
|
|
151
|
+
color: ${({ theme }) => theme.colors.text.body};
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
const ReadoutLabel = styled.span`
|
|
155
|
+
font-weight: ${({ theme }) => theme.fontWeights.semibold};
|
|
156
|
+
margin-right: ${({ theme }) => theme.space[200]};
|
|
157
|
+
`;
|
|
158
|
+
|
|
159
|
+
export function SelectionReadout({ selected }: { selected: string[] }) {
|
|
160
|
+
return (
|
|
161
|
+
<ReadoutPanel aria-live="polite">
|
|
162
|
+
<ReadoutLabel>Selected:</ReadoutLabel>
|
|
163
|
+
{selected.length === 0 ? "(none)" : selected.join(", ")}
|
|
164
|
+
</ReadoutPanel>
|
|
165
|
+
);
|
|
166
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@sproutsocial/seeds-tsconfig/react-library.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noImplicitAny": false,
|
|
5
|
+
"noUncheckedIndexedAccess": false,
|
|
6
|
+
"baseUrl": ".",
|
|
7
|
+
"types": ["@testing-library/jest-dom"],
|
|
8
|
+
"verbatimModuleSyntax": true
|
|
9
|
+
},
|
|
10
|
+
"include": ["src/**/*"],
|
|
11
|
+
"exclude": ["node_modules", "dist", "**/*.stories.tsx", "**/*.stories.ts"]
|
|
12
|
+
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineConfig } from "tsup";
|
|
2
|
+
|
|
3
|
+
export default defineConfig((options) => ({
|
|
4
|
+
entry: ["src/index.ts"],
|
|
5
|
+
format: ["cjs", "esm"],
|
|
6
|
+
clean: true,
|
|
7
|
+
legacyOutput: true,
|
|
8
|
+
dts: options.dts,
|
|
9
|
+
sourcemap: true,
|
|
10
|
+
external: ["react", "styled-components", /^@sproutsocial\//],
|
|
11
|
+
metafile: options.metafile,
|
|
12
|
+
}));
|