Skip to content

ActionMenu: Remove focus trap + Fix initial focus #2024

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/actionmenu-remove-focus-trap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

ActionMenu: Remove focus trap to enable Tab and Shift+Tab behavior and fix initial focus on click
16 changes: 9 additions & 7 deletions src/ActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ import {useSSRSafeId} from '@react-aria/ssr'
import {TriangleDownIcon} from '@primer/octicons-react'
import {AnchoredOverlay, AnchoredOverlayProps} from './AnchoredOverlay'
import {OverlayProps} from './Overlay'
import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuInitialFocus, useMnemonics} from './hooks'
import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigation} from './hooks'
import {Divider} from './ActionList/Divider'
import {ActionListContainerContext} from './ActionList/ActionListContainerContext'
import {Button, ButtonProps} from './Button'
import {MandateProps} from './utils/types'
import {SxProp, merge} from './sx'

type MenuContextProps = Pick<
export type MenuContextProps = Pick<
AnchoredOverlayProps,
'anchorRef' | 'renderAnchor' | 'open' | 'onOpen' | 'onClose' | 'anchorId'
>
'anchorRef' | 'renderAnchor' | 'open' | 'onOpen' | 'anchorId'
> & {
onClose?: (gesture: 'anchor-click' | 'click-outside' | 'escape' | 'tab') => void
}
const MenuContext = React.createContext<MenuContextProps>({renderAnchor: null, open: false})

