Skip to content
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

Add AriaDescription for ContextMenu items #15973

Merged
merged 4 commits into from
Dec 10, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Add support for ariaDescription in context menu items
  • Loading branch information
Savannah Bourgeois committed Nov 18, 2020
commit a1730293c6e8526e3373a363742a251947356354
Original file line number Diff line number Diff line change
Expand Up @@ -3177,6 +3177,8 @@ export interface IContextualMenuClassNames {
// @public (undocumented)
export interface IContextualMenuItem {
[propertyName: string]: any;
ariaDescribedBy?: string;
ariaDescription?: string;
ariaLabel?: string;
canCheck?: boolean;
checked?: boolean;
Expand Down Expand Up @@ -3289,6 +3291,7 @@ export interface IContextualMenuItemStyles extends IButtonStyles {
linkContent: IStyle;
linkContentMenu: IStyle;
root: IStyle;
screenReaderText: IStyle;
secondaryText: IStyle;
splitContainer: IStyle;
splitMenu: IStyle;
Expand Down Expand Up @@ -5905,6 +5908,8 @@ export interface IMenuItemClassNames {
// (undocumented)
root: string;
// (undocumented)
screenReaderText: string;
// (undocumented)
secondaryText: string;
// (undocumented)
splitContainer: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { getDividerClassNames } from '../Divider/VerticalDivider.classNames';
import { getMenuItemStyles } from './ContextualMenu.cnstyles';
import { ITheme, mergeStyleSets, getGlobalClassNames, getScreenSelector, ScreenWidthMaxMedium } from '../../Styling';
import {
ITheme,
mergeStyleSets,
getGlobalClassNames,
getScreenSelector,
ScreenWidthMaxMedium,
hiddenContentStyle,
} from '../../Styling';
import { IVerticalDividerClassNames } from '../Divider/VerticalDivider.types';
import { memoizeFunction, IsFocusVisibleClassName } from '../../Utilities';
import { IContextualMenuItemStyles, IContextualMenuItemStyleProps } from './ContextualMenuItem.types';
Expand Down Expand Up @@ -35,6 +42,7 @@ export interface IMenuItemClassNames {
splitPrimary: string;
splitMenu: string;
linkContentMenu: string;
screenReaderText: string;
}

const CONTEXTUAL_SPLIT_MENU_MINWIDTH = '28px';
Expand Down Expand Up @@ -79,6 +87,7 @@ const GlobalClassNames = {
label: 'ms-ContextualMenu-itemText',
secondaryText: 'ms-ContextualMenu-secondaryText',
splitMenu: 'ms-ContextualMenu-splitMenu',
screenReaderText: 'ms-ContextualMenu-screenReaderText',
};

/**
Expand Down Expand Up @@ -213,6 +222,12 @@ export const getItemClassNames = memoizeFunction(
},
],
],
screenReaderText: [
classNames.screenReaderText,
styles.screenReaderText,
hiddenContentStyle,
{ visibility: 'hidden' },
],
});
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('ContextualMenu', () => {
splitPrimary: 'splitPrimaryFoo',
splitMenu: 'splitMenuFoo',
linkContentMenu: 'linkContentMenuFoo',
screenReaderText: 'screenReaderTextFoo',
};
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,16 @@ export interface IContextualMenuItem {
*/
[propertyName: string]: any;

/**
* Detailed description of the menu item for the benefit of screen readers.
*/
ariaDescription?: string;

/**
SavannahBourgeois marked this conversation as resolved.
Show resolved Hide resolved
* ID of the element that contains additional detailed descriptive information for screen readers
*/
ariaDescribedBy?: string;

/**
* This prop is no longer used. All contextual menu items are now focusable when disabled.
* @deprecated in 6.38.2 will be removed in 7.0.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,5 +173,6 @@ function getMenuItemClassNames(): IMenuItemClassNames {
splitPrimary: 'splitPrimary',
splitMenu: 'splitMenu',
linkContentMenu: 'linkContentMenu',
screenReaderText: 'screenReaderText',
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ export interface IContextualMenuItemStyles extends IButtonStyles {
* Styles for a menu item that is a link.
*/
linkContentMenu: IStyle;

/**
* Styles for hidden screen reader text.
*/
screenReaderText: IStyle;
}

export interface IContextualMenuItemRenderFunctions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@ function getMenuItemClassNames(): IMenuItemClassNames {
splitPrimary: 'splitPrimary',
splitMenu: 'splitMenu',
linkContentMenu: 'linkContentMenu',
screenReaderText: 'screenReaderText',
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,6 @@ function getMenuItemClassNames(): IMenuItemClassNames {
splitPrimary: 'splitPrimary',
splitMenu: 'splitMenu',
linkContentMenu: 'linkContentMenu',
screenReaderText: 'screenReaderText',
};
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import * as React from 'react';
import { anchorProperties, getNativeProps, memoizeFunction } from '../../../Utilities';
import { anchorProperties, getNativeProps, memoizeFunction, getId, mergeAriaAttributeValues } from '../../../Utilities';
import { ContextualMenuItemWrapper } from './ContextualMenuItemWrapper';
import { KeytipData } from '../../../KeytipData';
import { isItemDisabled, hasSubmenu } from '../../../utilities/contextualMenu/index';
import { ContextualMenuItem } from '../ContextualMenuItem';
import { IKeytipDataProps } from '../../KeytipData/KeytipData.types';
import { IKeytipProps } from '../../Keytip/Keytip.types';

export class ContextualMenuAnchor extends ContextualMenuItemWrapper {
private _anchor = React.createRef<HTMLAnchorElement>();
private _ariaDescriptionId: string;

private _getMemoizedMenuButtonKeytipProps = memoizeFunction((keytipProps: IKeytipProps) => {
return {
Expand Down Expand Up @@ -43,21 +43,27 @@ export class ContextualMenuAnchor extends ContextualMenuItemWrapper {
const itemHasSubmenu = hasSubmenu(item);
const nativeProps = getNativeProps<React.HTMLAttributes<HTMLAnchorElement>>(item, anchorProperties);
const disabled = isItemDisabled(item);
const { itemProps } = item;
const { itemProps, ariaDescription } = item;

let { keytipProps } = item;
if (keytipProps && itemHasSubmenu) {
keytipProps = this._getMemoizedMenuButtonKeytipProps(keytipProps);
}

// Check for ariaDescription to set the _ariaDescriptionId and render a hidden span with
// the description in it to be added to ariaDescribedBy
if (ariaDescription) {
this._ariaDescriptionId = getId();
}

return (
<div>
<KeytipData
keytipProps={item.keytipProps}
ariaDescribedBy={nativeProps['aria-describedby']}
disabled={disabled}
>
{(keytipAttributes: IKeytipDataProps): JSX.Element => (
{(keytipAttributes: any): JSX.Element => (
<a
{...nativeProps}
{...keytipAttributes}
Expand All @@ -73,6 +79,11 @@ export class ContextualMenuAnchor extends ContextualMenuItemWrapper {
aria-posinset={focusableElementIndex + 1}
aria-setsize={totalItemCount}
aria-disabled={isItemDisabled(item)}
aria-describedby={mergeAriaAttributeValues(
item.ariaDescribedBy,
ariaDescription ? this._ariaDescriptionId : undefined,
keytipAttributes ? keytipAttributes['aria-describedby'] : undefined,
)}
// eslint-disable-next-line deprecation/deprecation
style={item.style}
onClick={this._onItemClick}
Expand All @@ -94,6 +105,7 @@ export class ContextualMenuAnchor extends ContextualMenuItemWrapper {
getSubmenuTarget={this._getSubmenuTarget}
{...itemProps}
/>
{this._renderAriaDescription(ariaDescription, classNames.screenReaderText)}
</a>
)}
</KeytipData>
Expand All @@ -111,4 +123,13 @@ export class ContextualMenuAnchor extends ContextualMenuItemWrapper {
onItemClick(item, ev);
}
};

protected _renderAriaDescription = (ariaDescription?: string, className?: string) => {
// If ariaDescription is given, descriptionId will be assigned to ariaDescriptionSpan
return ariaDescription ? (
<span id={this._ariaDescriptionId} className={className}>
{ariaDescription}
</span>
) : null;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@ function getMenuItemClassNames(): IMenuItemClassNames {
splitPrimary: 'splitPrimary',
splitMenu: 'splitMenu',
linkContentMenu: 'linkContentMenu',
screenReaderText: 'screenReaderText',
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,6 @@ function getMenuItemClassNames(): IMenuItemClassNames {
splitPrimary: 'splitPrimary',
splitMenu: 'splitMenu',
linkContentMenu: 'linkContentMenu',
screenReaderText: 'screenReaderText',
};
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import * as React from 'react';
import { buttonProperties, getNativeProps, memoizeFunction } from '../../../Utilities';
import { buttonProperties, getNativeProps, memoizeFunction, getId, mergeAriaAttributeValues } from '../../../Utilities';
import { ContextualMenuItemWrapper } from './ContextualMenuItemWrapper';
import { KeytipData } from '../../../KeytipData';
import { getIsChecked, isItemDisabled, hasSubmenu, getMenuItemAriaRole } from '../../../utilities/contextualMenu/index';
import { ContextualMenuItem } from '../ContextualMenuItem';
import { IKeytipDataProps } from '../../KeytipData/KeytipData.types';
import { IKeytipProps } from '../../Keytip/Keytip.types';

export class ContextualMenuButton extends ContextualMenuItemWrapper {
private _btn = React.createRef<HTMLButtonElement>();
private _ariaDescriptionId: string;

private _getMemoizedMenuButtonKeytipProps = memoizeFunction((keytipProps: IKeytipProps) => {
return {
Expand Down Expand Up @@ -41,7 +41,7 @@ export class ContextualMenuButton extends ContextualMenuItemWrapper {
const canCheck: boolean = isChecked !== null;
const defaultRole = getMenuItemAriaRole(item);
const itemHasSubmenu = hasSubmenu(item);
const { itemProps, ariaLabel } = item;
const { itemProps, ariaLabel, ariaDescription } = item;

const buttonNativeProperties = getNativeProps<React.ButtonHTMLAttributes<HTMLButtonElement>>(
item,
Expand All @@ -52,6 +52,16 @@ export class ContextualMenuButton extends ContextualMenuItemWrapper {

const itemRole = item.role || defaultRole;

// Check for ariaDescription to set the _ariaDescriptionId and render a hidden span with
// the description in it to be added to ariaDescribedBy
if (ariaDescription) {
this._ariaDescriptionId = getId();
}
const ariaDescribedByIds = mergeAriaAttributeValues(
item.ariaDescribedBy,
ariaDescription ? this._ariaDescriptionId : undefined,
);

const itemButtonProperties = {
className: classNames.root,
onClick: this._onItemClick,
Expand All @@ -64,6 +74,7 @@ export class ContextualMenuButton extends ContextualMenuItemWrapper {
href: item.href,
title: item.title,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedByIds,
'aria-haspopup': itemHasSubmenu || undefined,
'aria-owns': item.key === expandedMenuItemKey ? subMenuId : undefined,
'aria-expanded': itemHasSubmenu ? item.key === expandedMenuItemKey : undefined,
Expand All @@ -89,8 +100,17 @@ export class ContextualMenuButton extends ContextualMenuItemWrapper {
ariaDescribedBy={buttonNativeProperties['aria-describedby']}
disabled={isItemDisabled(item)}
>
{(keytipAttributes: IKeytipDataProps): JSX.Element => (
<button ref={this._btn} {...buttonNativeProperties} {...itemButtonProperties} {...keytipAttributes}>
{(keytipAttributes: any): JSX.Element => (
<button
ref={this._btn}
{...buttonNativeProperties}
{...itemButtonProperties}
{...keytipAttributes}
aria-describedby={mergeAriaAttributeValues(
itemButtonProperties['aria-describedby'],
keytipAttributes ? keytipAttributes['aria-describedby'] : undefined,
)}
>
<ChildrenRenderer
componentRef={item.componentRef}
item={item}
Expand All @@ -104,12 +124,22 @@ export class ContextualMenuButton extends ContextualMenuItemWrapper {
getSubmenuTarget={this._getSubmenuTarget}
{...itemProps}
/>
{this._renderAriaDescription(ariaDescription, classNames.screenReaderText)}
</button>
)}
</KeytipData>
);
}

protected _renderAriaDescription = (ariaDescription?: string, className?: string) => {
// If ariaDescription is given, descriptionId will be assigned to ariaDescriptionSpan
return ariaDescription ? (
<span id={this._ariaDescriptionId} className={className}>
{ariaDescription}
</span>
) : null;
};

protected _getSubmenuTarget = (): HTMLElement | undefined => {
return this._btn.current ? this._btn.current : undefined;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@ function getMenuItemClassNames(): IMenuItemClassNames {
splitPrimary: 'splitPrimary',
splitMenu: 'splitMenu',
linkContentMenu: 'linkContentMenu',
screenReaderText: 'screenReaderText',
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,6 @@ function getMenuItemClassNames(): IMenuItemClassNames {
splitPrimary: 'splitPrimary',
splitMenu: 'splitMenu',
linkContentMenu: 'linkContentMenu',
screenReaderText: 'screenReaderText',
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
memoizeFunction,
Async,
EventGroup,
getId,
} from '../../../Utilities';
import { ContextualMenuItem } from '../ContextualMenuItem';
import { IContextualMenuItem } from '../ContextualMenu.types';
Expand All @@ -26,6 +27,7 @@ export class ContextualMenuSplitButton extends ContextualMenuItemWrapper {
private _splitButton: HTMLDivElement;
private _lastTouchTimeoutId: number | undefined;
private _processingTouch: boolean;
private _ariaDescriptionId: string;

private _async: Async;
private _events: EventGroup;
Expand Down Expand Up @@ -75,6 +77,13 @@ export class ContextualMenuSplitButton extends ContextualMenuItemWrapper {
keytipProps = this._getMemoizedMenuButtonKeytipProps(keytipProps);
}

// Check for ariaDescription to set the _ariaDescriptionId and render a hidden span with
// the description in it to be added to ariaDescribedBy
const { ariaDescription } = item;
if (ariaDescription) {
this._ariaDescriptionId = getId();
}

return (
<KeytipData keytipProps={keytipProps} disabled={isItemDisabled(item)}>
{(keytipAttributes: any): JSX.Element => (
Expand All @@ -87,7 +96,11 @@ export class ContextualMenuSplitButton extends ContextualMenuItemWrapper {
aria-disabled={isItemDisabled(item)}
aria-expanded={itemHasSubmenu ? item.key === expandedMenuItemKey : undefined}
aria-haspopup={true}
aria-describedby={mergeAriaAttributeValues(item.ariaDescription, keytipAttributes['aria-describedby'])}
aria-describedby={mergeAriaAttributeValues(
item.ariaDescribedBy,
ariaDescription ? this._ariaDescriptionId : undefined,
keytipAttributes['aria-describedby'],
)}
aria-checked={item.isChecked || item.checked}
aria-posinset={focusableElementIndex + 1}
aria-setsize={totalItemCount}
Expand All @@ -106,12 +119,22 @@ export class ContextualMenuSplitButton extends ContextualMenuItemWrapper {
{this._renderSplitPrimaryButton(item, classNames, index, hasCheckmarks!, hasIcons!)}
{this._renderSplitDivider(item)}
{this._renderSplitIconButton(item, classNames, index, keytipAttributes)}
{this._renderAriaDescription(ariaDescription, classNames.screenReaderText)}
</div>
)}
</KeytipData>
);
}

protected _renderAriaDescription = (ariaDescription?: string, className?: string) => {
// If ariaDescription is given, descriptionId will be assigned to ariaDescriptionSpan
return ariaDescription ? (
<span id={this._ariaDescriptionId} className={className}>
{ariaDescription}
</span>
) : null;
};

protected _onItemKeyDown = (ev: React.KeyboardEvent<HTMLElement>): void => {
const { item, onItemKeyDown } = this.props;
if (ev.which === KeyCodes.enter) {
Expand Down
Loading