From 73d1f7e062ad393a156b3b439b590c5927f0fc9d Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi <31903212+d-rita@users.noreply.github.com> Date: Mon, 29 Jul 2024 14:20:23 +0300 Subject: [PATCH] feat: implement accessible flyout menu and handle submenus (#1495) - focus the popper's first child element when it is rendered - move focus to flyout menu's first child, i.e. the menu when it receives focus - the Right arrow key opens the submenu, Left arrow key closes submenu and focuses its parent item - close flyout menu with the Escape key by passing it a closeMenu function as a prop - add unit tests simulating opening and closing of submenus --- .../flyout-menu/__tests__/flyout-menu.test.js | 48 +++++++++++++++++ .../menu/src/flyout-menu/flyout-menu.js | 51 ++++++++++++++++++- .../src/flyout-menu/flyout-menu.stories.js | 2 +- components/menu/src/menu-item/menu-item.js | 43 +++++++++++++++- components/menu/src/menu/use-menu.js | 3 +- components/menu/types/index.d.ts | 4 ++ components/popper/src/popper.js | 9 +++- 7 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 components/menu/src/flyout-menu/__tests__/flyout-menu.test.js 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..9ec5324015 --- /dev/null +++ b/components/menu/src/flyout-menu/__tests__/flyout-menu.test.js @@ -0,0 +1,48 @@ +import { render } 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', () => { + const { getByText, queryByText, getAllByRole } = render( + + + + + + + ) + + const itemOne = getByText(/Item 1/i) + const itemTwo = getByText(/Item 2/i) + let 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) + + expect(submenuChild).not.toBeInTheDocument() + + 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() + + userEvent.keyboard('{ArrowRight}') + submenuChild = getByText(/Item 2 a/i) + + expect(submenuChild).toBeInTheDocument() + expect(submenuChild.parentElement.parentElement).toHaveFocus() + + userEvent.keyboard('{ArrowLeft}') + expect(queryByText(/Item 2 a/i)).not.toBeInTheDocument() + expect(menuItems[1].parentNode).toHaveFocus() + }) +}) diff --git a/components/menu/src/flyout-menu/flyout-menu.js b/components/menu/src/flyout-menu/flyout-menu.js index c3b4035635..cae0ae6617 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 = ({ @@ -10,6 +17,7 @@ const FlyoutMenu = ({ dense, maxHeight, maxWidth, + closeMenu, }) => { const [openedSubMenu, setOpenedSubMenu] = useState(null) const toggleSubMenu = (index) => { @@ -17,8 +25,45 @@ const FlyoutMenu = ({ setOpenedSubMenu(toggleValue) } + const divRef = useRef(null) + + useEffect(() => { + if (!divRef.current) { + return + } + const div = divRef.current + + const handleFocus = (event) => { + if (event.target === div) { + if (div?.children && div.children.length > 0) { + div.children[0].focus() + } + } + } + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault() + closeMenu && closeMenu() + } + } + + div.addEventListener('focus', handleFocus) + div.addEventListener('keydown', handleKeyDown) + + return () => { + div.removeEventListener('focus', handleFocus) + div.removeEventListener('keydown', handleKeyDown) + } + }, [closeMenu]) + return ( -
+
{Children.map(children, (child, index) => isValidElement(child) @@ -58,6 +103,8 @@ FlyoutMenu.propTypes = { /** Typically, but not limited to, `MenuItem` components */ children: PropTypes.node, className: PropTypes.string, + /** when Escape key is pressed, this function is called to close the flyout menu */ + closeMenu: PropTypes.func, dataTest: PropTypes.string, /** Menu uses smaller dimensions */ dense: PropTypes.bool, diff --git a/components/menu/src/flyout-menu/flyout-menu.stories.js b/components/menu/src/flyout-menu/flyout-menu.stories.js index d07d9d45ed..960b678caf 100644 --- a/components/menu/src/flyout-menu/flyout-menu.stories.js +++ b/components/menu/src/flyout-menu/flyout-menu.stories.js @@ -170,7 +170,7 @@ export const DropDownMenu = (args) => { {open && ( - + diff --git a/components/menu/src/menu-item/menu-item.js b/components/menu/src/menu-item/menu-item.js index 272f675c7a..2972ed5923 100644 --- a/components/menu/src/menu-item/menu-item.js +++ b/components/menu/src/menu-item/menu-item.js @@ -3,7 +3,7 @@ import { Portal } from '@dhis2-ui/portal' import { IconChevronRight24 } from '@dhis2/ui-icons' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useRef } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { FlyoutMenu } from '../index.js' import styles from './menu-item.styles.js' @@ -45,6 +45,46 @@ const MenuItem = ({ tabIndex, }) => { const menuItemRef = useRef() + const [openSubMenus, setOpenSubMenus] = useState([]) + + useEffect(() => { + // track open submenus + setOpenSubMenus(document.querySelectorAll('[data-submenu-open=true]')) + }, []) + + useEffect(() => { + if (!menuItemRef.current) { + return + } + + const menuItem = menuItemRef.current + + const handleKeyDown = (event) => { + const firstChild = event.target.children[0] + const hasSubMenu = firstChild?.getAttribute('aria-haspopup') + switch (event.key) { + // for submenus + case 'ArrowRight': + event.preventDefault() + if (hasSubMenu) { + firstChild.click() + } + break + case 'ArrowLeft': + case 'Escape': // close flyout menu + event.preventDefault() + openSubMenus[openSubMenus.length - 1]?.focus() + openSubMenus[openSubMenus.length - 1]?.children[0].click() + break + } + } + + menuItem.addEventListener('keydown', handleKeyDown) + + return () => { + menuItem.removeEventListener('keydown', handleKeyDown) + } + }, [openSubMenus]) return ( <> @@ -60,6 +100,7 @@ const MenuItem = ({ data-test={dataTest} role="presentation" tabIndex={tabIndex} + data-submenu-open={children && showSubMenu} > { const totalFocusablePositions = focusableItemsIndices?.length if (totalFocusablePositions) { const lastIndex = totalFocusablePositions - 1 + switch (event.key) { case 'ArrowUp': event.preventDefault() @@ -62,7 +63,7 @@ export const useMenuNavigation = (children) => { } } }, - [activeItemIndex, focusableItemsIndices] + [activeItemIndex, focusableItemsIndices?.length] ) // Event listeners for menu focus and key handling diff --git a/components/menu/types/index.d.ts b/components/menu/types/index.d.ts index bd99c82440..bcbafdc9d7 100644 --- a/components/menu/types/index.d.ts +++ b/components/menu/types/index.d.ts @@ -6,6 +6,10 @@ export interface FlyoutMenuProps { */ children?: React.ReactNode className?: string + /** + * On Escape key press, this function is called + */ + closeMenu?: () => void dataTest?: string /** * Menu uses smaller dimensions diff --git a/components/popper/src/popper.js b/components/popper/src/popper.js index 453e3a067f..ab88f12df1 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?.firstElementChild?.focus() + } + }, [popperElement]) + return (
{children}