@sproutsocial/seeds-react-tree 0.3.1 → 0.4.0
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 +11 -10
- package/CHANGELOG.md +36 -0
- package/dist/esm/index.js +389 -32
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.mts +86 -1
- package/dist/index.d.ts +86 -1
- package/dist/index.js +391 -32
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/Common/TreeSearchableSelectBase.tsx +383 -0
- package/src/Common/flattenTree.ts +22 -0
- package/src/MultiTreeSearchableSelect.stories.tsx +256 -0
- package/src/MultiTreeSearchableSelect.tsx +92 -0
- package/src/SingleTreeSearchableSelect.stories.tsx +217 -0
- package/src/SingleTreeSearchableSelect.tsx +95 -0
- package/src/TreeCombobox.tsx +91 -52
- package/src/TreeStyles.tsx +3 -2
- package/src/__tests__/MultiTreeSearchableSelect.test.tsx +194 -0
- package/src/__tests__/SingleTreeSearchableSelect.test.tsx +210 -0
- package/src/index.ts +8 -0
- package/src/storyData.tsx +339 -6
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { axe } from "jest-axe";
|
|
3
|
+
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
4
|
+
import { MultiTreeSearchableSelect } from "../MultiTreeSearchableSelect";
|
|
5
|
+
import type { TreeItemData } from "../Common/types";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* `<button role="combobox">` is the de facto select-style combobox pattern
|
|
9
|
+
* used by Base UI (v3 SearchableSelect), Radix UI, Headless UI, and others.
|
|
10
|
+
* It works correctly with screen readers, but axe's strict element-to-role
|
|
11
|
+
* mapping (`aria-allowed-role`) flags the override. Run axe with that rule
|
|
12
|
+
* disabled so the rest of the a11y checks still apply.
|
|
13
|
+
*/
|
|
14
|
+
const axeOpts = { rules: { "aria-allowed-role": { enabled: false } } };
|
|
15
|
+
|
|
16
|
+
const data: TreeItemData[] = [
|
|
17
|
+
{
|
|
18
|
+
id: "src",
|
|
19
|
+
label: "src",
|
|
20
|
+
children: [
|
|
21
|
+
{ id: "Button.tsx", label: "Button.tsx" },
|
|
22
|
+
{ id: "Modal.tsx", label: "Modal.tsx" },
|
|
23
|
+
{ id: "Tree.tsx", label: "Tree.tsx" },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "tests",
|
|
28
|
+
label: "tests",
|
|
29
|
+
children: [{ id: "Button.test.tsx", label: "Button.test.tsx" }],
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function getTrigger() {
|
|
34
|
+
return screen.getByRole("combobox", { name: /Pick files/ });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("MultiTreeSearchableSelect", () => {
|
|
38
|
+
it("stays open after selecting an item and accumulates selections", async () => {
|
|
39
|
+
const onChange = jest.fn();
|
|
40
|
+
const { user } = render(
|
|
41
|
+
<MultiTreeSearchableSelect
|
|
42
|
+
aria-label="Pick files"
|
|
43
|
+
items={data}
|
|
44
|
+
placeholder="Pick files"
|
|
45
|
+
defaultExpanded={["src"]}
|
|
46
|
+
onSelectedItemIdsChange={onChange}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
await user.click(getTrigger());
|
|
50
|
+
const input = screen.getByPlaceholderText("Search...");
|
|
51
|
+
await user.click(input);
|
|
52
|
+
await user.keyboard("{ArrowDown}{ArrowDown}"); // src → Button.tsx
|
|
53
|
+
await user.keyboard("{Enter}");
|
|
54
|
+
expect(onChange).toHaveBeenLastCalledWith(["Button.tsx"]);
|
|
55
|
+
// Stays open.
|
|
56
|
+
expect(getTrigger()).toHaveAttribute("aria-expanded", "true");
|
|
57
|
+
|
|
58
|
+
await user.keyboard("{ArrowDown}"); // → Modal.tsx
|
|
59
|
+
await user.keyboard("{Enter}");
|
|
60
|
+
expect(onChange).toHaveBeenLastCalledWith(
|
|
61
|
+
expect.arrayContaining(["Button.tsx", "Modal.tsx"])
|
|
62
|
+
);
|
|
63
|
+
expect(getTrigger()).toHaveAttribute("aria-expanded", "true");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("shows a comma-separated trigger label up to 3 selections", async () => {
|
|
67
|
+
const { user } = render(
|
|
68
|
+
<MultiTreeSearchableSelect
|
|
69
|
+
aria-label="Pick files"
|
|
70
|
+
items={data}
|
|
71
|
+
placeholder="Pick files"
|
|
72
|
+
defaultSelectedItemIds={["Button.tsx", "Modal.tsx"]}
|
|
73
|
+
defaultExpanded={["src"]}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
expect(getTrigger()).toHaveTextContent("Button.tsx, Modal.tsx");
|
|
77
|
+
|
|
78
|
+
await user.click(getTrigger());
|
|
79
|
+
const treeTree = screen.getByRole("treeitem", { name: /Tree\.tsx/ });
|
|
80
|
+
await user.click(treeTree);
|
|
81
|
+
// Three selections → still comma list.
|
|
82
|
+
expect(getTrigger()).toHaveTextContent("Button.tsx, Modal.tsx, Tree.tsx");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("collapses to 'N selected' beyond 3 items", () => {
|
|
86
|
+
render(
|
|
87
|
+
<MultiTreeSearchableSelect
|
|
88
|
+
aria-label="Pick files"
|
|
89
|
+
items={data}
|
|
90
|
+
placeholder="Pick files"
|
|
91
|
+
defaultSelectedItemIds={[
|
|
92
|
+
"Button.tsx",
|
|
93
|
+
"Modal.tsx",
|
|
94
|
+
"Tree.tsx",
|
|
95
|
+
"Button.test.tsx",
|
|
96
|
+
]}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
expect(getTrigger()).toHaveTextContent("4 selected");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("uses placeholder when nothing is selected", () => {
|
|
103
|
+
render(
|
|
104
|
+
<MultiTreeSearchableSelect
|
|
105
|
+
aria-label="Pick files"
|
|
106
|
+
items={data}
|
|
107
|
+
placeholder="Pick files"
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
expect(getTrigger()).toHaveTextContent("Pick files");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("renderTrigger overrides the default label", () => {
|
|
114
|
+
render(
|
|
115
|
+
<MultiTreeSearchableSelect
|
|
116
|
+
aria-label="Pick files"
|
|
117
|
+
items={data}
|
|
118
|
+
placeholder="Pick files"
|
|
119
|
+
defaultSelectedItemIds={["Button.tsx", "Modal.tsx"]}
|
|
120
|
+
renderTrigger={(sel) => `Picked: ${sel.length}`}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
expect(getTrigger()).toHaveTextContent("Picked: 2");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("clicking a treeitem in the open popout toggles selection without closing", async () => {
|
|
127
|
+
const onChange = jest.fn();
|
|
128
|
+
const { user } = render(
|
|
129
|
+
<MultiTreeSearchableSelect
|
|
130
|
+
aria-label="Pick files"
|
|
131
|
+
items={data}
|
|
132
|
+
placeholder="Pick files"
|
|
133
|
+
defaultExpanded={["src"]}
|
|
134
|
+
onSelectedItemIdsChange={onChange}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
await user.click(getTrigger());
|
|
138
|
+
const button = screen.getByRole("treeitem", { name: /Button\.tsx/ });
|
|
139
|
+
await user.click(button);
|
|
140
|
+
expect(onChange).toHaveBeenLastCalledWith(["Button.tsx"]);
|
|
141
|
+
expect(getTrigger()).toHaveAttribute("aria-expanded", "true");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("renders one hidden form input per selected value", () => {
|
|
145
|
+
const { container, rerender } = render(
|
|
146
|
+
<MultiTreeSearchableSelect
|
|
147
|
+
aria-label="Pick files"
|
|
148
|
+
items={data}
|
|
149
|
+
placeholder="Pick files"
|
|
150
|
+
name="files"
|
|
151
|
+
selectedItemIds={[]}
|
|
152
|
+
/>
|
|
153
|
+
);
|
|
154
|
+
// Nothing selected → no `type="hidden"` per-value inputs; the primary
|
|
155
|
+
// (visually-hidden) input is still in the DOM but has no name in
|
|
156
|
+
// multi-select mode, so the form receives nothing under "files".
|
|
157
|
+
expect(container.querySelectorAll('input[name="files"]')).toHaveLength(0);
|
|
158
|
+
|
|
159
|
+
rerender(
|
|
160
|
+
<MultiTreeSearchableSelect
|
|
161
|
+
aria-label="Pick files"
|
|
162
|
+
items={data}
|
|
163
|
+
placeholder="Pick files"
|
|
164
|
+
name="files"
|
|
165
|
+
selectedItemIds={["Button.tsx", "Modal.tsx"]}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
const inputs = container.querySelectorAll<HTMLInputElement>(
|
|
169
|
+
'input[name="files"]'
|
|
170
|
+
);
|
|
171
|
+
expect(inputs).toHaveLength(2);
|
|
172
|
+
expect(Array.from(inputs).map((i) => i.value)).toEqual([
|
|
173
|
+
"Button.tsx",
|
|
174
|
+
"Modal.tsx",
|
|
175
|
+
]);
|
|
176
|
+
inputs.forEach((input) => {
|
|
177
|
+
expect(input).toHaveAttribute("type", "hidden");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("passes axe a11y check (closed and open)", async () => {
|
|
182
|
+
const { container, user } = render(
|
|
183
|
+
<MultiTreeSearchableSelect
|
|
184
|
+
aria-label="Pick files"
|
|
185
|
+
items={data}
|
|
186
|
+
placeholder="Pick files"
|
|
187
|
+
defaultExpanded={["src"]}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
expect(await axe(container, axeOpts)).toHaveNoViolations();
|
|
191
|
+
await user.click(getTrigger());
|
|
192
|
+
expect(await axe(container, axeOpts)).toHaveNoViolations();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { axe } from "jest-axe";
|
|
3
|
+
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
4
|
+
import { SingleTreeSearchableSelect } from "../SingleTreeSearchableSelect";
|
|
5
|
+
import type { TreeItemData } from "../Common/types";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* `<button role="combobox">` is the de facto select-style combobox pattern
|
|
9
|
+
* used by Base UI (v3 SearchableSelect), Radix UI, Headless UI, and others.
|
|
10
|
+
* It works correctly with screen readers, but axe's strict element-to-role
|
|
11
|
+
* mapping (`aria-allowed-role`) flags the override. Run axe with that rule
|
|
12
|
+
* disabled so the rest of the a11y checks still apply.
|
|
13
|
+
*/
|
|
14
|
+
const axeOpts = { rules: { "aria-allowed-role": { enabled: false } } };
|
|
15
|
+
|
|
16
|
+
const data: TreeItemData[] = [
|
|
17
|
+
{
|
|
18
|
+
id: "src",
|
|
19
|
+
label: "src",
|
|
20
|
+
children: [
|
|
21
|
+
{ id: "Button.tsx", label: "Button.tsx" },
|
|
22
|
+
{ id: "Modal.tsx", label: "Modal.tsx" },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "tests",
|
|
27
|
+
label: "tests",
|
|
28
|
+
children: [{ id: "Button.test.tsx", label: "Button.test.tsx" }],
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function getTrigger() {
|
|
33
|
+
return screen.getByRole("combobox", { name: /Pick a file/ });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("SingleTreeSearchableSelect", () => {
|
|
37
|
+
it("renders a closed combobox trigger with placeholder and aria-haspopup=dialog", () => {
|
|
38
|
+
render(
|
|
39
|
+
<SingleTreeSearchableSelect
|
|
40
|
+
aria-label="Pick a file"
|
|
41
|
+
items={data}
|
|
42
|
+
placeholder="Pick a file"
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
const trigger = getTrigger();
|
|
46
|
+
expect(trigger).toHaveAttribute("role", "combobox");
|
|
47
|
+
expect(trigger).toHaveAttribute("aria-haspopup", "dialog");
|
|
48
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
49
|
+
expect(trigger).not.toHaveAttribute("aria-controls");
|
|
50
|
+
expect(trigger).toHaveTextContent("Pick a file");
|
|
51
|
+
// Tree is not in the DOM while closed.
|
|
52
|
+
expect(screen.queryByRole("tree")).not.toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("opens the popout on trigger click and wires aria-controls to the popup", async () => {
|
|
56
|
+
const { user } = render(
|
|
57
|
+
<SingleTreeSearchableSelect
|
|
58
|
+
aria-label="Pick a file"
|
|
59
|
+
items={data}
|
|
60
|
+
placeholder="Pick a file"
|
|
61
|
+
defaultExpanded={["src"]}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
await user.click(getTrigger());
|
|
65
|
+
const trigger = getTrigger();
|
|
66
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
67
|
+
|
|
68
|
+
const controlsId = trigger.getAttribute("aria-controls");
|
|
69
|
+
expect(controlsId).toBeTruthy();
|
|
70
|
+
expect(document.getElementById(controlsId!)).toBeInTheDocument();
|
|
71
|
+
|
|
72
|
+
expect(screen.getByRole("tree")).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("closes the popout when a single-select item is chosen", async () => {
|
|
76
|
+
const onChange = jest.fn();
|
|
77
|
+
const { user } = render(
|
|
78
|
+
<SingleTreeSearchableSelect
|
|
79
|
+
aria-label="Pick a file"
|
|
80
|
+
items={data}
|
|
81
|
+
placeholder="Pick a file"
|
|
82
|
+
defaultExpanded={["src"]}
|
|
83
|
+
onSelectedItemIdChange={onChange}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
await user.click(getTrigger());
|
|
87
|
+
const input = screen.getByPlaceholderText("Search...");
|
|
88
|
+
await user.click(input);
|
|
89
|
+
await user.keyboard("{ArrowDown}{ArrowDown}"); // src → Button.tsx
|
|
90
|
+
await user.keyboard("{Enter}");
|
|
91
|
+
|
|
92
|
+
expect(onChange).toHaveBeenLastCalledWith("Button.tsx");
|
|
93
|
+
expect(getTrigger()).toHaveAttribute("aria-expanded", "false");
|
|
94
|
+
expect(screen.queryByRole("tree")).not.toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders the selected item's label on the trigger", async () => {
|
|
98
|
+
const { user, rerender } = render(
|
|
99
|
+
<SingleTreeSearchableSelect
|
|
100
|
+
aria-label="Pick a file"
|
|
101
|
+
items={data}
|
|
102
|
+
placeholder="Pick a file"
|
|
103
|
+
selectedItemId={null}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
expect(getTrigger()).toHaveTextContent("Pick a file");
|
|
107
|
+
|
|
108
|
+
rerender(
|
|
109
|
+
<SingleTreeSearchableSelect
|
|
110
|
+
aria-label="Pick a file"
|
|
111
|
+
items={data}
|
|
112
|
+
placeholder="Pick a file"
|
|
113
|
+
selectedItemId="Modal.tsx"
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
expect(getTrigger()).toHaveTextContent("Modal.tsx");
|
|
117
|
+
|
|
118
|
+
// Trigger still works after selection.
|
|
119
|
+
await user.click(getTrigger());
|
|
120
|
+
expect(getTrigger()).toHaveAttribute("aria-expanded", "true");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("Escape with empty query closes the popout", async () => {
|
|
124
|
+
const { user } = render(
|
|
125
|
+
<SingleTreeSearchableSelect
|
|
126
|
+
aria-label="Pick a file"
|
|
127
|
+
items={data}
|
|
128
|
+
placeholder="Pick a file"
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
await user.click(getTrigger());
|
|
132
|
+
await user.keyboard("{Escape}");
|
|
133
|
+
expect(getTrigger()).toHaveAttribute("aria-expanded", "false");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("Escape with a non-empty query clears the query and stays open", async () => {
|
|
137
|
+
const { user } = render(
|
|
138
|
+
<SingleTreeSearchableSelect
|
|
139
|
+
aria-label="Pick a file"
|
|
140
|
+
items={data}
|
|
141
|
+
placeholder="Pick a file"
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
await user.click(getTrigger());
|
|
145
|
+
const input = screen.getByPlaceholderText("Search...");
|
|
146
|
+
await user.type(input, "But");
|
|
147
|
+
expect(input).toHaveValue("But");
|
|
148
|
+
|
|
149
|
+
await user.keyboard("{Escape}");
|
|
150
|
+
expect(input).toHaveValue("");
|
|
151
|
+
expect(getTrigger()).toHaveAttribute("aria-expanded", "true");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("renders a hidden form input mirroring the selected value", () => {
|
|
155
|
+
const { container, rerender } = render(
|
|
156
|
+
<SingleTreeSearchableSelect
|
|
157
|
+
aria-label="Pick a file"
|
|
158
|
+
items={data}
|
|
159
|
+
placeholder="Pick a file"
|
|
160
|
+
name="file"
|
|
161
|
+
selectedItemId={null}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
const hidden =
|
|
165
|
+
container.querySelector<HTMLInputElement>('input[name="file"]');
|
|
166
|
+
expect(hidden).toBeInTheDocument();
|
|
167
|
+
expect(hidden).toHaveAttribute("tabindex", "-1");
|
|
168
|
+
expect(hidden).toHaveAttribute("aria-hidden", "true");
|
|
169
|
+
expect(hidden).toHaveValue("");
|
|
170
|
+
|
|
171
|
+
rerender(
|
|
172
|
+
<SingleTreeSearchableSelect
|
|
173
|
+
aria-label="Pick a file"
|
|
174
|
+
items={data}
|
|
175
|
+
placeholder="Pick a file"
|
|
176
|
+
name="file"
|
|
177
|
+
selectedItemId="Modal.tsx"
|
|
178
|
+
/>
|
|
179
|
+
);
|
|
180
|
+
expect(
|
|
181
|
+
container.querySelector<HTMLInputElement>('input[name="file"]')
|
|
182
|
+
).toHaveValue("Modal.tsx");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("uses an ${id}-hidden-input id when no name is provided", () => {
|
|
186
|
+
const { container } = render(
|
|
187
|
+
<SingleTreeSearchableSelect
|
|
188
|
+
id="my-field"
|
|
189
|
+
aria-label="Pick a file"
|
|
190
|
+
items={data}
|
|
191
|
+
placeholder="Pick a file"
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
expect(container.querySelector("#my-field-hidden-input")).toBeTruthy();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("passes axe a11y check (closed and open)", async () => {
|
|
198
|
+
const { container, user } = render(
|
|
199
|
+
<SingleTreeSearchableSelect
|
|
200
|
+
aria-label="Pick a file"
|
|
201
|
+
items={data}
|
|
202
|
+
placeholder="Pick a file"
|
|
203
|
+
defaultExpanded={["src"]}
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
expect(await axe(container, axeOpts)).toHaveNoViolations();
|
|
207
|
+
await user.click(getTrigger());
|
|
208
|
+
expect(await axe(container, axeOpts)).toHaveNoViolations();
|
|
209
|
+
});
|
|
210
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
export { Tree, type TreeProps } from "./Tree";
|
|
2
2
|
export { TreeItem, type TreeItemProps } from "./TreeItem";
|
|
3
3
|
export { TreeCombobox, type TreeComboboxProps } from "./TreeCombobox";
|
|
4
|
+
export {
|
|
5
|
+
SingleTreeSearchableSelect,
|
|
6
|
+
type SingleTreeSearchableSelectProps,
|
|
7
|
+
} from "./SingleTreeSearchableSelect";
|
|
8
|
+
export {
|
|
9
|
+
MultiTreeSearchableSelect,
|
|
10
|
+
type MultiTreeSearchableSelectProps,
|
|
11
|
+
} from "./MultiTreeSearchableSelect";
|
|
4
12
|
export type {
|
|
5
13
|
TreeItemData,
|
|
6
14
|
TreeSelectionMode,
|