Skip to content

Commit e578ea0

Browse files
committed
fix(tree): Add missing aria-selected attribute
Closes #1388
1 parent ca27b19 commit e578ea0

File tree

4 files changed

+1257
-10
lines changed

4 files changed

+1257
-10
lines changed

packages/tree/src/Tree.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ export const Tree = forwardRef<ListElement, TreeProps<any>>(function Tree( // es
113113
selected,
114114
expanded,
115115
focused,
116-
onClick() {
116+
onClick(event) {
117+
event.stopPropagation();
118+
117119
setActiveId(itemId);
118120
onItemSelect(itemId);
119121
if (childItems) {

packages/tree/src/TreeItem.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ export const TreeItem = forwardRef<
5656
disabled = false,
5757
readOnly,
5858
onFocus,
59+
onKeyUp,
60+
onKeyDown,
61+
onClick,
62+
onMouseUp,
63+
onMouseDown,
64+
onMouseLeave,
65+
onTouchMove,
66+
onTouchEnd,
67+
onTouchStart,
5968
...props
6069
},
6170
ref
@@ -70,7 +79,17 @@ export const TreeItem = forwardRef<
7079
const { ripples, className, handlers } = useInteractionStates({
7180
disabled,
7281
className: propClassName,
73-
handlers: isLink ? props : undefined,
82+
handlers: {
83+
onKeyUp,
84+
onKeyDown,
85+
onClick,
86+
onMouseUp,
87+
onMouseDown,
88+
onMouseLeave,
89+
onTouchMove,
90+
onTouchEnd,
91+
onTouchStart,
92+
},
7493
disableSpacebarClick: isLink,
7594
});
7695

@@ -99,27 +118,25 @@ export const TreeItem = forwardRef<
99118
}
100119

101120
event.preventDefault();
102-
const tree = event.currentTarget.closest('[role="tree"]');
103-
if (tree) {
104-
(tree as ListElement).focus();
105-
}
121+
event.currentTarget.closest<ListElement>('[role="tree"]')?.focus();
106122
},
107123
[onFocus]
108124
);
109125

110126
const a11y = {
111127
"aria-expanded": renderChildItems ? expanded : undefined,
128+
"aria-selected": selected,
112129
"aria-level": depth + 1,
113130
"aria-setsize": listSize,
114131
"aria-posinset": itemIndex + 1,
115-
"aria-disabled": disabled ? "true" : undefined,
132+
"aria-disabled": disabled || undefined,
116133
id,
117134
role: "treeitem",
118135
tabIndex: -1,
119136
...handlers,
120137
onFocus: handleFocus,
121-
};
122-
const noA11y = { role: "none" };
138+
} as const;
139+
const noA11y = { role: "none" } as const;
123140

