Menu: Adds SubMenu component to support fly-out sub-menu. (#41647)

pull/39290/head^2
Tiago Mota Santos 4 years ago committed by GitHub
parent e5811ad106
commit 3dd73387fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      packages/grafana-e2e-selectors/src/selectors/components.ts
  2. 24
      packages/grafana-ui/src/components/Menu/Menu.story.internal.tsx
  3. 72
      packages/grafana-ui/src/components/Menu/Menu.tsx
  4. 66
      packages/grafana-ui/src/components/Menu/MenuItem.test.tsx
  5. 77
      packages/grafana-ui/src/components/Menu/MenuItem.tsx
  6. 25
      packages/grafana-ui/src/components/Menu/SubMenu.test.tsx
  7. 86
      packages/grafana-ui/src/components/Menu/SubMenu.tsx
  8. 189
      packages/grafana-ui/src/components/Menu/hooks.test.tsx
  9. 118
      packages/grafana-ui/src/components/Menu/hooks.ts
  10. 20
      packages/grafana-ui/src/components/Menu/utils.test.ts
  11. 23
      packages/grafana-ui/src/components/Menu/utils.ts

@ -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

@ -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};

@ -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';
}
};
Loading…
Cancel
Save