Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.

"type": "patch",
"comment": "fix: add accessible names to Tree selection controls",
"packageName": "@fluentui/react-tree",
"email": "paulmardling@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { isConformant } from '../../testing/isConformant';
import { Tree } from './Tree';
import { TreeItem } from '../TreeItem/TreeItem';
import { TreeItemLayout } from '../TreeItemLayout/TreeItemLayout';
import { TreeItemPersonaLayout } from '../TreeItemPersonaLayout/TreeItemPersonaLayout';

describe('Tree', () => {
isConformant({
Expand Down Expand Up @@ -37,4 +39,97 @@ describe('Tree', () => {

expect(handleOpenChange).toHaveBeenNthCalledWith(2, expect.any(Object), expect.objectContaining({ open: false }));
});

describe('selection control accessibility', () => {
it.each([
{
case: 'multiselect',
selectionMode: 'multiselect',
role: 'checkbox',
layout: <TreeItemLayout>Item 1</TreeItemLayout>,
accessibleName: 'Item 1',
},
{
case: 'single',
selectionMode: 'single',
role: 'radio',
layout: <TreeItemLayout>Item 1</TreeItemLayout>,
accessibleName: 'Item 1',
},
{
case: 'TreeItemPersonaLayout',
selectionMode: 'multiselect',
role: 'checkbox',
layout: <TreeItemPersonaLayout>Jane Doe</TreeItemPersonaLayout>,
accessibleName: 'Jane Doe',
},
] as const)('exposes a named $role selector ($case)', ({ selectionMode, role, layout, accessibleName }) => {
render(
<Tree aria-label="Tree" selectionMode={selectionMode}>
<TreeItem itemType="leaf" value="item1">
{layout}
</TreeItem>
</Tree>,
);

expect(screen.getByRole(role, { name: accessibleName })).toBeTruthy();
});

it.each([
{
case: 'TreeItemLayout',
layout: <TreeItemLayout main={{ id: 'custom-main' }}>Item 1</TreeItemLayout>,
mainId: 'custom-main',
accessibleName: 'Item 1',
},
{
case: 'TreeItemPersonaLayout',
layout: <TreeItemPersonaLayout main={{ id: 'custom-persona-main' }}>Jane Doe</TreeItemPersonaLayout>,
mainId: 'custom-persona-main',
accessibleName: 'Jane Doe',
},
] as const)('preserves a consumer-provided main ID ($case)', ({ layout, mainId, accessibleName }) => {
render(
<Tree aria-label="Tree" selectionMode="multiselect">
<TreeItem itemType="leaf" value="item1">
{layout}
</TreeItem>
</Tree>,
);

expect(screen.getByRole('checkbox', { name: accessibleName }).getAttribute('aria-labelledby')).toBe(mainId);
expect(document.getElementById(mainId)?.textContent).toBe(accessibleName);
});

it('preserves a consumer-provided selector aria-label', () => {
render(
<Tree aria-label="Tree" selectionMode="multiselect">
<TreeItem itemType="leaf" value="item1">
<TreeItemLayout selector={{ 'aria-label': 'Custom selector label' }}>Item 1</TreeItemLayout>
</TreeItem>
</Tree>,
);

const selector = screen.getByRole('checkbox', { name: 'Custom selector label' });
expect(selector.getAttribute('aria-label')).toBe('Custom selector label');
expect(selector.hasAttribute('aria-labelledby')).toBe(false);
});

it('preserves a consumer-provided selector aria-labelledby', () => {
render(
<>
<span id="external-selector-label">External selector label</span>
<Tree aria-label="Tree" selectionMode="multiselect">
<TreeItem itemType="leaf" value="item1">
<TreeItemLayout selector={{ 'aria-labelledby': 'external-selector-label' }}>Item 1</TreeItemLayout>
</TreeItem>
</Tree>
</>,
);

expect(screen.getByRole('checkbox', { name: 'External selector label' }).getAttribute('aria-labelledby')).toBe(
'external-selector-label',
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useEventCallback,
elementContains,
useControllableState,
useId,
} from '@fluentui/react-utilities';
import { useTreeItemContext_unstable, useTreeContext_unstable } from '../../contexts';
import type {
Expand Down Expand Up @@ -66,6 +67,32 @@ export const useTreeItemLayout_unstable = (
const checked = useTreeItemContext_unstable(ctx => ctx.checked);
const isBranch = useTreeItemContext_unstable(ctx => ctx.itemType === 'branch');

const mainShorthand = isResolvedShorthand(main) ? main : undefined;
const mainContentId = useId('fui-TreeItemLayout__main-', mainShorthand?.id);

// Link the selector to the main content unless the consumer provided aria-label or aria-labelledby
const selectorShorthand = isResolvedShorthand(props.selector) ? props.selector : undefined;
const selectorAriaLabelledBy =
selectorShorthand?.['aria-label'] || selectorShorthand?.['aria-labelledby'] ? undefined : mainContentId;

const selector = slot.optional(props.selector, {
renderByDefault: selectionMode !== 'none',
defaultProps: {
checked,
tabIndex: -1,
ref: selectionRef,
'aria-labelledby': selectorAriaLabelledBy,
// casting here to a union between checkbox and radio
// since ref is not present on the selector signature
// FIXME: look into Slot type to see if we can make this work
} 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

defaultProps: { id: selector ? mainContentId : undefined },
elementType: 'div',
});

// FIXME: Asserting is required here, as converting this to RefObject on context type would be a breaking change
// eslint-disable-next-line react-hooks/refs
assertIsRefObject(treeItemRef);
Expand Down Expand Up @@ -142,29 +169,18 @@ export const useTreeItemLayout_unstable = (
[setIsActionsVisible, onActionVisibilityChange, treeItemRef, isNavigatingWithKeyboard, targetDocument],
);

const expandIconRefs = useMergedRefs(expandIconRef);
const expandIcon = slot.optional(props.expandIcon, {
renderByDefault: isBranch,
defaultProps: {
children: <TreeItemChevron />,
'aria-hidden': true,
ref: expandIconRefs,
},
elementType: 'div',
});
const expandIconRefs = useMergedRefs(expandIcon?.ref, expandIconRef);
if (expandIcon) {
expandIcon.ref = expandIconRefs;
}
const arrowNavigationProps = useArrowNavigationGroup({ circular: navigationMode === 'tree', axis: 'horizontal' });
const actions = isActionsVisible
? slot.optional(props.actions, {
defaultProps: { ...arrowNavigationProps, role: 'toolbar' },
elementType: 'div',
})
: undefined;
delete actions?.visible;
delete actions?.onVisibilityChange;

const actionsRefs = useMergedRefs(actions?.ref, actionsRef, actionsRefInternal);
const handleActionsBlur = useEventCallback((event: React.FocusEvent<HTMLDivElement>) => {
if (isResolvedShorthand(props.actions)) {
props.actions.onBlur?.(event);
Expand All @@ -177,10 +193,19 @@ export const useTreeItemLayout_unstable = (
} as Extract<TreeItemLayoutActionVisibilityChangeData, { event: typeof event }>);
setIsActionsVisible(isRelatedTargetFromActions);
});
if (actions) {
actions.ref = actionsRefs;
actions.onBlur = handleActionsBlur;
}

const actionsRefs = useMergedRefs(actionsRef, actionsRefInternal);
const actions = isActionsVisible
? slot.optional(props.actions, {
defaultProps: {
...arrowNavigationProps,
role: 'toolbar',
ref: actionsRefs,
onBlur: handleActionsBlur,
},
elementType: 'div',
})
: undefined;

const hasActions = Boolean(props.actions);

Expand Down Expand Up @@ -233,26 +258,12 @@ export const useTreeItemLayout_unstable = (
},
),
iconBefore: slot.optional(iconBefore, { elementType: 'div' }),
main: slot.always(main, { elementType: 'div' }),
main: mainSlot,
iconAfter: slot.optional(iconAfter, { elementType: 'div' }),
aside: !isActionsVisible ? slot.optional(props.aside, { elementType: 'div' }) : undefined,
actions,
expandIcon,
selector: slot.optional(props.selector, {
renderByDefault: selectionMode !== 'none',
defaultProps: {
checked,
tabIndex: -1,
'aria-hidden': true,
ref: selectionRef,
// casting here to a union between checkbox and radio
// since ref is not present on the selector signature
// FIXME: look into Slot type to see if we can make this work
} as CheckboxProps | RadioProps,
elementType: (selectionMode === 'multiselect' ? Checkbox : Radio) as React.ElementType<
CheckboxProps | RadioProps
>,
}),
selector,
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export const useTreeItemPersonaLayout_unstable = (
selector: (selectionMode === 'multiselect' ? Checkbox : Radio) as React.ElementType<CheckboxProps | RadioProps>,
},
avatarSize: treeAvatarSize[size],
main: slot.always(main, { defaultProps: { children }, elementType: 'div' }),
main: slot.always(main, {
defaultProps: { children, id: treeItemLayoutState.main.id },
elementType: 'div',
}),
media: slot.always(media, { elementType: 'div' }),
description: slot.optional(description, { elementType: 'div' }),
};
Expand Down
Loading