-
Notifications
You must be signed in to change notification settings - Fork 2.9k
fix(react-tree): expose tree selection control to assistive technologies #36384
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
0147f13
41cc16c
26b0e06
bea47ec
c545476
e7c44a7
345ccb5
ea7ea78
b4d0c83
6f2867d
cd49af7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| { | ||
| "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 |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import { | |
| useEventCallback, | ||
| elementContains, | ||
| useControllableState, | ||
| useId, | ||
| } from '@fluentui/react-utilities'; | ||
| import { useTreeItemContext_unstable, useTreeContext_unstable } from '../../contexts'; | ||
| import type { | ||
|
|
@@ -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, { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
|
@@ -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); | ||
|
|
@@ -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); | ||
|
|
||
|
|
@@ -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, | ||
| }; | ||
| }; | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
vr-tests-react-components/Portal 1 screenshots
vr-tests-react-components/Positioning 2 screenshots
vr-tests-react-components/ProgressBar converged 1 screenshots
vr-tests-react-components/TagPicker 1 screenshots
There were 1 duplicate changes discarded. Check the build logs for more information.