@sproutsocial/seeds-react-tree 0.3.1 → 0.4.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.
@@ -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,