Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/public-docsite-v9/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@fluentui/recipes": "*",
"@fluentui/theme-designer": "*",
"@griffel/react": "^1.5.32",
"@microsoft/applicationinsights-web": "^3",
"@microsoft/applicationinsights-web": "^3.4.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^5.7.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "chore: security dependency remediation for S360 CVEs",
"packageName": "@fluentui/chart-web-components",
"email": "paulmardling@microsoft.com",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "chore: security dependency remediation for S360 CVEs",
"packageName": "@fluentui/react-conformance",
"email": "paulmardling@microsoft.com",
"dependentChangeType": "none"
}
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
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "chore: security dependency remediation for S360 CVEs",
"packageName": "@fluentui/web-components",
"email": "paulmardling@microsoft.com",
"dependentChangeType": "none"
}
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@
"license-webpack-plugin": "4.0.2",
"lint-staged": "10.2.10",
"loader-utils": "2.0.4",
"lodash": "4.17.21",
"lodash": "^4.18.1",
"markdown-table": "2.0.0",
"memfs": "3.5.3",
"micromatch": "4.0.8",
Expand Down Expand Up @@ -309,7 +309,7 @@
"terser": "5.39.1",
"terser-webpack-plugin": "5.3.10",
"through2": "4.0.2",
"tmp": "0.2.1",
"tmp": "^0.2.7",
"ts-jest": "29.4.5",
"ts-loader": "9.4.2",
"ts-node": "10.9.2",
Expand Down Expand Up @@ -379,7 +379,7 @@
"swc-loader": "0.2.6",
"syncpack/minimatch": "^9.0.7",
"tar-fs": "2.1.4",
"ws": "8.17.1"
"ws": "^8.21.1"
},
"nx": {
"includedScripts": []
Expand Down
2 changes: 1 addition & 1 deletion packages/charts/chart-web-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"@storybook/html": "9.1.17",
"@storybook/html-vite": "9.1.17",
"@tensile-perf/web-components": "~0.2.2",
"chromedriver": "^125.0.0"
"chromedriver": "^151.0.0"
},
"dependencies": {
"@fluentui/tokens": "^1.0.0-alpha.23",
Expand Down
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,118 @@ 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 layout ID', () => {
render(
<Tree aria-label="Tree" selectionMode="multiselect">
<TreeItem itemType="leaf" value="item1">
<TreeItemLayout id="custom-layout" main={{ id: 'lower-priority-main-id' }}>
Item 1
</TreeItemLayout>
</TreeItem>
</Tree>,
);

const selector = screen.getByRole('checkbox', { name: 'Item 1' });
const mainId = selector.getAttribute('aria-labelledby');
const layout = document.getElementById('custom-layout');
const main = document.getElementById('lower-priority-main-id');

expect(layout?.classList.contains('fui-TreeItemLayout')).toBe(true);
expect(mainId).toBe('lower-priority-main-id');
expect(main?.classList.contains('fui-TreeItemLayout__main')).toBe(true);
});

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 an accessible name
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, {
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
2 changes: 1 addition & 1 deletion packages/react-conformance/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"dependencies": {
"@swc/helpers": "^0.5.1",
"chalk": "^2.4.2",
"lodash": "^4.17.15",
"lodash": "^4.18.1",
"react-docgen-typescript": "^2.1.0",
"react-is": "^17.0.2"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/web-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
"@wc-toolkit/cem-validator": "1.0.3",
"@wc-toolkit/module-path-resolver": "1.0.0",
"@wc-toolkit/type-parser": "1.0.3",
"chromedriver": "^125.0.0",
"chromedriver": "^151.0.0",
"dedent": "^1.2.0",
"rollup-plugin-fast-tagged-templates": "^1.0.2"
},
Expand Down
Loading
Loading