mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
A11y/Menu: Add keyboard support to Menu component (#38974)
This commit is contained in:
parent
f3475b864c
commit
d5b885f958
@ -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>
|
||||
));
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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`
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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'}
|
||||
/>
|
||||
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user