Skip to content

fix(react-tree): expose tree selection control to assistive technologies#36384

Open
PaulGMardling wants to merge 11 commits into
microsoft:masterfrom
PaulGMardling:fix/a11y-tree-selection
Open

fix(react-tree): expose tree selection control to assistive technologies#36384
PaulGMardling wants to merge 11 commits into
microsoft:masterfrom
PaulGMardling:fix/a11y-tree-selection

Conversation

@PaulGMardling

@PaulGMardling PaulGMardling commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Previous Behavior

Voice access users are unable to access selection tree controls.

New Behavior

The Tree selection control (a checkbox in multiselect, a radio in single-select) now participates in the accessibility tree with an accessible name derived from the item's content. Previously it was hidden from assistive technologies, leaving any tool that operates the visible control directly unable to reach it.

Although the issue was reported against Windows Voice Access, the gap was platform-agnostic and equally affected macOS Voice Control and comparable technologies. Exposing the control resolves it at the source: the selector is now discoverable and operable by assistive technologies across platforms.

Selection state remains on the tree item via aria-checked / aria-selected, so screen readers announce it once (no double-announcement), and keyboard behavior is unchanged (the selector stays tabIndex -1; Space toggles). The fix applies to both the standard tree item layout and the persona tree item layout.

Testing/Verification

Windows 11:

Voice Access — "Show numbers" now places a number on each selection control; saying that number toggles selection, and "Click (item name)" works, confirming the accessible name resolves.
Narrator — selection state is announced once (no double-announcement); the row and the checkbox are both reachable as expected.

macOS (supplementary proxy):

Voice Control number-targeting toggles selection; VoiceOver reads the state once (no doubling). The browser accessibility tree confirms the control is exposed with the correct name.

Automated:

Added unit regression tests asserting the selection control is exposed (found by role) and named; verified they fail if the fix is reverted.

Fixes #36260

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-components
react-components: entire library
1.294 MB
326.017 kB
1.294 MB
326.068 kB
107 B
51 B
react-tree
FlatTree
135.571 kB
40.413 kB
135.661 kB
40.459 kB
90 B
46 B
react-tree
PersonaFlatTree
137.417 kB
40.931 kB
137.524 kB
40.991 kB
107 B
60 B
react-tree
PersonaTree
133.498 kB
39.718 kB
133.605 kB
39.781 kB
107 B
63 B
react-tree
Tree
131.658 kB
39.221 kB
131.748 kB
39.265 kB
90 B
44 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-components
react-components: Button, FluentProvider & webLightTheme
66.281 kB
19.002 kB
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
225.836 kB
67.998 kB
react-components
react-components: FluentProvider & webLightTheme
39.504 kB
13.112 kB
react-portal-compat
PortalCompatProvider
5.341 kB
2.146 kB
react-timepicker-compat
TimePicker
140.677 kB
45.99 kB
🤖 This report was generated against bdb7bfaf86a92bbdc5519708ba01361001fa5744

@github-actions

Copy link
Copy Markdown

Pull request demo site: URL

@PaulGMardling
PaulGMardling force-pushed the fix/a11y-tree-selection branch from a4b0786 to 7574e86 Compare July 10, 2026 12:40
@PaulGMardling
PaulGMardling marked this pull request as ready for review July 10, 2026 13:35
@PaulGMardling
PaulGMardling requested a review from a team as a code owner July 10, 2026 13:35
@PaulGMardling
PaulGMardling force-pushed the fix/a11y-tree-selection branch 3 times, most recently from 67743cc to 823bdbb Compare July 15, 2026 15:06
@PaulGMardling
PaulGMardling requested a review from mainframev July 16, 2026 09:47

@mainframev mainframev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

I think that won't work and the issue won't be automatically closed after PR merge

Change to: Fixes #36260

Comment thread change/@fluentui-react-tree-48fe2b57-5a5f-4455-b90c-0810d0a2ba03.json Outdated
@@ -0,0 +1,7 @@
{

@github-actions github-actions Bot Jul 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Menu Converged - submenuIndicator slotted content 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default - RTL.submenus open.chromium.png 404 Changed
vr-tests-react-components/Portal 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Portal.Apply Class Names.should have green border.chromium.png 15 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.chromium.png 878 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 44 Changed
vr-tests-react-components/ProgressBar converged 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 2 Changed
vr-tests-react-components/TagPicker 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - RTL.chromium.png 635 Changed

There were 1 duplicate changes discarded. Check the build logs for more information.

@PaulGMardling
PaulGMardling force-pushed the fix/a11y-tree-selection branch from 4d5ed51 to e7c44a7 Compare July 17, 2026 12:53
Comment on lines +85 to +90
if (selector) {
mainSlot.id = mainContentId;
if (!selector['aria-label'] && !selector['aria-labelledby']) {
selector['aria-labelledby'] = mainContentId;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this should be added as props while you construct the slot, not conditionally after as a mutation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've pushed an update, good catch, thank you

@bsunderhus bsunderhus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid mutations after the slot declaration, instead conditionally declare what you want. defaultProps should be enough for your usage here, let me know if it's not

Pass `ref` and `onBlur` to the actions slot as defaultProps during construction,
rather than mutating the slot after creation. This follows Fluent UI best practices
and addresses the code review feedback from @bsunderhus.

Removed post-construction mutations on the actions slot and moved callback
creation before slot initialization.
Pass `ref`, `onBlur`, and other props to both the `expandIcon` and `actions`
slots as defaultProps during construction, rather than mutating the slots after
creation. This eliminates the post-construction mutation pattern and follows
Fluent UI best practices, addressing the code review feedback from @bsunderhus.
…edby

Pass selector props during slot construction instead of mutating after.
Preserve the conditional logic: only set aria-labelledby if the consumer
didn't provide aria-label or aria-labelledby.

Also set mainSlot.id unconditionally during construction.

Update snapshots to reflect the id being added to the main slot.
PaulGMardling and others added 2 commits July 20, 2026 17:04
…eeItemLayout/useTreeItemLayout.tsx

Co-authored-by: Bernardo Sunderhus <bernardo.sunderhus@gmail.com>
} as CheckboxProps | RadioProps,
elementType: (selectionMode === 'multiselect' ? Checkbox : Radio) as React.ElementType<CheckboxProps | RadioProps>,
});
const mainSlot = slot.always(main, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if user provides a custom id, like in this example:

<FlatTree {...flatTree.getTreeProps()} aria-label="Selection">
      {Array.from(flatTree.items(), flatTreeItem => {
        const { content, ...treeItemProps } = flatTreeItem.getTreeItemProps();
        return (
          <FlatTreeItem {...treeItemProps} key={flatTreeItem.value}>
            {/* what happens to the user provided id? */}
            <TreeItemLayout id={`layout-${flatTreeItem.value}`}>{content}</TreeItemLayout>
          </FlatTreeItem>
        );
      })}
    </FlatTree>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot just silently ignore user provided id, it should be the default. right now we're dropping it in favor of the one defined internally

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants