Announce resource tree selection state to screen readers (#4798482)

The Data Explorer left-nav resource tree did not expose selection state to
assistive technology. Screen readers announced tree items (e.g. "Documents,
1 of 2, level 3") without conveying whether the item was selected, failing
MAS 4.1.2 (Name, Role, Value).

Selection in the tree is a custom app-state concept (node.isSelected /
shouldShowAsSelected) and does not use Fluent v9's built-in selectionMode.
Wire the existing selection state to the aria-selected prop on TreeItem so
selectable nodes announce "selected"/"not selected". Pure grouping nodes
(no isSelected) omit the attribute, matching the ARIA single-select tree
pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 956998dd-4849-4f7d-a027-b7190018e089
This commit is contained in:
Jade Welton
2026-07-20 15:21:49 -07:00
parent acd2e3d839
commit 05d95cb467
3 changed files with 45 additions and 0 deletions
@@ -110,6 +110,42 @@ describe("TreeNodeComponent", () => {
expect(component).toMatchSnapshot(); expect(component).toMatchSnapshot();
}); });
it("sets aria-selected=true on a selected selectable node", () => {
const node = generateTestNode("root", {
isSelected: () => true,
});
const component = shallow(<TreeNodeComponent openItems={[]} node={node} treeNodeId={node.id} />);
expect(component.find(TreeItem).first().props()["aria-selected"]).toBe(true);
});
it("sets aria-selected=false on an unselected selectable node", () => {
const node = generateTestNode("root", {
isSelected: () => false,
});
const component = shallow(<TreeNodeComponent openItems={[]} node={node} treeNodeId={node.id} />);
expect(component.find(TreeItem).first().props()["aria-selected"]).toBe(false);
});
it("does not set aria-selected on a non-selectable (grouping) node", () => {
const node = generateTestNode("root");
delete node.isSelected;
const component = shallow(<TreeNodeComponent openItems={[]} node={node} treeNodeId={node.id} />);
expect(component.find(TreeItem).first().props()["aria-selected"]).toBeUndefined();
});
it("sets aria-selected=false on a selected parent when a descendant is selected", () => {
const node = generateTestNode("root", {
isSelected: () => true,
children: [
generateTestNode("child1", {
isSelected: () => true,
}),
],
});
const component = shallow(<TreeNodeComponent openItems={[]} node={node} treeNodeId={node.id} />);
expect(component.find(TreeItem).first().props()["aria-selected"]).toBe(false);
});
it("renders an icon if the node has one", () => { it("renders an icon if the node has one", () => {
const node = generateTestNode("root", { const node = generateTestNode("root", {
iconSrc: "the-icon.svg", iconSrc: "the-icon.svg",
@@ -172,6 +172,9 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
itemType={isBranch ? "branch" : "leaf"} itemType={isBranch ? "branch" : "leaf"}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
className={treeStyles.treeItem} className={treeStyles.treeItem}
// Expose selection state to screen readers for selectable nodes so they announce "selected"/"not selected".
// Pure grouping nodes (no isSelected) omit the attribute, matching the ARIA single-select tree pattern.
aria-selected={node.isSelected ? shouldShowAsSelected : undefined}
> >
<TreeItemLayout <TreeItemLayout
className={mergeClasses( className={mergeClasses(
@@ -111,6 +111,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
treeNodeId="root" treeNodeId="root"
> >
<TreeItem <TreeItem
aria-selected={false}
className="" className=""
data-test="TreeNodeContainer:root" data-test="TreeNodeContainer:root"
itemType="branch" itemType="branch"
@@ -120,6 +121,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
<div <div
aria-expanded={false} aria-expanded={false}
aria-level={0} aria-level={0}
aria-selected={false}
className="fui-TreeItem r15xhw3a" className="fui-TreeItem r15xhw3a"
data-fui-tree-item-value="root" data-fui-tree-item-value="root"
data-test="TreeNodeContainer:root" data-test="TreeNodeContainer:root"
@@ -218,6 +220,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
"current": <div "current": <div
aria-expanded="false" aria-expanded="false"
aria-level="0" aria-level="0"
aria-selected="false"
class="fui-TreeItem r15xhw3a" class="fui-TreeItem r15xhw3a"
data-fui-tree-item-value="root" data-fui-tree-item-value="root"
data-test="TreeNodeContainer:root" data-test="TreeNodeContainer:root"
@@ -1595,6 +1598,7 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
exports[`TreeNodeComponent renders selected parent node as selected if no descendant nodes are selected 1`] = ` exports[`TreeNodeComponent renders selected parent node as selected if no descendant nodes are selected 1`] = `
<TreeItem <TreeItem
aria-selected={true}
className="" className=""
data-test="TreeNodeContainer:root" data-test="TreeNodeContainer:root"
itemType="branch" itemType="branch"
@@ -1678,6 +1682,7 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
exports[`TreeNodeComponent renders selected parent node as unselected if any descendant node is selected 1`] = ` exports[`TreeNodeComponent renders selected parent node as unselected if any descendant node is selected 1`] = `
<TreeItem <TreeItem
aria-selected={false}
className="" className=""
data-test="TreeNodeContainer:root" data-test="TreeNodeContainer:root"
itemType="branch" itemType="branch"
@@ -1762,6 +1767,7 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = ` exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
<TreeItem <TreeItem
aria-selected={true}
className="" className=""
data-test="TreeNodeContainer:root" data-test="TreeNodeContainer:root"
itemType="leaf" itemType="leaf"