From f31fd08d5d436c1f53851c824c50db736b3ec64e Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Fri, 28 Jun 2024 20:10:54 +0300 Subject: [PATCH] feat: implement accessible flyout menu and submenus - focus popper's first child when rendered - focus flyout menu when rendered - close flyout menu with Escape key - add unit tests simulating opening and closing of submenus --- .../flyout-menu/__tests__/flyout-menu.test.js | 48 +++++++++++++++++++ .../menu/src/flyout-menu/flyout-menu.js | 36 +++++++++++++- components/menu/src/menu-item/menu-item.js | 1 + components/menu/src/menu/use-menu.js | 26 +++++++++- components/popper/src/popper.js | 9 +++- 5 files changed, 116 insertions(+), 4 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..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 ( -
+
{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 272f675c7a..4a0b2bf60e 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} > { const menuRef = useRef(null) const [focusableItemsIndices, setFocusableItemsIndices] = useState(null) const [activeItemIndex, setActiveItemIndex] = useState(-1) + const [openSubMenus, setOpenSubMenus] = useState([]) // Initializes the indices for focusable items + // track open submenus useEffect(() => { if (menuRef) { const menuItems = Array.from(menuRef.current.children) const itemsIndices = getFocusableItemsIndices(menuItems) setFocusableItemsIndices(itemsIndices) + + setOpenSubMenus( + document.querySelectorAll('[data-submenu-open=true]') + ) } }, [children]) @@ -31,6 +37,11 @@ export const useMenuNavigation = (children) => { (event) => { const totalFocusablePositions = focusableItemsIndices?.length const lastIndex = totalFocusablePositions - 1 + + //submenus + const firstChild = event.target.children[0] + const hasSubMenu = firstChild?.getAttribute('aria-haspopup') + switch (event.key) { case 'ArrowUp': event.preventDefault() @@ -51,11 +62,24 @@ export const useMenuNavigation = (children) => { event.target.children[0].click() } break + // 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 default: break } }, - [activeItemIndex, focusableItemsIndices?.length] + [activeItemIndex, focusableItemsIndices?.length, openSubMenus] ) // Event listeners for menu focus and key handling 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}