mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Dropdown: Fix keyboard accessibility (#84683)
* fix dropdown keyboard a11y * remove unnecessary css * restore tabIndex to keep linting happy * use Box in Menu * fix unit test
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import {
|
import {
|
||||||
|
FloatingFocusManager,
|
||||||
autoUpdate,
|
autoUpdate,
|
||||||
flip,
|
flip,
|
||||||
offset as floatingUIOffset,
|
offset as floatingUIOffset,
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
useFloating,
|
useFloating,
|
||||||
useInteractions,
|
useInteractions,
|
||||||
} from '@floating-ui/react';
|
} from '@floating-ui/react';
|
||||||
import { FocusScope } from '@react-aria/focus';
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { CSSTransition } from 'react-transition-group';
|
import { CSSTransition } from 'react-transition-group';
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi
|
|||||||
})}
|
})}
|
||||||
{show && (
|
{show && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<FocusScope autoFocus restoreFocus contain>
|
<FloatingFocusManager context={context}>
|
||||||
{/*
|
{/*
|
||||||
this is handling bubbled events from the inner overlay
|
this is handling bubbled events from the inner overlay
|
||||||
see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
|
see https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
|
||||||
@@ -100,7 +100,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi
|
|||||||
<div ref={transitionRef}>{ReactUtils.renderOrCallToRender(overlay, { ...getFloatingProps() })}</div>
|
<div ref={transitionRef}>{ReactUtils.renderOrCallToRender(overlay, { ...getFloatingProps() })}</div>
|
||||||
</CSSTransition>
|
</CSSTransition>
|
||||||
</div>
|
</div>
|
||||||
</FocusScope>
|
</FloatingFocusManager>
|
||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@@ -4,6 +4,7 @@ import React, { useImperativeHandle, useRef } from 'react';
|
|||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
import { useStyles2 } from '../../themes';
|
import { useStyles2 } from '../../themes';
|
||||||
|
import { Box } from '../Layout/Box/Box';
|
||||||
|
|
||||||
import { MenuDivider } from './MenuDivider';
|
import { MenuDivider } from './MenuDivider';
|
||||||
import { MenuGroup } from './MenuGroup';
|
import { MenuGroup } from './MenuGroup';
|
||||||
@@ -27,17 +28,22 @@ const MenuComp = React.forwardRef<HTMLDivElement, MenuProps>(
|
|||||||
const localRef = useRef<HTMLDivElement>(null);
|
const localRef = useRef<HTMLDivElement>(null);
|
||||||
useImperativeHandle(forwardedRef, () => localRef.current!);
|
useImperativeHandle(forwardedRef, () => localRef.current!);
|
||||||
|
|
||||||
const [handleKeys] = useMenuFocus({ localRef, onOpen, onClose, onKeyDown });
|
const [handleKeys] = useMenuFocus({ isMenuOpen: true, localRef, onOpen, onClose, onKeyDown });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
tabIndex={-1}
|
|
||||||
ref={localRef}
|
|
||||||
className={styles.wrapper}
|
|
||||||
role="menu"
|
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
backgroundColor="primary"
|
||||||
|
borderRadius="default"
|
||||||
|
boxShadow="z3"
|
||||||
|
display="inline-block"
|
||||||
onKeyDown={handleKeys}
|
onKeyDown={handleKeys}
|
||||||
|
paddingX={0}
|
||||||
|
paddingY={0.5}
|
||||||
|
ref={localRef}
|
||||||
|
role="menu"
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{header && (
|
{header && (
|
||||||
<div
|
<div
|
||||||
@@ -50,7 +56,7 @@ const MenuComp = React.forwardRef<HTMLDivElement, MenuProps>(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -66,17 +72,10 @@ export const Menu = Object.assign(MenuComp, {
|
|||||||
const getStyles = (theme: GrafanaTheme2) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
header: css({
|
header: css({
|
||||||
padding: `${theme.spacing(0.5, 1, 1, 1)}`,
|
padding: theme.spacing(0.5, 1, 1, 1),
|
||||||
}),
|
}),
|
||||||
headerBorder: css({
|
headerBorder: css({
|
||||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||||
}),
|
}),
|
||||||
wrapper: css({
|
|
||||||
background: `${theme.colors.background.primary}`,
|
|
||||||
boxShadow: `${theme.shadows.z3}`,
|
|
||||||
display: `inline-block`,
|
|
||||||
borderRadius: `${theme.shape.radius.default}`,
|
|
||||||
padding: `${theme.spacing(0.5, 0)}`,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -79,7 +79,6 @@ export const MenuItem = React.memo(
|
|||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const [isActive, setIsActive] = useState(active);
|
const [isActive, setIsActive] = useState(active);
|
||||||
const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
|
const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
|
||||||
const [openedWithArrow, setOpenedWithArrow] = useState(false);
|
|
||||||
const onMouseEnter = useCallback(() => {
|
const onMouseEnter = useCallback(() => {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
@@ -128,7 +127,6 @@ export const MenuItem = React.memo(
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (hasSubMenu) {
|
if (hasSubMenu) {
|
||||||
setIsSubMenuOpen(true);
|
setIsSubMenuOpen(true);
|
||||||
setOpenedWithArrow(true);
|
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -178,8 +176,6 @@ export const MenuItem = React.memo(
|
|||||||
<SubMenu
|
<SubMenu
|
||||||
items={childItems}
|
items={childItems}
|
||||||
isOpen={isSubMenuOpen}
|
isOpen={isSubMenuOpen}
|
||||||
openedWithArrow={openedWithArrow}
|
|
||||||
setOpenedWithArrow={setOpenedWithArrow}
|
|
||||||
close={closeSubMenu}
|
close={closeSubMenu}
|
||||||
customStyle={customSubMenuContainerStyles}
|
customStyle={customSubMenuContainerStyles}
|
||||||
/>
|
/>
|
||||||
@@ -219,7 +215,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|
||||||
'&:hover, &:focus, &:focus-visible': {
|
'&:hover, &:focus-visible': {
|
||||||
background: theme.colors.action.hover,
|
background: theme.colors.action.hover,
|
||||||
color: theme.colors.text.primary,
|
color: theme.colors.text.primary,
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
|
@@ -13,9 +13,7 @@ describe('SubMenu', () => {
|
|||||||
<MenuItem key="subitem2" label="subitem2" icon="apps" />,
|
<MenuItem key="subitem2" label="subitem2" icon="apps" />,
|
||||||
];
|
];
|
||||||
|
|
||||||
render(
|
render(<SubMenu items={items} isOpen={true} close={jest.fn()} />);
|
||||||
<SubMenu items={items} isOpen={true} openedWithArrow={false} setOpenedWithArrow={jest.fn()} close={jest.fn()} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId(selectors.components.Menu.SubMenu.icon)).toBeInTheDocument();
|
expect(screen.getByTestId(selectors.components.Menu.SubMenu.icon)).toBeInTheDocument();
|
||||||
|
|
||||||
|
@@ -17,10 +17,6 @@ export interface SubMenuProps {
|
|||||||
items?: Array<ReactElement<MenuItemProps>>;
|
items?: Array<ReactElement<MenuItemProps>>;
|
||||||
/** Open */
|
/** Open */
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
/** Marks whether subMenu was opened with arrow */
|
|
||||||
openedWithArrow: boolean;
|
|
||||||
/** Changes value of openedWithArrow */
|
|
||||||
setOpenedWithArrow: (openedWithArrow: boolean) => void;
|
|
||||||
/** Closes the subMenu */
|
/** Closes the subMenu */
|
||||||
close: () => void;
|
close: () => void;
|
||||||
/** Custom style */
|
/** Custom style */
|
||||||
@@ -28,15 +24,12 @@ export interface SubMenuProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export const SubMenu = React.memo(
|
export const SubMenu = React.memo(({ items, isOpen, close, customStyle }: SubMenuProps) => {
|
||||||
({ items, isOpen, openedWithArrow, setOpenedWithArrow, close, customStyle }: SubMenuProps) => {
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const localRef = useRef<HTMLDivElement>(null);
|
const localRef = useRef<HTMLDivElement>(null);
|
||||||
const [handleKeys] = useMenuFocus({
|
const [handleKeys] = useMenuFocus({
|
||||||
localRef,
|
localRef,
|
||||||
isMenuOpen: isOpen,
|
isMenuOpen: isOpen,
|
||||||
openedWithArrow,
|
|
||||||
setOpenedWithArrow,
|
|
||||||
close,
|
close,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,8 +59,7 @@ export const SubMenu = React.memo(
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
SubMenu.displayName = 'SubMenu';
|
SubMenu.displayName = 'SubMenu';
|
||||||
|
|
||||||
|
@@ -141,18 +141,15 @@ describe('useMenuFocus', () => {
|
|||||||
expect(onKeyDown).toHaveBeenCalledTimes(2);
|
expect(onKeyDown).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('focuses on first item when menu was opened with arrow', () => {
|
it('focuses on first item', () => {
|
||||||
const ref = createRef<HTMLDivElement>();
|
const ref = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
render(getMenuElement(ref));
|
render(getMenuElement(ref));
|
||||||
|
|
||||||
const isMenuOpen = true;
|
const isMenuOpen = true;
|
||||||
const openedWithArrow = true;
|
renderHook(() => useMenuFocus({ localRef: ref, isMenuOpen }));
|
||||||
const setOpenedWithArrow = jest.fn();
|
|
||||||
renderHook(() => useMenuFocus({ localRef: ref, isMenuOpen, openedWithArrow, setOpenedWithArrow }));
|
|
||||||
|
|
||||||
expect(screen.getByText('Item 1').tabIndex).toBe(0);
|
expect(screen.getByText('Item 1').tabIndex).toBe(0);
|
||||||
expect(setOpenedWithArrow).toHaveBeenCalledWith(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicks focused item when Enter key is pressed', () => {
|
it('clicks focused item when Enter key is pressed', () => {
|
||||||
|
@@ -8,8 +8,6 @@ const UNFOCUSED = -1;
|
|||||||
export interface UseMenuFocusProps {
|
export interface UseMenuFocusProps {
|
||||||
localRef: RefObject<HTMLDivElement>;
|
localRef: RefObject<HTMLDivElement>;
|
||||||
isMenuOpen?: boolean;
|
isMenuOpen?: boolean;
|
||||||
openedWithArrow?: boolean;
|
|
||||||
setOpenedWithArrow?: (openedWithArrow: boolean) => void;
|
|
||||||
close?: () => void;
|
close?: () => void;
|
||||||
onOpen?: (focusOnItem: (itemId: number) => void) => void;
|
onOpen?: (focusOnItem: (itemId: number) => void) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -23,8 +21,6 @@ export type UseMenuFocusReturn = [(event: React.KeyboardEvent) => void];
|
|||||||
export const useMenuFocus = ({
|
export const useMenuFocus = ({
|
||||||
localRef,
|
localRef,
|
||||||
isMenuOpen,
|
isMenuOpen,
|
||||||
openedWithArrow,
|
|
||||||
setOpenedWithArrow,
|
|
||||||
close,
|
close,
|
||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -33,11 +29,10 @@ export const useMenuFocus = ({
|
|||||||
const [focusedItem, setFocusedItem] = useState(UNFOCUSED);
|
const [focusedItem, setFocusedItem] = useState(UNFOCUSED);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMenuOpen && openedWithArrow) {
|
if (isMenuOpen) {
|
||||||
setFocusedItem(0);
|
setFocusedItem(0);
|
||||||
setOpenedWithArrow?.(false);
|
|
||||||
}
|
}
|
||||||
}, [isMenuOpen, openedWithArrow, setOpenedWithArrow]);
|
}, [isMenuOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const menuItems = localRef?.current?.querySelectorAll<HTMLElement | HTMLButtonElement | HTMLAnchorElement>(
|
const menuItems = localRef?.current?.querySelectorAll<HTMLElement | HTMLButtonElement | HTMLAnchorElement>(
|
||||||
|
@@ -126,6 +126,8 @@ describe('contact points', () => {
|
|||||||
await userEvent.click(button);
|
await userEvent.click(button);
|
||||||
const deleteButton = await screen.queryByRole('menuitem', { name: 'delete' });
|
const deleteButton = await screen.queryByRole('menuitem', { name: 'delete' });
|
||||||
expect(deleteButton).toBeDisabled();
|
expect(deleteButton).toBeDisabled();
|
||||||
|
// click outside the menu to close it otherwise we can't interact with the rest of the page
|
||||||
|
await userEvent.click(document.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check buttons in Notification Templates
|
// check buttons in Notification Templates
|
||||||
|
Reference in New Issue
Block a user