diff --git a/components/menu/src/flyout-menu/__tests__/flyout-menu.test.js b/components/menu/src/flyout-menu/__tests__/flyout-menu.test.js new file mode 100644 index 0000000000..002b51c1a5 --- /dev/null +++ b/components/menu/src/flyout-menu/__tests__/flyout-menu.test.js @@ -0,0 +1,48 @@ +import { render, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { MenuItem } from '../../menu-item/menu-item.js' +import { FlyoutMenu } from '../flyout-menu.js' + +describe('Flyout Menu Component', () => { + it('can handle navigation of submenus', async () => { + const { getByText, queryByText, getAllByRole } = render( + <FlyoutMenu> + <MenuItem label="Item 1" /> + <MenuItem label="Item 2"> + <MenuItem label="Item 2 a" /> + </MenuItem> + </FlyoutMenu> + ) + + const itemOne = getByText(/Item 1/i) + const itemTwo = getByText(/Item 2/i) + const submenuChild = queryByText(/Item 2 a/i) + + const menuItems = getAllByRole('menuitem') + + expect(menuItems.length).toBe(2) + expect(menuItems[0]).toBe(itemOne.parentNode) + expect(menuItems[1]).toBe(itemTwo.parentNode) + + userEvent.tab() + expect(menuItems[0].parentNode).toHaveFocus() + expect(menuItems[1].parentNode).not.toHaveFocus() + + userEvent.keyboard('{ArrowDown}') + expect(menuItems[0].parentNode).not.toHaveFocus() + expect(menuItems[1].parentNode).toHaveFocus() + + expect(submenuChild).not.toBeInTheDocument() + + act(() => { + userEvent.keyboard('{ArrowRight}') + }) + expect(getByText(/Item 2 a/i)).toBeInTheDocument() + + act(() => { + userEvent.keyboard('{ArrowLeft}') + }) + expect(queryByText(/Item 2 a/i)).not.toBeInTheDocument() + }) +}) diff --git a/components/menu/src/flyout-menu/flyout-menu.js b/components/menu/src/flyout-menu/flyout-menu.js index c3b4035635..aa8aec0e90 100644 --- a/components/menu/src/flyout-menu/flyout-menu.js +++ b/components/menu/src/flyout-menu/flyout-menu.js @@ -1,6 +1,13 @@ import { colors, elevations, spacers } from '@dhis2/ui-constants' import PropTypes from 'prop-types' -import React, { Children, cloneElement, isValidElement, useState } from 'react' +import React, { + Children, + cloneElement, + isValidElement, + useEffect, + useRef, + useState, +} from 'react' import { Menu } from '../index.js' const FlyoutMenu = ({ @@ -17,8 +24,33 @@ const FlyoutMenu = ({ setOpenedSubMenu(toggleValue) } + const divRef = useRef(null) + + const handleFocus = (event) => { + if (event.target === divRef.current) { + divRef.current.children[0].focus() + } + } + + useEffect(() => { + if (!divRef) { + return + } + const div = divRef.current + div.addEventListener('focus', handleFocus) + + return () => { + div.removeEventListener('focus', handleFocus) + } + }) + return ( - <div className={className} data-test={dataTest}> + <div + className={className} + data-test={dataTest} + tabIndex={0} + ref={divRef} + > <Menu dense={dense}> {Children.map(children, (child, index) => isValidElement(child) diff --git a/components/menu/src/menu-item/menu-item.js b/components/menu/src/menu-item/menu-item.js index edacfac93d..9ce2918528 100644 --- a/components/menu/src/menu-item/menu-item.js +++ b/components/menu/src/menu-item/menu-item.js @@ -60,6 +60,7 @@ const MenuItem = ({ data-test={dataTest} role="presentation" tabIndex={tabIndex} + data-submenu-open={children && showSubMenu} > <a target={target} diff --git a/components/menu/src/menu/use-menu.js b/components/menu/src/menu/use-menu.js index 07adee4f70..1afeaf0560 100644 --- a/components/menu/src/menu/use-menu.js +++ b/components/menu/src/menu/use-menu.js @@ -6,6 +6,7 @@ export const useMenuNavigation = (children) => { const menuItemsRef = useRef(null) const [focusableItemsIndices, setFocusableItemsIndices] = useState(null) const [activeItemIndex, setActiveItemIndex] = useState(-1) + const [openSubMenus, setOpenSubMenus] = useState([]) // Focus the first menu item when the menu receives focus const handleFocus = useCallback( @@ -41,6 +42,10 @@ export const useMenuNavigation = (children) => { const handleNavigation = useCallback( (event) => { const totalFocusablePositions = focusableItemsIndices?.length + //menu item components + const anchorChild = event.target.children[0] + const hasSubMenu = anchorChild?.getAttribute('aria-haspopup') + switch (event.key) { case 'ArrowUp': event.preventDefault() @@ -58,11 +63,24 @@ export const useMenuNavigation = (children) => { setActiveItemIndex(activeItemIndex + 1) } break + // for MenuItem components + case 'ArrowRight': + event.preventDefault() + if (hasSubMenu) { + anchorChild.click() + } + break + case 'ArrowLeft': + case 'Escape': + event.preventDefault() + openSubMenus[openSubMenus.length - 1]?.focus() + openSubMenus[openSubMenus.length - 1]?.children[0].click() + break default: break } }, - [activeItemIndex, focusableItemsIndices] + [activeItemIndex, focusableItemsIndices?.length, openSubMenus] ) // Keydown: handleNavigation and handleAction @@ -77,6 +95,7 @@ export const useMenuNavigation = (children) => { ) // Initializes the indices for focusable items + // track open submenus useEffect(() => { if (!menuRef) { return @@ -88,6 +107,8 @@ export const useMenuNavigation = (children) => { Array.from(menu.children) ) setFocusableItemsIndices(itemsIndices) + + setOpenSubMenus(document.querySelectorAll('[data-submenu-open=true]')) }, [children]) // Focus the active menu child diff --git a/components/popper/src/popper.js b/components/popper/src/popper.js index 453e3a067f..1ba1ee86a2 100644 --- a/components/popper/src/popper.js +++ b/components/popper/src/popper.js @@ -1,6 +1,6 @@ import { sharedPropTypes } from '@dhis2/ui-constants' import PropTypes from 'prop-types' -import React, { useState, useMemo } from 'react' +import React, { useState, useMemo, useEffect } from 'react' import { usePopper } from 'react-popper' import { getReferenceElement } from './get-reference-element.js' import { deduplicateModifiers } from './modifiers.js' @@ -49,6 +49,12 @@ const Popper = ({ modifiers: deduplicatedModifiers, }) + useEffect(() => { + if (popperElement) { + popperElement.children?.[0].focus() || popperElement.focus() + } + }, [popperElement]) + return ( <div className={className}