diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index ef23263ae4c..9911f364700 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.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: { diff --git a/packages/grafana-ui/src/components/Menu/Menu.story.internal.tsx b/packages/grafana-ui/src/components/Menu/Menu.story.internal.tsx index 9cfbfcf5130..896162f0e2f 100644 --- a/packages/grafana-ui/src/components/Menu/Menu.story.internal.tsx +++ b/packages/grafana-ui/src/components/Menu/Menu.story.internal.tsx @@ -50,6 +50,30 @@ export const Simple: Story = (args) => { + + + + , + , + , + , + , + ]} + />, + ]} + /> + + + ); }; diff --git a/packages/grafana-ui/src/components/Menu/Menu.tsx b/packages/grafana-ui/src/components/Menu/Menu.tsx index 8f93dda38dc..60773029cb8 100644 --- a/packages/grafana-ui/src/components/Menu/Menu.tsx +++ b/packages/grafana-ui/src/components/Menu/Menu.tsx @@ -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 { @@ -15,81 +15,15 @@ export interface MenuProps extends React.HTMLAttributes { 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( ({ header, children, ariaLabel, onOpen, onClose, onKeyDown, ...otherProps }, forwardedRef) => { const styles = useStyles2(getStyles); - const [focusedItem, setFocusedItem] = useState(UNFOCUSED); - const localRef = useRef(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 (
{ + const getMenuItem = (props?: Partial) => ( + + ); + + 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 = [ + , + , + ]; + + 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 = [ + , + , + ]; + + 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(); + }); +}); diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.tsx index 4dc72fae3bc..4a383214c2c 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.tsx @@ -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 { @@ -29,11 +34,14 @@ export interface MenuItemProps { active?: boolean; tabIndex?: number; + + /** List of menu items for the subMenu */ + childItems?: Array>; } /** @internal */ export const MenuItem = React.memo( - React.forwardRef((props, ref) => { + React.forwardRef((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(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 ( { 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 && } {label} + {icon && } + {label} + {hasSubMenu && ( + + )} ); }) @@ -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}; diff --git a/packages/grafana-ui/src/components/Menu/SubMenu.test.tsx b/packages/grafana-ui/src/components/Menu/SubMenu.test.tsx new file mode 100644 index 00000000000..b96064ffbfe --- /dev/null +++ b/packages/grafana-ui/src/components/Menu/SubMenu.test.tsx @@ -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 = [ + , + , + ]; + + render( + + ); + + 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); + }); +}); diff --git a/packages/grafana-ui/src/components/Menu/SubMenu.tsx b/packages/grafana-ui/src/components/Menu/SubMenu.tsx new file mode 100644 index 00000000000..cbdab1f25f6 --- /dev/null +++ b/packages/grafana-ui/src/components/Menu/SubMenu.tsx @@ -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>; + /** 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 = React.memo( + ({ items, isOpen, openedWithArrow, setOpenedWithArrow, close }) => { + const styles = useStyles2(getStyles); + const localRef = useRef(null); + const [handleKeys] = useMenuFocus({ + localRef, + isMenuOpen: isOpen, + openedWithArrow, + setOpenedWithArrow, + close, + }); + + return ( + <> +
+ +
+ {isOpen && ( +
+
+ {items} +
+
+ )} + + ); + } +); +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%; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/Menu/hooks.test.tsx b/packages/grafana-ui/src/components/Menu/hooks.test.tsx new file mode 100644 index 00000000000..6c2203075d6 --- /dev/null +++ b/packages/grafana-ui/src/components/Menu/hooks.test.tsx @@ -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, + handleKeys?: (event: KeyboardEvent) => void, + handleFocus?: () => void, + onClick?: () => void + ) => ( +
+ + Item 1 + + Item 2 + Item 3 +
+ ); + + it('sets correct focused item on keydown', () => { + const ref = createRef(); + 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(); + 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(); + 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(); + + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/packages/grafana-ui/src/components/Menu/hooks.ts b/packages/grafana-ui/src/components/Menu/hooks.ts new file mode 100644 index 00000000000..1ea4525bad0 --- /dev/null +++ b/packages/grafana-ui/src/components/Menu/hooks.ts @@ -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; + 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]; +}; diff --git a/packages/grafana-ui/src/components/Menu/utils.test.ts b/packages/grafana-ui/src/components/Menu/utils.test.ts new file mode 100644 index 00000000000..e71b99c9489 --- /dev/null +++ b/packages/grafana-ui/src/components/Menu/utils.test.ts @@ -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'); + }); +}); diff --git a/packages/grafana-ui/src/components/Menu/utils.ts b/packages/grafana-ui/src/components/Menu/utils.ts new file mode 100644 index 00000000000..4b76779266b --- /dev/null +++ b/packages/grafana-ui/src/components/Menu/utils.ts @@ -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'; + } +};