Skip to content

Commit

Permalink
feat: implement accessible flyout menu and submenus
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
d-rita committed Jun 30, 2024
1 parent efb9c88 commit f31fd08
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 4 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()
})
})
36 changes: 34 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 @@ -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)
Expand Down
1 change: 1 addition & 0 deletions components/menu/src/menu-item/menu-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const MenuItem = ({
data-test={dataTest}
role="presentation"
tabIndex={tabIndex}
data-submenu-open={children && showSubMenu}
>
<a
target={target}
Expand Down
26 changes: 25 additions & 1 deletion components/menu/src/menu/use-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ export const useMenuNavigation = (children) => {
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])

Expand All @@ -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()
Expand All @@ -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
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 f31fd08

Please sign in to comment.