mirror of https://github.com/grafana/grafana
Menu: Adds SubMenu component to support fly-out sub-menu. (#41647)
parent
e5811ad106
commit
3dd73387fa
@ -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(); |
||||
}); |
||||
}); |
@ -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…
Reference in new issue