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 (
-
+