A11y/Menu: Add keyboard support to Menu component (#38974)

This commit is contained in:
kay delaney 2021-09-20 09:08:46 +01:00 committed by GitHub
parent f3475b864c
commit d5b885f958
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 164 additions and 71 deletions

View File

@ -24,15 +24,18 @@ const menuItems = [
items: [
{ label: 'First', ariaLabel: 'First' },
{ label: 'Second', ariaLabel: 'Second' },
{ label: 'Third', ariaLabel: 'Third' },
{ label: 'Fourth', ariaLabel: 'Fourth' },
{ label: 'Fifth', ariaLabel: 'Fifth' },
],
},
];
const renderMenuItems = () => {
return menuItems?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
{(group.items || []).map((item) => (
<MenuItem key={item.label} label={item.label} ariaLabel={item.label} />
return menuItems.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label}>
{group.items.map((item) => (
<MenuItem key={item.label} label={item.label} />
))}
</MenuGroup>
));

View File

@ -41,12 +41,20 @@ export const ContextMenu: React.FC<ContextMenuProps> = React.memo(
}, [x, y]);
useClickAway(menuRef, () => {
if (onClose) {
onClose();
}
onClose?.();
});
const header = renderHeader && renderHeader();
const menuItems = renderMenuItems && renderMenuItems();
const header = renderHeader?.();
const menuItems = renderMenuItems?.();
const onOpen = (setFocusedItem: (a: number) => void) => {
setFocusedItem(0);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose?.();
}
};
return (
<Portal>
@ -55,7 +63,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = React.memo(
ref={menuRef}
style={positionStyles}
ariaLabel={selectors.components.Menu.MenuComponent('Context')}
onOpen={onOpen}
onClick={onClose}
onKeyDown={onKeyDown}
>
{menuItems}
</Menu>

View File

@ -23,13 +23,12 @@ export const DataLinksContextMenu: React.FC<DataLinksContextMenuProps> = ({ chil
const itemsGroup: MenuItemsGroup[] = [{ items: linkModelToContextMenuItems(links), label: 'Data links' }];
const renderMenuGroupItems = () => {
return itemsGroup.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
<MenuGroup key={`${group.label}${index}`} label={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={item.label}
url={item.url}
label={item.label}
ariaLabel={item.label}
target={item.target}
icon={item.icon}
active={item.active}

View File

@ -62,7 +62,6 @@ const ButtonSelectComponent = <T,>(props: Props<T>) => {
<MenuItem
key={`${item.value}`}
label={(item.label || item.value) as string}
ariaLabel={(item.label || item.value) as string}
onClick={() => onChangeInternal(item)}
active={item.value === value?.value}
/>

View File

@ -79,13 +79,12 @@ export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
};
const renderMenuGroupItems = () => {
return itemsToRender?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
<MenuGroup key={`${group.label}${index}`} label={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={`${item.label}`}
url={item.url}
label={item.label}
ariaLabel={item.label}
target={item.target}
icon={item.icon}
active={item.active}

View File

@ -32,21 +32,21 @@ export const Simple: Story<MenuProps> = (args) => {
<VerticalGroup>
<StoryExample name="Simple">
<Menu>
<MenuItem label="Google" icon="search-plus" ariaLabel="Menu item" />
<MenuItem label="Filter" icon="filter" ariaLabel="Menu item" />
<MenuItem label="History" icon="history" ariaLabel="Menu item" />
<MenuItem label="Active" icon="history" active ariaLabel="Menu item" />
<MenuItem label="Apps" icon="apps" ariaLabel="Menu item" />
<MenuItem label="Google" icon="search-plus" />
<MenuItem label="Filter" icon="filter" />
<MenuItem label="History" icon="history" />
<MenuItem label="Active" icon="history" active />
<MenuItem label="Apps" icon="apps" />
</Menu>
</StoryExample>
<StoryExample name="With header & groups">
<Menu header={args.header} ariaLabel="Menu header">
<MenuGroup label="Group 1" ariaLabel="Menu Group">
<MenuItem label="item1" icon="history" ariaLabel="Menu item" />
<MenuItem label="item2" icon="filter" ariaLabel="Menu item" />
<MenuGroup label="Group 1">
<MenuItem label="item1" icon="history" />
<MenuItem label="item2" icon="filter" />
</MenuGroup>
<MenuGroup label="Group 2" ariaLabel="Menu Group">
<MenuItem label="item1" icon="history" ariaLabel="Menu item" />
<MenuGroup label="Group 2">
<MenuItem label="item1" icon="history" />
</MenuGroup>
</Menu>
</StoryExample>

View File

@ -1,7 +1,8 @@
import React from 'react';
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { useEffectOnce } from 'react-use';
/** @internal */
export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
@ -9,15 +10,88 @@ export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
header?: React.ReactNode;
children: React.ReactNode;
ariaLabel?: string;
onOpen?: (focusOnItem: (itemId: number) => void) => void;
onKeyDown?: React.KeyboardEventHandler;
}
const modulo = (a: number, n: number) => ((a % n) + n) % n;
const UNFOCUSED = -1;
type MenuItemElement = HTMLAnchorElement & HTMLButtonElement;
/** @internal */
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(
({ header, children, ariaLabel, ...otherProps }, ref) => {
({ header, children, ariaLabel, onOpen, onKeyDown, ...otherProps }, forwardedRef) => {
const styles = useStyles2(getStyles);
const [focusedItem, setFocusedItem] = useState(UNFOCUSED);
const localRef = useRef<HTMLDivElement>(null);
useImperativeHandle(forwardedRef, () => localRef.current!);
useEffect(() => {
const menuItems = localRef?.current?.querySelectorAll(`[data-role="menuitem"]`);
(menuItems?.[focusedItem] as MenuItemElement)?.focus();
menuItems?.forEach((menuItem, i) => {
(menuItem as MenuItemElement).tabIndex = i === focusedItem ? 0 : -1;
});
}, [localRef, focusedItem]);
useEffectOnce(() => {
const firstMenuItem = localRef?.current?.querySelector(`[data-role="menuitem"]`) as MenuItemElement | null;
if (firstMenuItem) {
firstMenuItem.tabIndex = 0;
}
onOpen?.(setFocusedItem);
});
const handleKeys = (event: React.KeyboardEvent) => {
const menuItemsCount = localRef?.current?.querySelectorAll('[data-role="menuitem"]').length ?? 0;
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
event.stopPropagation();
setFocusedItem(modulo(focusedItem - 1, menuItemsCount));
break;
case 'ArrowDown':
event.preventDefault();
event.stopPropagation();
setFocusedItem(modulo(focusedItem + 1, menuItemsCount));
break;
case 'Home':
event.preventDefault();
event.stopPropagation();
setFocusedItem(0);
break;
case 'End':
event.preventDefault();
event.stopPropagation();
setFocusedItem(menuItemsCount - 1);
break;
default:
break;
}
// Forward event to parent
onKeyDown?.(event);
};
const handleFocus = () => {
if (focusedItem === UNFOCUSED) {
setFocusedItem(0);
}
};
return (
<div {...otherProps} ref={ref} className={styles.wrapper} aria-label={ariaLabel}>
<div
{...otherProps}
ref={localRef}
className={styles.wrapper}
role="menu"
aria-label={ariaLabel}
onKeyDown={handleKeys}
onFocus={handleFocus}
>
{header && <div className={styles.header}>{header}</div>}
{children}
</div>

View File

@ -3,6 +3,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { MenuItemProps } from './MenuItem';
import { uniqueId } from 'lodash';
/** @internal */
export interface MenuItemsGroup<T = any> {
@ -13,6 +14,7 @@ export interface MenuItemsGroup<T = any> {
/** Items of the group */
items: Array<MenuItemProps<T>>;
}
/** @internal */
export interface MenuGroupProps extends Partial<MenuItemsGroup> {
/** special children prop to pass children elements */
@ -20,15 +22,16 @@ export interface MenuGroupProps extends Partial<MenuItemsGroup> {
}
/** @internal */
export const MenuGroup: React.FC<MenuGroupProps> = ({ label, children, ariaLabel }) => {
export const MenuGroup: React.FC<MenuGroupProps> = ({ label, ariaLabel, children }) => {
const styles = useStyles2(getStyles);
const labelID = `group-label-${uniqueId()}`;
return (
<div>
<div role="group" aria-labelledby={!ariaLabel && label ? labelID : undefined} aria-label={ariaLabel}>
{label && (
<div className={styles.groupLabel} aria-label={ariaLabel}>
<label id={labelID} className={styles.groupLabel} aria-hidden>
{label}
</div>
</label>
)}
{children}
</div>

View File

@ -10,7 +10,7 @@ export interface MenuItemProps<T = any> {
/** Label of the menu item */
label: string;
/** Aria label for accessibility support */
ariaLabel: string;
ariaLabel?: string;
/** Target of the menu item (i.e. new window) */
target?: LinkTarget;
/** Icon of the menu item */
@ -23,26 +23,30 @@ export interface MenuItemProps<T = any> {
className?: string;
/** Active */
active?: boolean;
tabIndex?: number;
}
/** @internal */
export const MenuItem: React.FC<MenuItemProps> = React.memo(
({ url, icon, label, ariaLabel, target, onClick, className, active }) => {
const styles = useStyles2(getStyles);
const itemStyle = cx(
{
[styles.item]: true,
[styles.activeItem]: active,
},
className
);
export const MenuItem = React.memo(
React.forwardRef<HTMLAnchorElement & HTMLButtonElement, MenuItemProps>(
({ url, icon, label, ariaLabel, target, onClick, className, active, tabIndex = -1 }, ref) => {
const styles = useStyles2(getStyles);
const itemStyle = cx(
{
[styles.item]: true,
[styles.activeItem]: active,
},
className
);
return (
<div className={itemStyle} aria-label={ariaLabel}>
<a
href={url ? url : undefined}
const Wrapper = url === undefined ? 'button' : 'a';
return (
<Wrapper
target={target}
className={styles.link}
className={itemStyle}
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
href={url}
onClick={
onClick
? (event) => {
@ -53,37 +57,40 @@ export const MenuItem: React.FC<MenuItemProps> = React.memo(
}
: undefined
}
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
role={url === undefined ? 'menuitem' : undefined}
data-role="menuitem" // used to identify menuitem in Menu.tsx
ref={ref}
aria-label={ariaLabel}
tabIndex={tabIndex}
>
{icon && <Icon name={icon} className={styles.icon} />} {label}
</a>
</div>
);
}
{icon && <Icon name={icon} className={styles.icon} aria-hidden />} {label}
</Wrapper>
);
}
)
);
MenuItem.displayName = 'MenuItem';
/** @internal */
const getStyles = (theme: GrafanaTheme2) => {
return {
link: css`
color: ${theme.colors.text.primary};
display: flex;
cursor: pointer;
padding: 5px 12px 5px 10px;
&:hover {
color: ${theme.colors.text.primary};
text-decoration: none;
}
`,
item: css`
background: none;
cursor: pointer;
white-space: nowrap;
color: ${theme.colors.text.primary};
display: flex;
padding: 5px 12px 5px 10px;
margin: 0;
border: none;
width: 100%;
&:hover {
&:hover,
&:focus,
&:focus-visible {
background: ${theme.colors.action.hover};
color: ${theme.colors.text.primary};
text-decoration: none;
}
`,
activeItem: css`

View File

@ -25,8 +25,8 @@ type Props = {
const renderRemovableNameMenuItems = (onClick: () => void) => {
return (
<MenuGroup label="" ariaLabel="">
<MenuItem label="remove" ariaLabel="remove" onClick={onClick} />
<MenuGroup label="">
<MenuItem label="remove" onClick={onClick} />
</MenuGroup>
);
};

View File

@ -87,7 +87,7 @@ function getItemsRenderer<T extends NodeDatum | EdgeDatum>(
const items = getItems(links);
return () => {
let groups = items?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
<MenuGroup key={`${group.label}${index}`} label={group.label}>
{(group.items || []).map(mapMenuItem(item))}
</MenuGroup>
));
@ -106,7 +106,7 @@ function mapMenuItem<T extends NodeDatum | EdgeDatum>(item: T) {
key={link.label}
url={link.url}
label={link.label}
ariaLabel={link.ariaLabel || link.label}
ariaLabel={link.ariaLabel}
onClick={link.onClick ? () => link.onClick?.(item) : undefined}
target={'_self'}
/>

View File

@ -281,13 +281,12 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
const renderMenuGroupItems = () => {
return items?.map((group, index) => (
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
<MenuGroup key={`${group.label}${index}`} label={group.label}>
{(group.items || []).map((item) => (
<MenuItem
key={item.label}
url={item.url}
label={item.label}
ariaLabel={item.label}
target={item.target}
icon={item.icon}
active={item.active}