Menu: Adds SubMenu component to support fly-out sub-menu. (#41647)
This commit is contained in:
@@ -50,6 +50,10 @@ export const Components = {
|
|||||||
MenuComponent: (title: string) => `${title} menu`,
|
MenuComponent: (title: string) => `${title} menu`,
|
||||||
MenuGroup: (title: string) => `${title} menu group`,
|
MenuGroup: (title: string) => `${title} menu group`,
|
||||||
MenuItem: (title: string) => `${title} menu item`,
|
MenuItem: (title: string) => `${title} menu item`,
|
||||||
|
SubMenu: {
|
||||||
|
container: 'SubMenu container',
|
||||||
|
icon: 'SubMenu icon',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Panels: {
|
Panels: {
|
||||||
Panel: {
|
Panel: {
|
||||||
|
|||||||
@@ -50,6 +50,30 @@ export const Simple: Story<MenuProps> = (args) => {
|
|||||||
</MenuGroup>
|
</MenuGroup>
|
||||||
</Menu>
|
</Menu>
|
||||||
</StoryExample>
|
</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>
|
</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 { css } from '@emotion/css';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { useStyles2 } from '../../themes';
|
import { useStyles2 } from '../../themes';
|
||||||
import { useEffectOnce } from 'react-use';
|
import { useMenuFocus } from './hooks';
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
|
export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
@@ -15,81 +15,15 @@ export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
onKeyDown?: React.KeyboardEventHandler;
|
onKeyDown?: React.KeyboardEventHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modulo = (a: number, n: number) => ((a % n) + n) % n;
|
|
||||||
const UNFOCUSED = -1;
|
|
||||||
type MenuItemElement = HTMLAnchorElement & HTMLButtonElement;
|
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(
|
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(
|
||||||
({ header, children, ariaLabel, onOpen, onClose, onKeyDown, ...otherProps }, forwardedRef) => {
|
({ header, children, ariaLabel, onOpen, onClose, onKeyDown, ...otherProps }, forwardedRef) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const [focusedItem, setFocusedItem] = useState(UNFOCUSED);
|
|
||||||
|
|
||||||
const localRef = useRef<HTMLDivElement>(null);
|
const localRef = useRef<HTMLDivElement>(null);
|
||||||
useImperativeHandle(forwardedRef, () => localRef.current!);
|
useImperativeHandle(forwardedRef, () => localRef.current!);
|
||||||
|
|
||||||
useEffect(() => {
|
const [handleKeys, handleFocus] = useMenuFocus({ localRef, onOpen, onClose, onKeyDown });
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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 { css, cx } from '@emotion/css';
|
||||||
import { GrafanaTheme2, LinkTarget } from '@grafana/data';
|
import { GrafanaTheme2, LinkTarget } from '@grafana/data';
|
||||||
import { useStyles2 } from '../../themes';
|
import { useStyles2 } from '../../themes';
|
||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
import { IconName } from '../../types';
|
import { IconName } from '../../types';
|
||||||
|
import { SubMenu } from './SubMenu';
|
||||||
|
import { getFocusStyles } from '../../themes/mixins';
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export type MenuItemElement = HTMLAnchorElement & HTMLButtonElement & HTMLDivElement;
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export interface MenuItemProps<T = any> {
|
export interface MenuItemProps<T = any> {
|
||||||
@@ -29,11 +34,14 @@ export interface MenuItemProps<T = any> {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
|
|
||||||
|
/** List of menu items for the subMenu */
|
||||||
|
childItems?: Array<ReactElement<MenuItemProps>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export const MenuItem = React.memo(
|
export const MenuItem = React.memo(
|
||||||
React.forwardRef<HTMLAnchorElement & HTMLButtonElement, MenuItemProps>((props, ref) => {
|
React.forwardRef<MenuItemElement, MenuItemProps>((props, ref) => {
|
||||||
const {
|
const {
|
||||||
url,
|
url,
|
||||||
icon,
|
icon,
|
||||||
@@ -44,19 +52,57 @@ export const MenuItem = React.memo(
|
|||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
active,
|
active,
|
||||||
|
childItems,
|
||||||
role = 'menuitem',
|
role = 'menuitem',
|
||||||
tabIndex = -1,
|
tabIndex = -1,
|
||||||
} = props;
|
} = props;
|
||||||
const styles = useStyles2(getStyles);
|
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(
|
const itemStyle = cx(
|
||||||
{
|
{
|
||||||
[styles.item]: true,
|
[styles.item]: true,
|
||||||
[styles.activeItem]: active,
|
[styles.activeItem]: isActive,
|
||||||
},
|
},
|
||||||
className
|
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 (
|
return (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
target={target}
|
target={target}
|
||||||
@@ -68,19 +114,33 @@ export const MenuItem = React.memo(
|
|||||||
? (event) => {
|
? (event) => {
|
||||||
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && onClick) {
|
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && onClick) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
onClick(event);
|
onClick(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
onKeyDown={handleKeys}
|
||||||
role={url === undefined ? role : undefined}
|
role={url === undefined ? role : undefined}
|
||||||
data-role="menuitem" // used to identify menuitem in Menu.tsx
|
data-role="menuitem" // used to identify menuitem in Menu.tsx
|
||||||
ref={ref}
|
ref={localRef}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-checked={ariaChecked}
|
aria-checked={ariaChecked}
|
||||||
tabIndex={tabIndex}
|
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>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -100,6 +160,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
border: none;
|
border: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus,
|
&:focus,
|
||||||
@@ -108,6 +169,10 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
color: ${theme.colors.text.primary};
|
color: ${theme.colors.text.primary};
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
${getFocusStyles(theme)}
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
activeItem: css`
|
activeItem: css`
|
||||||
background: ${theme.colors.action.selected};
|
background: ${theme.colors.action.selected};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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%;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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