124141
return (
125142
<li

packages/tree/src/__tests__/Tree.tsx

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { render } from "@testing-library/react";
1+
import type { ReactElement } from "react";
2+
import { fireEvent, render } from "@testing-library/react";
23

34
import { Tree } from "../Tree";
5+
import type { TreeData, TreeItemIds } from "../types";
6+
import { useTreeItemExpansion } from "../useTreeItemExpansion";
7+
import { useTreeItemSelection } from "../useTreeItemSelection";
48

59
const PROPS = {
610
id: "tree",
@@ -85,4 +89,139 @@ describe("Tree", () => {
8589
expect(item.className).toContain("item-1-li-class-name");
8690
expect(item.children[0].className).toContain("item-1-class-name");
8791
});
92+
93+
describe("Real World Demos", () => {
94+
interface Folder extends TreeItemIds {
95+
name: string;
96+
}
97+
98+
const folders: TreeData<Folder> = {
99+
"folder-1": {
100+
name: "Folder 1",
101+
itemId: "folder-1",
102+
parentId: null,
103+
},
104+
"folder-2": {
105+
name: "Folder 2",
106+
itemId: "folder-2",
107+
parentId: null,
108+
},
109+
"folder-3": {
110+
name: "Folder 3",
111+
itemId: "folder-3",
112+
parentId: null,
113+
},
114+
"folder-2-1": {
115+
name: "Folder 2 Child 1",
116+
itemId: "folder-2-1",
117+
parentId: "folder-2",
118+
},
119+
"folder-2-2": {
120+
name: "Folder 2 Child 2",
121+
itemId: "folder-2-2",
122+
parentId: "folder-2",
123+
},
124+
"folder-2-3": {
125+
name: "Folder 2 Child 3",
126+
itemId: "folder-2-3",
127+
parentId: "folder-2",
128+
},
129+
};
130+
131+
it("should work with the tree item hooks for single selection", () => {
132+
function Test(): ReactElement {
133+
const selection = useTreeItemSelection([], false);
134+
const expansion = useTreeItemExpansion([]);
135+
136+
return (
137+
<Tree
138+
id="single-select-tree"
139+
data={folders}
140+
aria-label="Tree"
141+
{...selection}
142+
{...expansion}
143+
/>
144+
);
145+
}
146+
147+
const { getByRole, container } = render(<Test />);
148+
const tree = getByRole("tree", { name: "Tree" });
149+
expect(tree).not.toHaveAttribute("aria-multiselectable");
150+
151+
const folder1 = getByRole("treeitem", { name: "Folder 1" });
152+
const folder2 = getByRole("treeitem", { name: "Folder 2" });
153+
const folder3 = getByRole("treeitem", { name: "Folder 3" });
154+
expect(folder1).toHaveAttribute("aria-selected", "false");
155+
expect(folder2).toHaveAttribute("aria-selected", "false");
156+
expect(folder3).toHaveAttribute("aria-selected", "false");
157+
expect(container).toMatchSnapshot();
158+
159+
fireEvent.click(folder1);
160+
expect(folder1).toHaveAttribute("aria-selected", "true");
161+
expect(folder2).toHaveAttribute("aria-selected", "false");
162+
expect(folder3).toHaveAttribute("aria-selected", "false");
163+
expect(container).toMatchSnapshot();
164+
165+
fireEvent.click(folder2);
166+
expect(folder1).toHaveAttribute("aria-selected", "false");
167+
expect(folder2).toHaveAttribute("aria-selected", "true");
168+
expect(folder3).toHaveAttribute("aria-selected", "false");
169+
expect(container).toMatchSnapshot();
170+
171+
// does not support having an unselected tree item at this time
172+
fireEvent.click(folder2);
173+
expect(folder1).toHaveAttribute("aria-selected", "false");
174+
expect(folder2).toHaveAttribute("aria-selected", "true");
175+
expect(folder3).toHaveAttribute("aria-selected", "false");
176+
expect(container).toMatchSnapshot();
177+
});
178+
179+
it("should work with the tree item hooks for multi selection", () => {
180+
function Test(): ReactElement {
181+
const selection = useTreeItemSelection([], true);
182+
const expansion = useTreeItemExpansion([]);
183+
184+
return (
185+
<Tree
186+
id="multi-select-tree"
187+
data={folders}
188+
aria-label="Tree"
189+
{...selection}
190+
{...expansion}
191+
/>
192+
);
193+
}
194+
195+
const { getByRole, container } = render(<Test />);
196+
197+
const tree = getByRole("tree", { name: "Tree" });
198+
expect(tree).toHaveAttribute("aria-multiselectable", "true");
199+
expect(container).toMatchSnapshot();
200+
201+
const folder1 = getByRole("treeitem", { name: "Folder 1" });
202+
const folder2 = getByRole("treeitem", { name: "Folder 2" });
203+
const folder3 = getByRole("treeitem", { name: "Folder 3" });
204+
expect(folder1).toHaveAttribute("aria-selected", "false");
205+
expect(folder2).toHaveAttribute("aria-selected", "false");
206+
expect(folder3).toHaveAttribute("aria-selected", "false");
207+
208+
fireEvent.click(folder1);
209+
expect(folder1).toHaveAttribute("aria-selected", "true");
210+
expect(folder2).toHaveAttribute("aria-selected", "false");
211+
expect(folder3).toHaveAttribute("aria-selected", "false");
212+
expect(container).toMatchSnapshot();
213+
214+
fireEvent.click(folder2);
215+
expect(folder1).toHaveAttribute("aria-selected", "true");
216+
expect(folder2).toHaveAttribute("aria-selected", "true");
217+
expect(folder3).toHaveAttribute("aria-selected", "false");
218+
expect(container).toMatchSnapshot();
219+
220+
fireEvent.click(folder2);
221+
expect(folder1).toHaveAttribute("aria-selected", "true");
222+
expect(folder2).toHaveAttribute("aria-selected", "false");
223+
expect(folder3).toHaveAttribute("aria-selected", "false");
224+
expect(container).toMatchSnapshot();
225+
});
226+
});
88227
});

0 commit comments

Comments
 (0)