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 and flyout menu when rendered
- add unit tests
- close flyout menu with Escape key
  • Loading branch information
d-rita committed Jun 29, 2024
1 parent 2ccf6d0 commit de7d976
Show file tree
Hide file tree
Showing 5 changed files with 112 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, act } 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', async () => {
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)
const 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)

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

expect(submenuChild).not.toBeInTheDocument()

act(() => {
userEvent.keyboard('{ArrowRight}')
})
expect(getByText(/Item 2 a/i)).toBeInTheDocument()

act(() => {
userEvent.keyboard('{ArrowLeft}')
})
expect(queryByText(/Item 2 a/i)).not.toBeInTheDocument()
})
})
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
23 changes: 22 additions & 1 deletion components/menu/src/menu/use-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const useMenuNavigation = (children) => {
const menuItemsRef = useRef(null)
const [focusableItemsIndices, setFocusableItemsIndices] = useState(null)
const [activeItemIndex, setActiveItemIndex] = useState(-1)
const [openSubMenus, setOpenSubMenus] = useState([])

// Focus the first menu item when the menu receives focus
const handleFocus = useCallback(
Expand Down Expand Up @@ -41,6 +42,10 @@ export const useMenuNavigation = (children) => {
const handleNavigation = useCallback(
(event) => {
const totalFocusablePositions = focusableItemsIndices?.length
//menu item components
const anchorChild = event.target.children[0]
const hasSubMenu = anchorChild?.getAttribute('aria-haspopup')

switch (event.key) {
case 'ArrowUp':
event.preventDefault()
Expand All @@ -58,11 +63,24 @@ export const useMenuNavigation = (children) => {
setActiveItemIndex(activeItemIndex + 1)
}
break
// for MenuItem components
case 'ArrowRight':
event.preventDefault()
if (hasSubMenu) {
anchorChild.click()
}
break
case 'ArrowLeft':
case 'Escape':
event.preventDefault()
openSubMenus[openSubMenus.length - 1]?.focus()
openSubMenus[openSubMenus.length - 1]?.children[0].click()
break
default:
break
}
},
[activeItemIndex, focusableItemsIndices]
[activeItemIndex, focusableItemsIndices?.length, openSubMenus]
)

// Keydown: handleNavigation and handleAction
Expand All @@ -77,6 +95,7 @@ export const useMenuNavigation = (children) => {
)

// Initializes the indices for focusable items
// track open submenus
useEffect(() => {
if (!menuRef) {
return
Expand All @@ -88,6 +107,8 @@ export const useMenuNavigation = (children) => {
Array.from(menu.children)
)
setFocusableItemsIndices(itemsIndices)

setOpenSubMenus(document.querySelectorAll('[data-submenu-open=true]'))
}, [children])

// Focus the active menu child
Expand Down
8 changes: 7 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,6 +49,12 @@ const Popper = ({
modifiers: deduplicatedModifiers,
})

useEffect(() => {
if (popperElement) {
popperElement.children?.[0].focus() || popperElement.focus()
}
}, [popperElement])

return (
<div
className={className}
Expand Down

0 comments on commit de7d976

Please sign in to comment.