mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Menu: Adds SubMenu component to support fly-out sub-menu. (#41647)
This commit is contained in:
committed by
GitHub
parent
e5811ad106
commit
3dd73387fa
@@ -50,6 +50,10 @@ export const Components = {
|
||||
MenuComponent: (title: string) => `${title} menu`,
|
||||
MenuGroup: (title: string) => `${title} menu group`,
|
||||
MenuItem: (title: string) => `${title} menu item`,
|
||||
SubMenu: {
|
||||
container: 'SubMenu container',
|
||||
icon: 'SubMenu icon',
|
||||
},
|
||||
},
|
||||
Panels: {
|
||||
Panel: {
|
||||
|
||||
@@ -50,6 +50,30 @@ export const Simple: Story<MenuProps> = (args) => {
|
||||
</MenuGroup>
|
||||
</Menu>
|
||||
</StoryExample>
|
||||
<StoryExample name="With submenu">
|
||||
<Menu>
|
||||
<MenuItem label="item1" icon="history" />
|
||||
<MenuItem
|
||||
label="item2"
|
||||
icon="apps"
|
||||
childItems={[
|
||||
<MenuItem key="subitem1" label="subitem1" icon="history" />,
|
||||
<MenuItem key="subitem2" label="subitem2" icon="apps" />,
|
||||
<MenuItem
|
||||
key="subitem3"
|
||||
label="subitem3"
|
||||
icon="search-plus"
|
||||
childItems={[
|
||||
<MenuItem key="subitem1" label="subitem1" icon="history" />,
|
||||
<MenuItem key="subitem2" label="subitem2" icon="apps" />,
|
||||
<MenuItem key="subitem3" label="subitem3" icon="search-plus" />,
|
||||
]}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
<MenuItem label="item3" icon="filter" />
|
||||
</Menu>
|
||||
</StoryExample>
|
||||
</VerticalGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import React, { useImperativeHandle, useRef } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
import { useMenuFocus } from './hooks';
|
||||
|
||||
/** @internal */
|
||||
export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -15,81 +15,15 @@ export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
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, onOpen, onClose, 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) {
|
||||
setFocusedItem(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;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose?.();
|
||||
break;
|
||||
case 'Tab':
|
||||
onClose?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Forward event to parent
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (focusedItem === UNFOCUSED) {
|
||||
setFocusedItem(0);
|
||||
}
|
||||
};
|
||||
const [handleKeys, handleFocus] = useMenuFocus({ localRef, onOpen, onClose, onKeyDown });
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
66
packages/grafana-ui/src/components/Menu/MenuItem.test.tsx
Normal file
66
packages/grafana-ui/src/components/Menu/MenuItem.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { MenuItem, MenuItemProps } from './MenuItem';
|
||||
|
||||
describe('MenuItem', () => {
|
||||
const getMenuItem = (props?: Partial<MenuItemProps>) => (
|
||||
<MenuItem ariaLabel={selectors.components.Menu.MenuItem('Test')} label="item1" icon="history" {...props} />
|
||||
);
|
||||
|
||||
it('renders correct element type', () => {
|
||||
const { rerender } = render(getMenuItem({ onClick: jest.fn() }));
|
||||
|
||||
expect(screen.getByLabelText(selectors.components.Menu.MenuItem('Test')).nodeName).toBe('BUTTON');
|
||||
|
||||
rerender(getMenuItem({ url: 'test' }));
|
||||
|
||||
expect(screen.getByLabelText(selectors.components.Menu.MenuItem('Test')).nodeName).toBe('A');
|
||||
});
|
||||
|
||||
it('calls onClick when item is clicked', () => {
|
||||
const onClick = jest.fn();
|
||||
|
||||
render(getMenuItem({ onClick }));
|
||||
|
||||
fireEvent.click(screen.getByLabelText(selectors.components.Menu.MenuItem('Test')));
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders and opens subMenu correctly', async () => {
|
||||
const childItems = [
|
||||
<MenuItem key="subitem1" label="subitem1" icon="history" />,
|
||||
<MenuItem key="subitem2" label="subitem2" icon="apps" />,
|
||||
];
|
||||
|
||||
render(getMenuItem({ childItems }));
|
||||
|
||||
expect(screen.getByLabelText(selectors.components.Menu.MenuItem('Test')).nodeName).toBe('DIV');
|
||||
expect(screen.getByLabelText(selectors.components.Menu.SubMenu.icon)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(selectors.components.Menu.SubMenu.container)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseOver(screen.getByLabelText(selectors.components.Menu.MenuItem('Test')));
|
||||
|
||||
const subMenuContainer = await screen.findByLabelText(selectors.components.Menu.SubMenu.container);
|
||||
|
||||
expect(subMenuContainer).toBeInTheDocument();
|
||||
expect(subMenuContainer.firstChild?.childNodes.length).toBe(2);
|
||||
});
|
||||
|
||||
it('opens subMenu on ArrowRight', async () => {
|
||||
const childItems = [
|
||||
<MenuItem key="subitem1" label="subitem1" icon="history" />,
|
||||
<MenuItem key="subitem2" label="subitem2" icon="apps" />,
|
||||
];
|
||||
|
||||
render(getMenuItem({ childItems }));
|
||||
|
||||
expect(screen.queryByLabelText(selectors.components.Menu.SubMenu.container)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(screen.getByLabelText(selectors.components.Menu.MenuItem('Test')), { key: 'ArrowRight' });
|
||||
|
||||
expect(await screen.findByLabelText(selectors.components.Menu.SubMenu.container)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { ReactElement, useCallback, useMemo, useState, useRef, useImperativeHandle } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme2, LinkTarget } from '@grafana/data';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../../types';
|
||||
import { SubMenu } from './SubMenu';
|
||||
import { getFocusStyles } from '../../themes/mixins';
|
||||
|
||||
/** @internal */
|
||||
export type MenuItemElement = HTMLAnchorElement & HTMLButtonElement & HTMLDivElement;
|
||||
|
||||
/** @internal */
|
||||
export interface MenuItemProps<T = any> {
|
||||
@@ -29,11 +34,14 @@ export interface MenuItemProps<T = any> {
|
||||
active?: boolean;
|
||||
|
||||
tabIndex?: number;
|
||||
|
||||
/** List of menu items for the subMenu */
|
||||
childItems?: Array<ReactElement<MenuItemProps>>;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const MenuItem = React.memo(
|
||||
React.forwardRef<HTMLAnchorElement & HTMLButtonElement, MenuItemProps>((props, ref) => {
|
||||
React.forwardRef<MenuItemElement, MenuItemProps>((props, ref) => {
|
||||
const {
|
||||
url,
|
||||
icon,
|
||||
@@ -44,19 +52,57 @@ export const MenuItem = React.memo(
|
||||
onClick,
|
||||
className,
|
||||
active,
|
||||
childItems,
|
||||
role = 'menuitem',
|
||||
tabIndex = -1,
|
||||
} = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
const [isActive, setIsActive] = useState(active);
|
||||
const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
|
||||
const [openedWithArrow, setOpenedWithArrow] = useState(false);
|
||||
const onMouseEnter = useCallback(() => {
|
||||
setIsSubMenuOpen(true);
|
||||
setIsActive(true);
|
||||
}, []);
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setIsSubMenuOpen(false);
|
||||
setIsActive(false);
|
||||
}, []);
|
||||
const hasSubMenu = useMemo(() => childItems && childItems.length > 0, [childItems]);
|
||||
const Wrapper = hasSubMenu ? 'div' : url === undefined ? 'button' : 'a';
|
||||
const itemStyle = cx(
|
||||
{
|
||||
[styles.item]: true,
|
||||
[styles.activeItem]: active,
|
||||
[styles.activeItem]: isActive,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const Wrapper = url === undefined ? 'button' : 'a';
|
||||
const localRef = useRef<MenuItemElement>(null);
|
||||
useImperativeHandle(ref, () => localRef.current!);
|
||||
|
||||
const handleKeys = (event: React.KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (hasSubMenu) {
|
||||
setIsSubMenuOpen(true);
|
||||
setOpenedWithArrow(true);
|
||||
setIsActive(true);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const closeSubMenu = () => {
|
||||
setIsSubMenuOpen(false);
|
||||
setIsActive(false);
|
||||
localRef?.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
target={target}
|
||||
@@ -68,19 +114,33 @@ export const MenuItem = React.memo(
|
||||
? (event) => {
|
||||
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && onClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick(event);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onKeyDown={handleKeys}
|
||||
role={url === undefined ? role : undefined}
|
||||
data-role="menuitem" // used to identify menuitem in Menu.tsx
|
||||
ref={ref}
|
||||
ref={localRef}
|
||||
aria-label={ariaLabel}
|
||||
aria-checked={ariaChecked}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{icon && <Icon name={icon} className={styles.icon} aria-hidden />} {label}
|
||||
{icon && <Icon name={icon} className={styles.icon} aria-hidden />}
|
||||
{label}
|
||||
{hasSubMenu && (
|
||||
<SubMenu
|
||||
items={childItems}
|
||||
isOpen={isSubMenuOpen}
|
||||
openedWithArrow={openedWithArrow}
|
||||
setOpenedWithArrow={setOpenedWithArrow}
|
||||
close={closeSubMenu}
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
})
|
||||
@@ -100,6 +160,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
margin: 0;
|
||||
border: none;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
@@ -108,6 +169,10 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
color: ${theme.colors.text.primary};
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
${getFocusStyles(theme)}
|
||||
}
|
||||
`,
|
||||
activeItem: css`
|
||||
background: ${theme.colors.action.selected};
|
||||
|
||||
25
packages/grafana-ui/src/components/Menu/SubMenu.test.tsx
Normal file
25
packages/grafana-ui/src/components/Menu/SubMenu.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { MenuItem } from './MenuItem';
|
||||
import { SubMenu } from './SubMenu';
|
||||
|
||||
describe('SubMenu', () => {
|
||||
it('renders and opens SubMenu', async () => {
|
||||
const items = [
|
||||
<MenuItem key="subitem1" label="subitem1" icon="history" />,
|
||||
<MenuItem key="subitem2" label="subitem2" icon="apps" />,
|
||||
];
|
||||
|
||||
render(
|
||||
<SubMenu items={items} isOpen={true} openedWithArrow={false} setOpenedWithArrow={jest.fn()} close={jest.fn()} />
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(selectors.components.Menu.SubMenu.icon)).toBeInTheDocument();
|
||||
|
||||
const subMenuContainer = await screen.findByLabelText(selectors.components.Menu.SubMenu.container);
|
||||
|
||||
expect(subMenuContainer).toBeInTheDocument();
|
||||
expect(subMenuContainer.firstChild?.childNodes.length).toBe(2);
|
||||
});
|
||||
});
|
||||
86
packages/grafana-ui/src/components/Menu/SubMenu.tsx
Normal file
86
packages/grafana-ui/src/components/Menu/SubMenu.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { ReactElement, useRef } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { MenuItemProps } from './MenuItem';
|
||||
import { getPosition } from './utils';
|
||||
import { useMenuFocus } from './hooks';
|
||||
|
||||
/** @internal */
|
||||
export interface SubMenuProps {
|
||||
/** List of menu items of the subMenu */
|
||||
items?: Array<ReactElement<MenuItemProps>>;
|
||||
/** Open */
|
||||
isOpen: boolean;
|
||||
/** Marks whether subMenu was opened with arrow */
|
||||
openedWithArrow: boolean;
|
||||
/** Changes value of openedWithArrow */
|
||||
setOpenedWithArrow: (openedWithArrow: boolean) => void;
|
||||
/** Closes the subMenu */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export const SubMenu: React.FC<SubMenuProps> = React.memo(
|
||||
({ items, isOpen, openedWithArrow, setOpenedWithArrow, close }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const localRef = useRef<HTMLDivElement>(null);
|
||||
const [handleKeys] = useMenuFocus({
|
||||
localRef,
|
||||
isMenuOpen: isOpen,
|
||||
openedWithArrow,
|
||||
setOpenedWithArrow,
|
||||
close,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.iconWrapper} aria-label={selectors.components.Menu.SubMenu.icon}>
|
||||
<Icon name="angle-right" className={styles.icon} aria-hidden />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={localRef}
|
||||
className={styles.subMenu(localRef.current)}
|
||||
aria-label={selectors.components.Menu.SubMenu.container}
|
||||
>
|
||||
<div className={styles.itemsWrapper} role="menu" onKeyDown={handleKeys}>
|
||||
{items}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
SubMenu.displayName = 'SubMenu';
|
||||
|
||||
/** @internal */
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
iconWrapper: css`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: end;
|
||||
`,
|
||||
icon: css`
|
||||
opacity: 0.7;
|
||||
margin-left: 10px;
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
itemsWrapper: css`
|
||||
background: ${theme.colors.background.primary};
|
||||
box-shadow: ${theme.shadows.z3};
|
||||
display: inline-block;
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
`,
|
||||
subMenu: (element: HTMLElement | null) => css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: ${theme.zIndex.dropdown};
|
||||
${getPosition(element)}: 100%;
|
||||
`,
|
||||
};
|
||||
};
|
||||
189
packages/grafana-ui/src/components/Menu/hooks.test.tsx
Normal file
189
packages/grafana-ui/src/components/Menu/hooks.test.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React, { createRef, KeyboardEvent, RefObject } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import { useMenuFocus } from './hooks';
|
||||
|
||||
describe('useMenuFocus', () => {
|
||||
const testid = 'test';
|
||||
const getMenuElement = (
|
||||
ref: RefObject<HTMLDivElement>,
|
||||
handleKeys?: (event: KeyboardEvent) => void,
|
||||
handleFocus?: () => void,
|
||||
onClick?: () => void
|
||||
) => (
|
||||
<div data-testid={testid} ref={ref} tabIndex={0} onKeyDown={handleKeys} onFocus={handleFocus}>
|
||||
<span data-role="menuitem" onClick={onClick}>
|
||||
Item 1
|
||||
</span>
|
||||
<span data-role="menuitem">Item 2</span>
|
||||
<span data-role="menuitem">Item 3</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
it('sets correct focused item on keydown', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
const { result } = renderHook(() => useMenuFocus({ localRef: ref }));
|
||||
const [handleKeys] = result.current;
|
||||
const { rerender } = render(getMenuElement(ref, handleKeys));
|
||||
|
||||
expect(screen.getByText('Item 1').tabIndex).toBe(-1);
|
||||
expect(screen.getByText('Item 2').tabIndex).toBe(-1);
|
||||
expect(screen.getByText('Item 3').tabIndex).toBe(-1);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowDown' });
|
||||
});
|
||||
|
||||
const [handleKeys2] = result.current;
|
||||
rerender(getMenuElement(ref, handleKeys2));
|
||||
|
||||
expect(screen.getByText('Item 1').tabIndex).toBe(0);
|
||||
expect(screen.getByText('Item 2').tabIndex).toBe(-1);
|
||||
expect(screen.getByText('Item 3').tabIndex).toBe(-1);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowDown' });
|
||||
});
|
||||
|
||||
const [handleKeys3] = result.current;
|
||||
rerender(getMenuElement(ref, handleKeys3));
|
||||
|
||||
expect(screen.getByText('Item 1').tabIndex).toBe(-1);
|
||||
expect(screen.getByText('Item 2').tabIndex).toBe(0);
|
||||
expect(screen.getByText('Item 3').tabIndex).toBe(-1);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowUp' });
|
||||
});
|
||||
|
||||
const [handleKeys4] = result.current;
|
||||
rerender(getMenuElement(ref, handleKeys4));
|
||||
|
||||
expect(screen.getByText('Item 1').tabIndex).toBe(0);
|
||||
expect(screen.getByText('Item 2').tabIndex).toBe(-1);
|
||||
expect(screen.getByText('Item 3').tabIndex).toBe(-1);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowUp' });
|
||||
});
|
||||
|
||||
const [handleKeys5] = result.current;
|
||||
rerender(getMenuElement(ref, handleKeys5));
|
||||
|
||||
expect(screen.getByText('Item 1').tabIndex).toBe(-1);
|
||||
expect(screen.getByText('Item 2').tabIndex).toBe(-1);
|
||||
expect(screen.getByText('Item 3').tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('calls close on ArrowLeft and unfocuses all items', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
const close = jest.fn();
|
||||
const { result } = renderHook(() => useMenuFocus({ localRef: ref, close }));
|
||||
const [handleKeys] = result.current;
|
||||
const { rerender } = render(getMenuElement(ref, handleKeys));
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowDown' });
|
||||
});
|
||||
|
||||
const [handleKeys2] = result.current;
|
||||
rerender(getMenuElement(ref, handleKeys2));
|
||||
|
||||
expect(screen.getByText('Item 1').tabIndex).toBe(0);
|
||||
expect(screen.getByText('Item 2').tabIndex).toBe(-1);
|
||||
expect(screen.getByText('Item 3').tabIndex).toBe(-1);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowLeft' });
|
||||
});
|
||||
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(screen.getByText('Item 1').tabIndex).toBe(-1);
|
||||
expect(screen.getByText('Item 2').tabIndex).toBe(-1);
|
||||
expect(screen.getByText('Item 3').tabIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('forwards keydown and open events', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
const onOpen = jest.fn();
|
||||
const onKeyDown = jest.fn();
|
||||
const { result } = renderHook(() => useMenuFocus({ localRef: ref, onOpen, onKeyDown }));
|
||||
const [handleKeys] = result.current;
|
||||
|
||||
render(getMenuElement(ref, handleKeys));
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'Home' });
|
||||
});
|
||||
|
||||
expect(onOpen).toHaveBeenCalled();
|
||||
expect(onKeyDown).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('focuses on first item when menu was opened with arrow', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
|
||||
render(getMenuElement(ref));
|
||||
|
||||
const isMenuOpen = true;
|
||||
const openedWithArrow = true;
|
||||
const setOpenedWithArrow = jest.fn();
|
||||
renderHook(() => useMenuFocus({ localRef: ref, isMenuOpen, openedWithArrow, setOpenedWithArrow }));
|
||||
|
||||
expect(screen.getByText('Item 1').tabIndex).toBe(0);
|
||||
expect(setOpenedWithArrow).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('focuses on first item when container receives focus', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
const { result } = renderHook(() => useMenuFocus({ localRef: ref }));
|
||||
const [_, handleFocus] = result.current;
|
||||
|
||||
render(getMenuElement(ref, undefined, handleFocus));
|
||||
|
||||
act(() => {
|
||||
screen.getByTestId(testid).focus();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Item 1').tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('clicks focused item when Enter key is pressed', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
const onClick = jest.fn();
|
||||
const { result } = renderHook(() => useMenuFocus({ localRef: ref }));
|
||||
const [handleKeys] = result.current;
|
||||
const { rerender } = render(getMenuElement(ref, handleKeys, undefined, onClick));
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowDown' });
|
||||
});
|
||||
|
||||
const [handleKeys2] = result.current;
|
||||
rerender(getMenuElement(ref, handleKeys2, undefined, onClick));
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'Enter' });
|
||||
});
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose on Tab or Escape', () => {
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
const onClose = jest.fn();
|
||||
const { result } = renderHook(() => useMenuFocus({ localRef: ref, onClose }));
|
||||
const [handleKeys] = result.current;
|
||||
|
||||
render(getMenuElement(ref, handleKeys));
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'Tab' });
|
||||
fireEvent.keyDown(screen.getByTestId(testid), { key: 'Escape' });
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
118
packages/grafana-ui/src/components/Menu/hooks.ts
Normal file
118
packages/grafana-ui/src/components/Menu/hooks.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
import { MenuItemElement } from './MenuItem';
|
||||
|
||||
const modulo = (a: number, n: number) => ((a % n) + n) % n;
|
||||
const UNFOCUSED = -1;
|
||||
|
||||
/** @internal */
|
||||
export interface UseMenuFocusProps {
|
||||
localRef: RefObject<HTMLDivElement>;
|
||||
isMenuOpen?: boolean;
|
||||
openedWithArrow?: boolean;
|
||||
setOpenedWithArrow?: (openedWithArrow: boolean) => void;
|
||||
close?: () => void;
|
||||
onOpen?: (focusOnItem: (itemId: number) => void) => void;
|
||||
onClose?: () => void;
|
||||
onKeyDown?: React.KeyboardEventHandler;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type UseMenuFocusReturn = [(event: React.KeyboardEvent) => void, () => void];
|
||||
|
||||
/** @internal */
|
||||
export const useMenuFocus = ({
|
||||
localRef,
|
||||
isMenuOpen,
|
||||
openedWithArrow,
|
||||
setOpenedWithArrow,
|
||||
close,
|
||||
onOpen,
|
||||
onClose,
|
||||
onKeyDown,
|
||||
}: UseMenuFocusProps): UseMenuFocusReturn => {
|
||||
const [focusedItem, setFocusedItem] = useState(UNFOCUSED);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMenuOpen && openedWithArrow) {
|
||||
setFocusedItem(0);
|
||||
setOpenedWithArrow?.(false);
|
||||
}
|
||||
}, [isMenuOpen, openedWithArrow, setOpenedWithArrow]);
|
||||
|
||||
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 menuItems = localRef?.current?.querySelectorAll(`[data-role="menuitem"]`);
|
||||
const menuItemsCount = menuItems?.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 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setFocusedItem(UNFOCUSED);
|
||||
close?.();
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setFocusedItem(0);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setFocusedItem(menuItemsCount - 1);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
(menuItems?.[focusedItem] as MenuItemElement)?.click();
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose?.();
|
||||
break;
|
||||
case 'Tab':
|
||||
onClose?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Forward event to parent
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (focusedItem === UNFOCUSED) {
|
||||
setFocusedItem(0);
|
||||
}
|
||||
};
|
||||
|
||||
return [handleKeys, handleFocus];
|
||||
};
|
||||
20
packages/grafana-ui/src/components/Menu/utils.test.ts
Normal file
20
packages/grafana-ui/src/components/Menu/utils.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { getPosition } from './utils';
|
||||
|
||||
describe('utils', () => {
|
||||
it('getPosition', () => {
|
||||
const getElement = (right: number, width: number) =>
|
||||
({
|
||||
parentElement: {
|
||||
getBoundingClientRect: () => ({ right }),
|
||||
},
|
||||
getBoundingClientRect: () => ({ width }),
|
||||
} as HTMLElement);
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1000 });
|
||||
|
||||
expect(getPosition(null)).toBe('left');
|
||||
expect(getPosition(getElement(900, 100))).toBe('right');
|
||||
expect(getPosition(getElement(800, 100))).toBe('left');
|
||||
expect(getPosition(getElement(1200, 0))).toBe('left');
|
||||
});
|
||||
});
|
||||
23
packages/grafana-ui/src/components/Menu/utils.ts
Normal file
23
packages/grafana-ui/src/components/Menu/utils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Returns where the subMenu should be positioned (left or right)
|
||||
*
|
||||
* @param element HTMLElement for the subMenu wrapper
|
||||
*/
|
||||
export const getPosition = (element: HTMLElement | null) => {
|
||||
if (!element) {
|
||||
return 'left';
|
||||
}
|
||||
|
||||
const wrapperPos = element.parentElement!.getBoundingClientRect();
|
||||
const pos = element.getBoundingClientRect();
|
||||
|
||||
if (pos.width === 0) {
|
||||
return 'left';
|
||||
}
|
||||
|
||||
if (wrapperPos.right + pos.width + 10 > window.innerWidth) {
|
||||
return 'right';
|
||||
} else {
|
||||
return 'left';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user