export type ActionMenuProps = {
Expand Down Expand Up @@ -111,20 +113,20 @@ const Overlay: React.FC<React.PropsWithChildren<MenuOverlayProps>> = ({children,
>

const containerRef = React.createRef<HTMLDivElement>()
const {openWithFocus} = useMenuInitialFocus(open, onOpen, containerRef)
useMnemonics(open, containerRef)
useMenuKeyboardNavigation(open, onClose, containerRef, anchorRef)

return (
<AnchoredOverlay
anchorRef={anchorRef}
renderAnchor={renderAnchor}
anchorId={anchorId}
open={open}
onOpen={openWithFocus}
onOpen={onOpen}
onClose={onClose}
align={align}
overlayProps={overlayProps}
focusZoneSettings={{focusOutBehavior: 'wrap'}}
focusTrapSettings={{disabled: true}}
>
<div ref={containerRef}>
<ActionListContainerContext.Provider
Expand Down
143 changes: 118 additions & 25 deletions src/__tests__/ActionMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {cleanup, render as HTMLRender, waitFor, fireEvent} from '@testing-library/react'
import {cleanup, render as HTMLRender, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import 'babel-polyfill'
import {axe, toHaveNoViolations} from 'jest-axe'
import React from 'react'
Expand Down Expand Up @@ -48,54 +49,66 @@ describe('ActionMenu', () => {

it('should open Menu on MenuButton click', async () => {
const component = HTMLRender(<Example />)
const button = component.getByText('Toggle Menu')
fireEvent.click(button)
const button = component.getByRole('button')

const user = userEvent.setup()
await user.click(button)

expect(component.getByRole('menu')).toBeInTheDocument()
cleanup()
})

it('should open Menu on MenuButton keypress', async () => {
const component = HTMLRender(<Example />)
const button = component.getByText('Toggle Menu')
const button = component.getByRole('button')

const user = userEvent.setup()
button.focus()
await user.keyboard('{Enter}')

// We pass keycode here to navigate a implementation detail in react-testing-library
// https://github.com/testing-library/react-testing-library/issues/269#issuecomment-455854112
fireEvent.keyDown(button, {key: 'Enter', charCode: 13})
expect(component.getByRole('menu')).toBeInTheDocument()
cleanup()
})

it('should close Menu on selecting an action with click', async () => {
const component = HTMLRender(<Example />)
const button = component.getByText('Toggle Menu')
const button = component.getByRole('button')

fireEvent.click(button)
const menuItems = await waitFor(() => component.getAllByRole('menuitem'))
fireEvent.click(menuItems[0])
expect(component.queryByRole('menu')).toBeNull()
const user = userEvent.setup()
await user.click(button)

const menuItems = component.getAllByRole('menuitem')
await user.click(menuItems[0])

expect(component.queryByRole('menu')).toBeNull()
cleanup()
})

it('should close Menu on selecting an action with Enter', async () => {
const component = HTMLRender(<Example />)
const button = component.getByText('Toggle Menu')
const button = component.getByRole('button')

fireEvent.click(button)
const menuItems = await waitFor(() => component.getAllByRole('menuitem'))
fireEvent.keyPress(menuItems[0], {key: 'Enter', charCode: 13})
expect(component.queryByRole('menu')).toBeNull()
const user = userEvent.setup()
await user.click(button)

const menuItems = component.getAllByRole('menuitem')
menuItems[0].focus()
await user.keyboard('{Enter}')

expect(component.queryByRole('menu')).toBeNull()
cleanup()
})

it('should not close Menu if event is prevented', async () => {
const component = HTMLRender(<Example />)
const button = component.getByText('Toggle Menu')
const button = component.getByRole('button')

const user = userEvent.setup()
await user.click(button)

const menuItems = component.getAllByRole('menuitem')
await user.click(menuItems[3])

fireEvent.click(button)
const menuItems = await waitFor(() => component.getAllByRole('menuitem'))
fireEvent.click(menuItems[3])
// menu should still be open
expect(component.getByRole('menu')).toBeInTheDocument()

Expand All @@ -109,14 +122,16 @@ describe('ActionMenu', () => {
</ThemeProvider>
)
const button = component.getByLabelText('Field type')
fireEvent.click(button)

const user = userEvent.setup()
await user.click(button)

// select first item by role, that would close the menu
fireEvent.click(component.getAllByRole('menuitemradio')[0])
await user.click(component.getAllByRole('menuitemradio')[0])
expect(component.queryByRole('menu')).not.toBeInTheDocument()

// open menu again and check if the first option is checked
fireEvent.click(button)
await user.click(button)
expect(component.getAllByRole('menuitemradio')[0]).toHaveAttribute('aria-checked', 'true')
cleanup()
})
Expand All @@ -128,14 +143,92 @@ describe('ActionMenu', () => {
</ThemeProvider>
)
const button = component.getByLabelText('Group by')
fireEvent.click(button)

const user = userEvent.setup()
await user.click(button)

expect(component.getByLabelText('Status')).toHaveAttribute('role', 'menuitemradio')
expect(component.getByLabelText('Clear Group by')).toHaveAttribute('role', 'menuitem')

cleanup()
})

it('should keep focus on Button when menu is opened with click', async () => {
const component = HTMLRender(<Example />)
const button = component.getByRole('button')

const user = userEvent.setup()
await user.tab() // tab into the story, this should focus on the first button
expect(button).toEqual(document.activeElement) // trust, but verify

await user.click(button)
expect(component.queryByRole('menu')).toBeInTheDocument()
expect(document.activeElement).toEqual(button)

cleanup()
})

it('should select first element when ArrowDown is pressed after opening Menu with click', async () => {
const component = HTMLRender(<Example />)
const button = component.getByRole('button')

const user = userEvent.setup()
await user.click(button)

expect(component.queryByRole('menu')).toBeInTheDocument()

// assumes button is the active element at this point
await user.keyboard('{ArrowDown}')

expect(component.getAllByRole('menuitem')[0]).toEqual(document.activeElement)
cleanup()
})

it('should select last element when ArrowUp is pressed after opening Menu with click', async () => {
const component = HTMLRender(<Example />)

const button = component.getByRole('button')

const user = userEvent.setup()
await user.click(button)

expect(component.queryByRole('menu')).toBeInTheDocument()

// button should be the active element
// assumes button is the active element at this point
await user.keyboard('{ArrowUp}')

expect(component.getAllByRole('menuitem').pop()).toEqual(document.activeElement)
cleanup()
})

it('should close the menu if Tab is pressed and move to next element', async () => {
const component = HTMLRender(
<>
<Example />
<input type="text" placeholder="next focusable element" />
</>
)
const anchor = component.getByRole('button')

const user = userEvent.setup()
await user.tab() // tab into the story, this should focus on the first button
expect(anchor).toEqual(document.activeElement) // trust, but verify

await user.keyboard('{Enter}')
expect(component.queryByRole('menu')).toBeInTheDocument()
expect(component.getAllByRole('menuitem')[0]).toEqual(document.activeElement)

// TODO: revisit why we need this waitFor
await waitFor(async () => {
await user.tab()
expect(document.activeElement).toEqual(component.getByPlaceholderText('next focusable element'))
expect(component.queryByRole('menu')).not.toBeInTheDocument()
})

cleanup()
})

it('should have no axe violations', async () => {
const {container} = HTMLRender(<Example />)
const results = await axe(container)
Expand Down
18 changes: 16 additions & 2 deletions src/__tests__/hooks/useMenuInitialFocus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ const Component = () => {
const onOpen = () => setOpen(!open)

const containerRef = React.createRef<HTMLDivElement>()
const {openWithFocus} = useMenuInitialFocus(open, onOpen, containerRef)
const anchorRef = React.createRef<HTMLButtonElement>()
useMenuInitialFocus(open, containerRef, anchorRef)

return (
<>
<button onClick={() => setOpen(true)} onKeyDown={event => openWithFocus('anchor-key-press', event)}>
<button ref={anchorRef} onClick={() => onOpen()} onKeyDown={() => onOpen()}>
open container
</button>
{open && (
Expand Down Expand Up @@ -83,4 +84,17 @@ describe('useMenuInitialFocus', () => {
expect(document.body).toEqual(document.activeElement)
})
})

it('should keep focus on trigger when opened with click', async () => {
const {getByText} = render(<Component />)

const button = getByText('open container')
button.focus() // browsers do this automatically on click, but tests don't
expect(button).toEqual(document.activeElement)
fireEvent.click(button)

await waitFor(() => {
expect(button).toEqual(document.activeElement)
})
})
})
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export type {UseOverlaySettings} from './useOverlay'
export {useRenderForcingRef} from './useRenderForcingRef'
export {useProvidedStateOrCreate} from './useProvidedStateOrCreate'
export {useMenuInitialFocus} from './useMenuInitialFocus'
export {useMenuKeyboardNavigation} from './useMenuKeyboardNavigation'
export {useMnemonics} from './useMnemonics'
Loading