diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx index 773b78d2584..edcc1948814 100644 --- a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.story.tsx @@ -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) => ( - - {(group.items || []).map((item) => ( - + return menuItems.map((group, index) => ( + + {group.items.map((item) => ( + ))} )); diff --git a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx index 26b7019e857..369782f4a63 100644 --- a/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx +++ b/packages/grafana-ui/src/components/ContextMenu/ContextMenu.tsx @@ -41,12 +41,20 @@ export const ContextMenu: React.FC = 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 ( @@ -55,7 +63,9 @@ export const ContextMenu: React.FC = React.memo( ref={menuRef} style={positionStyles} ariaLabel={selectors.components.Menu.MenuComponent('Context')} + onOpen={onOpen} onClick={onClose} + onKeyDown={onKeyDown} > {menuItems} diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx index 13467c4bb97..7eb4714a71b 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx @@ -23,13 +23,12 @@ export const DataLinksContextMenu: React.FC = ({ chil const itemsGroup: MenuItemsGroup[] = [{ items: linkModelToContextMenuItems(links), label: 'Data links' }]; const renderMenuGroupItems = () => { return itemsGroup.map((group, index) => ( - + {(group.items || []).map((item) => ( (props: Props) => { onChangeInternal(item)} active={item.value === value?.value} /> diff --git a/packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx b/packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx index 97677ea846e..cd0dba40497 100644 --- a/packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx +++ b/packages/grafana-ui/src/components/Graph/GraphContextMenu.tsx @@ -79,13 +79,12 @@ export const GraphContextMenu: React.FC = ({ }; const renderMenuGroupItems = () => { return itemsToRender?.map((group, index) => ( - + {(group.items || []).map((item) => ( = (args) => { - - - - - + + + + + - - - + + + - - + + diff --git a/packages/grafana-ui/src/components/Menu/Menu.tsx b/packages/grafana-ui/src/components/Menu/Menu.tsx index 9de75fe26e7..62ad58a2ce8 100644 --- a/packages/grafana-ui/src/components/Menu/Menu.tsx +++ b/packages/grafana-ui/src/components/Menu/Menu.tsx @@ -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 { @@ -9,15 +10,88 @@ export interface MenuProps extends React.HTMLAttributes { 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( - ({ header, children, ariaLabel, ...otherProps }, ref) => { + ({ header, children, ariaLabel, onOpen, onKeyDown, ...otherProps }, forwardedRef) => { const styles = useStyles2(getStyles); + const [focusedItem, setFocusedItem] = useState(UNFOCUSED); + + const localRef = useRef(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 ( -
+
{header &&
{header}
} {children}
diff --git a/packages/grafana-ui/src/components/Menu/MenuGroup.tsx b/packages/grafana-ui/src/components/Menu/MenuGroup.tsx index 71822258a9d..b730ec8685c 100644 --- a/packages/grafana-ui/src/components/Menu/MenuGroup.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuGroup.tsx @@ -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 { @@ -13,6 +14,7 @@ export interface MenuItemsGroup { /** Items of the group */ items: Array>; } + /** @internal */ export interface MenuGroupProps extends Partial { /** special children prop to pass children elements */ @@ -20,15 +22,16 @@ export interface MenuGroupProps extends Partial { } /** @internal */ -export const MenuGroup: React.FC = ({ label, children, ariaLabel }) => { +export const MenuGroup: React.FC = ({ label, ariaLabel, children }) => { const styles = useStyles2(getStyles); + const labelID = `group-label-${uniqueId()}`; return ( -
+
{label && ( -
+
+ )} {children}
diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.tsx index 01428114d86..a1cdb6a72b5 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.tsx @@ -10,7 +10,7 @@ export interface MenuItemProps { /** 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 { className?: string; /** Active */ active?: boolean; + + tabIndex?: number; } /** @internal */ -export const MenuItem: React.FC = 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( + ({ 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 ( - - ); - } + {icon && } {label} + + ); + } + ) ); 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` diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/PartListSection.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/PartListSection.tsx index a18384418a9..54c3bc1fbd1 100644 --- a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/PartListSection.tsx +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/PartListSection.tsx @@ -25,8 +25,8 @@ type Props = { const renderRemovableNameMenuItems = (onClick: () => void) => { return ( - - + + ); }; diff --git a/public/app/plugins/panel/nodeGraph/useContextMenu.tsx b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx index e14fd30b586..c3c55b0407b 100644 --- a/public/app/plugins/panel/nodeGraph/useContextMenu.tsx +++ b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx @@ -87,7 +87,7 @@ function getItemsRenderer( const items = getItems(links); return () => { let groups = items?.map((group, index) => ( - + {(group.items || []).map(mapMenuItem(item))} )); @@ -106,7 +106,7 @@ function mapMenuItem(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'} /> diff --git a/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx index 23e8d6fc559..0412c089f21 100644 --- a/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx @@ -281,13 +281,12 @@ export const ContextMenuView: React.FC = ({ const renderMenuGroupItems = () => { return items?.map((group, index) => ( - + {(group.items || []).map((item) => (