Skip to content

Commit

Permalink
feat: implement accessible flyout menu and handle submenus (#1495)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
d-rita authored Jul 29, 2024
1 parent 457e04f commit 73d1f7e
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 6 deletions.
48 changes: 48 additions & 0 deletions components/menu/src/flyout-menu/__tests__/flyout-menu.test.js
Original file line number Diff line number Diff line change
@@ -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(
<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)
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()
})
})
51 changes: 49 additions & 2 deletions components/menu/src/flyout-menu/flyout-menu.js
Original file line number Diff line number Diff line change
@@ -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 = ({
Expand All @@ -10,15 +17,53 @@ const FlyoutMenu = ({
dense,
maxHeight,
maxWidth,
closeMenu,
}) => {
const [openedSubMenu, setOpenedSubMenu] = useState(null)
const toggleSubMenu = (index) => {
const toggleValue = index === openedSubMenu ? null : index
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 (
<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)
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion components/menu/src/flyout-menu/flyout-menu.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export const DropDownMenu = (args) => {
{open && (
<Layer onBackdropClick={toggle}>
<Popper reference={ref} placement="bottom-start">
<FlyoutMenu {...args}>
<FlyoutMenu {...args} closeMenu={toggle}>
<MenuItem label="Item 1" />
<MenuItem label="Item 2" />
</FlyoutMenu>
Expand Down
43 changes: 42 additions & 1 deletion components/menu/src/menu-item/menu-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 (
<>
Expand All @@ -60,6 +100,7 @@ const MenuItem = ({
data-test={dataTest}
role="presentation"
tabIndex={tabIndex}
data-submenu-open={children && showSubMenu}
>
<a
target={target}
Expand Down
3 changes: 2 additions & 1 deletion components/menu/src/menu/use-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const useMenuNavigation = (children) => {
const totalFocusablePositions = focusableItemsIndices?.length
if (totalFocusablePositions) {
const lastIndex = totalFocusablePositions - 1

switch (event.key) {
case 'ArrowUp':
event.preventDefault()
Expand Down Expand Up @@ -62,7 +63,7 @@ export const useMenuNavigation = (children) => {
}
}
},
[activeItemIndex, focusableItemsIndices]
[activeItemIndex, focusableItemsIndices?.length]
)

// Event listeners for menu focus and key handling
Expand Down
4 changes: 4 additions & 0 deletions components/menu/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion components/popper/src/popper.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -49,13 +49,20 @@ const Popper = ({
modifiers: deduplicatedModifiers,
})

useEffect(() => {
if (popperElement) {
popperElement?.firstElementChild?.focus()
}
}, [popperElement])

return (
<div
className={className}
data-test={dataTest}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
tabIndex={0}
>
{children}
</div>
Expand Down

0 comments on commit 73d1f7e

Please sign in to comment.