Skip to content

ActionMenu: Remove focus trap #1984

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
merged 48 commits into from
Mar 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
14f15ca
1. disable focus trap
siddharthkp Mar 18, 2022
d471a74
add failing tests
siddharthkp Mar 18, 2022
8d13770
add more failing tests
siddharthkp Mar 18, 2022
36d6738
handle click in useMenuInitialFocus
siddharthkp Mar 18, 2022
06c236c
Fix useMenuInitialFocus test
siddharthkp Mar 18, 2022
ac52a48
Add test for click with ActionMenu
siddharthkp Mar 18, 2022
ca07811
use updated menu focus
siddharthkp Mar 18, 2022
1762561
add the option of rendering inline instead of portal
siddharthkp Mar 18, 2022
5ce902b
handle tab press inside open menu
siddharthkp Mar 18, 2022
818676e
Repurpose openWithFocus for initialising keyboard interaction
siddharthkp Mar 18, 2022
88f30dd
Add anchorRef to useTypeaheadFocus
siddharthkp Mar 18, 2022
b2585cc
only activate keyboard activation if the menu is open
siddharthkp Mar 18, 2022
5ee1c35
add tab handler to Anchor as well
siddharthkp Mar 18, 2022
74b7a74
ActionMenu inside Overlay - overflow:visible, zIndex
siddharthkp Mar 21, 2022
7e23db7
merge sx prop on overlay props
siddharthkp Mar 21, 2022
c951b48
update snapshot
siddharthkp Mar 21, 2022
0bd23cd
make anchorRef optional for typeahead
siddharthkp Mar 21, 2022
d5281c2
use userEvent for tests
siddharthkp Mar 21, 2022
95df88d
Add story for Tab testing
siddharthkp Mar 21, 2022
9821bf0
improve types for handler
siddharthkp Mar 21, 2022
0110352
continue to improve types
siddharthkp Mar 21, 2022
7399216
clean up hooks
siddharthkp Mar 21, 2022
7f75827
move effects to one big hook
siddharthkp Mar 22, 2022
f0b7614
gesture type should not be in AnchoredOverlay
siddharthkp Mar 22, 2022
1be39c3
add changelog
siddharthkp Mar 22, 2022
345d90a
Add docs for usePortal
siddharthkp Mar 22, 2022
21a157a
add comments to overlayProps
siddharthkp Mar 22, 2022
ff413d9
remove todo
siddharthkp Mar 22, 2022
bfbd8da
Add overlay story with overflow
siddharthkp Mar 22, 2022
e13b60a
Merge branch 'main' into siddharth/actionmenu-without-focustrap
siddharthkp Mar 22, 2022
ad927b7
tiny bit more coverage
siddharthkp Mar 22, 2022
342138d
rename usePortal to renderInPortal
siddharthkp Mar 23, 2022
d8c1c20
Add story for position relative + overflow
siddharthkp Mar 23, 2022
bc79239
change z-index to 11, add comment why
siddharthkp Mar 25, 2022
48cfaaa
undo render inline
siddharthkp Mar 25, 2022
f99b197
remove story
siddharthkp Mar 25, 2022
872d84c
wrap async test in waitFor
siddharthkp Mar 25, 2022
1ac46a4
unused variable
siddharthkp Mar 25, 2022
7a65d7e
remove overlay overflow
siddharthkp Mar 25, 2022
1b01aa9
Delete overlay-use-portal.md
siddharthkp Mar 25, 2022
530774e
Delete overlay-overflow.md
siddharthkp Mar 25, 2022
ec0d943
remove duplicate sx row
siddharthkp Mar 25, 2022
26afdce
update snapshots
siddharthkp Mar 25, 2022
20013b2
remove irrelevant story
siddharthkp Mar 25, 2022
546f7fc
Merge branch 'main' into siddharth/actionmenu-without-focustrap
siddharthkp Mar 25, 2022
4486717
remove unused imports
siddharthkp Mar 25, 2022
b4a288b
Merge branch 'main' into siddharth/actionmenu-without-focustrap
siddharthkp Mar 28, 2022
c513785
Merge branch 'main' into siddharth/actionmenu-without-focustrap
siddharthkp Mar 29, 2022
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
14 changes: 8 additions & 6 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, useTypeaheadFocus} from './hooks'
import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigation} from './hooks'
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explanation: there's a lot happening with keyboard navigation and focus, so moved the effects to useMenuKeyboardNavigation which can call other 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
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new gesture: 'tab' for closing menu. See example

}
const MenuContext = React.createContext<MenuContextProps>({renderAnchor: null, open: false})

export type ActionMenuProps = {
Expand Down Expand Up @@ -111,8 +113,7 @@ const Overlay: React.FC<MenuOverlayProps> = ({children, align = 'start', ...over
>

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

return (
<AnchoredOverlay
Expand All @@ -125,6 +126,7 @@ const Overlay: React.FC<MenuOverlayProps> = ({children, align = 'start', ...over
align={align}
overlayProps={overlayProps}
focusZoneSettings={{focusOutBehavior: 'wrap'}}
focusTrapSettings={{disabled: true}}
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explanation: Menu should not have focus trap: Pressing Tab (or Shift + Tab) should close the menu.

(The keyboard navigation with arrow keys is handled by focusZone, not focusTrap)

>
<div ref={containerRef}>
<ActionListContainerContext.Provider
Expand Down
88 changes: 85 additions & 3 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 userEvent from '@testing-library/user-event'
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explanation: Added userEvent because fireEvent doesn't support Tab

(Tried moving all instances of fireEvent to userEvent, but userEvent does not cover all instances either, so we're stuck with both :/)

import 'babel-polyfill'
import {axe, toHaveNoViolations} from 'jest-axe'
import React from 'react'
Expand Down Expand Up @@ -58,9 +59,7 @@ describe('ActionMenu', () => {
const component = HTMLRender(<Example />)
const button = component.getByText('Toggle Menu')

// 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})
fireEvent.keyDown(button, {key: 'Enter'})
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup: Don't actually need to pass charCode with keyDown

expect(component.getByRole('menu')).toBeInTheDocument()
cleanup()
})
Expand All @@ -83,6 +82,9 @@ describe('ActionMenu', () => {

fireEvent.click(button)
const menuItems = await waitFor(() => component.getAllByRole('menuitem'))

// 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.keyPress(menuItems[0], {key: 'Enter', charCode: 13})
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup: Moved the above comment here instead because we do need to pass charCode with keyPress though

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

Expand Down Expand Up @@ -136,6 +138,86 @@ describe('ActionMenu', () => {
cleanup()
})

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

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

fireEvent.click(button)
expect(component.queryByRole('menu')).toBeInTheDocument()

/** We use waitFor because the hook uses an effect with setTimeout
* and we need to wait for that to happen in the next tick
*/
await waitFor(() => 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.getByText('Toggle Menu')
button.focus() // browsers do this automatically on click, but tests don't
fireEvent.click(button)
expect(component.queryByRole('menu')).toBeInTheDocument()

// button should be the active element
fireEvent.keyDown(document.activeElement!, {key: 'ArrowDown', code: 'ArrowDown'})

await waitFor(() => {
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.getByText('Toggle Menu')
button.focus() // browsers do this automatically on click, but tests don't
fireEvent.click(button)
expect(component.queryByRole('menu')).toBeInTheDocument()

// button should be the active element
fireEvent.keyDown(document.activeElement!, {key: 'ArrowUp', code: 'ArrowUp'})

await waitFor(() => {
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')

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

fireEvent.keyDown(anchor, {key: 'Enter'})
expect(component.queryByRole('menu')).toBeInTheDocument()

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

await waitFor(() => {
userEvent.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
22 changes: 20 additions & 2 deletions src/__tests__/hooks/useMenuInitialFocus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ const Component = () => {
const onOpen = () => setOpen(!open)

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

return (
<>
<button onClick={() => setOpen(true)} onKeyDown={event => openWithFocus('anchor-key-press', event)}>
<button
ref={anchorRef}
onClick={() => openWithFocus('anchor-click')}
onKeyDown={event => openWithFocus('anchor-key-press', event)}
>
open container
</button>
{open && (
Expand Down Expand Up @@ -83,4 +88,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 @@ -12,3 +12,4 @@ export {useRenderForcingRef} from './useRenderForcingRef'
export {useProvidedStateOrCreate} from './useProvidedStateOrCreate'
export {useMenuInitialFocus} from './useMenuInitialFocus'
export {useTypeaheadFocus} from './useTypeaheadFocus'
export {useMenuKeyboardNavigation} from './useMenuKeyboardNavigation'
23 changes: 17 additions & 6 deletions src/hooks/useMenuInitialFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,25 @@ import {useProvidedRefOrCreate} from './useProvidedRefOrCreate'
type Gesture = 'anchor-click' | 'anchor-key-press'
type Callback = (gesture: Gesture, event?: React.KeyboardEvent<HTMLElement>) => unknown

export const useMenuInitialFocus = (open: boolean, onOpen?: Callback, providedRef?: React.RefObject<HTMLElement>) => {
const containerRef = useProvidedRefOrCreate(providedRef)
export const useMenuInitialFocus = (
open: boolean,
onOpen?: Callback,
providedContainerRef?: React.RefObject<HTMLElement>,
providedAnchorRef?: React.RefObject<HTMLElement>
) => {
const containerRef = useProvidedRefOrCreate(providedContainerRef)
const anchorRef = useProvidedRefOrCreate(providedAnchorRef)
const [openingKey, setOpeningKey] = React.useState<string | undefined>(undefined)

const openWithFocus: Callback = (gesture, event) => {
if (gesture === 'anchor-click') setOpeningKey('mouse-click')
if (gesture === 'anchor-key-press' && event) setOpeningKey(event.code)
else setOpeningKey(undefined)
if (typeof onOpen === 'function') onOpen(gesture, event)
}

/**
* Pick the first element to focus based on the key used to open the Menu
* Click: anchor
* ArrowDown | Space | Enter: first element
* ArrowUp: last element
*/
Expand All @@ -25,7 +32,11 @@ export const useMenuInitialFocus = (open: boolean, onOpen?: Callback, providedRe
if (!openingKey || !containerRef.current) return

const iterable = iterateFocusableElements(containerRef.current)
if (['ArrowDown', 'Space', 'Enter'].includes(openingKey)) {

if (openingKey === 'mouse-click') {
if (anchorRef.current) anchorRef.current.focus()
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explanation: If the menu is opened with a click, we should not focus any items. The focus should remain on the anchor for natural focus management. (For example: If you press Tab, it would move to the next element after the Menu instead of entering the Menu)

before, focus is on the first element:

before-menu-click.mov

after, focus stays on the anchor:

menu-focus-click-after.mov

else throw new Error('For focus management, please attach anchorRef')
} else if (['ArrowDown', 'Space', 'Enter'].includes(openingKey)) {
const firstElement = iterable.next().value
/** We push imperative focus to the next tick to prevent React's batching */
setTimeout(() => firstElement?.focus())
Expand All @@ -34,7 +45,7 @@ export const useMenuInitialFocus = (open: boolean, onOpen?: Callback, providedRe
const lastElement = elements[elements.length - 1]
setTimeout(() => lastElement.focus())
}
}, [open, openingKey, containerRef])
}, [open, openingKey, containerRef, anchorRef])

return {containerRef, openWithFocus}
return {containerRef, anchorRef, openWithFocus}
}
88 changes: 88 additions & 0 deletions src/hooks/useMenuKeyboardNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React from 'react'
import {iterateFocusableElements} from '@primer/behaviors/utils'
import {useMenuInitialFocus} from './useMenuInitialFocus'
import {useTypeaheadFocus} from './useTypeaheadFocus'
import {MenuContextProps} from '../ActionMenu'

/**
* Keyboard navigation is a mix of 4 hooks
* 1. useMenuInitialFocus
* 2. useTypeaheadFocus
* 3. useCloseMenuOnTab
* 4. useMoveFocusToMenuItem
*/
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explanation: There is a lot happening in the menu, so I've tried to call the hooks from a single hook as the entry point so that it's easier to read and maintain.

There is a tiny overhead of attaching multiple event listeners. As long as we clean them up after the menu closes, the readability it provides feels like a good tradeoff

export const useMenuKeyboardNavigation = (
open: boolean,
onOpen: MenuContextProps['onOpen'],
onClose: MenuContextProps['onClose'],
containerRef: React.RefObject<HTMLElement>,
anchorRef: React.RefObject<HTMLElement>
) => {
const {openWithFocus} = useMenuInitialFocus(open, onOpen, containerRef, anchorRef)
useTypeaheadFocus(open, containerRef, anchorRef)
useCloseMenuOnTab(open, onClose, containerRef, anchorRef)
useMoveFocusToMenuItem(open, containerRef, anchorRef)

return {openWithFocus}
}

/**
* When Tab or Shift+Tab is pressed, the menu should close
* and the focus should naturally move to the next item
*/
const useCloseMenuOnTab = (
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With inline menu, natural focus management would make sure the right element is focused on Tab and Shift + Tab. We just need to close the Menu when it does.

before, pressing Tab when the menu did nothing:

tab-menu-before.mov

after, pressing Tab should close the Menu and let the focus go to the next item (or previous item for Shift+Tab)

menu-tab-after.mov

open: boolean,
onClose: MenuContextProps['onClose'],
containerRef: React.RefObject<HTMLElement>,
anchorRef: React.RefObject<HTMLElement>
) => {
React.useEffect(() => {
const container = containerRef.current
const anchor = anchorRef.current

const handler = (event: KeyboardEvent) => {
if (open && event.key === 'Tab') onClose?.('tab')
}

container?.addEventListener('keydown', handler)
anchor?.addEventListener('keydown', handler)
return () => {
container?.removeEventListener('keydown', handler)
anchor?.removeEventListener('keydown', handler)
}
}, [open, onClose, containerRef, anchorRef])
}

/**
* When Arrow Keys are pressed and the focus is on the anchor,
* focus should move to a menu item
*/
const useMoveFocusToMenuItem = (
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explanation: When menu is open with keyboard, the focus moves to the menu item based on the key pressed. However, when menu is opened with click, the focus stays on the anchor. Pressing ArrowDown or ArrowUp should move the focus to the right menu item

switch-focus.mov

open: boolean,
containerRef: React.RefObject<HTMLElement>,
anchorRef: React.RefObject<HTMLElement>
) => {
React.useEffect(() => {
const container = containerRef.current
const anchor = anchorRef.current

const handler = (event: KeyboardEvent) => {
if (!open || !container) return

const iterable = iterateFocusableElements(container)

if (event.key === 'ArrowDown') {
const firstElement = iterable.next().value
/** We push imperative focus to the next tick to prevent React's batching */
setTimeout(() => firstElement?.focus())
} else if (event.key === 'ArrowUp') {
const elements = [...iterable]
const lastElement = elements[elements.length - 1]
setTimeout(() => lastElement.focus())
}
}

anchor?.addEventListener('keydown', handler)
return () => anchor?.addEventListener('keydown', handler)
}, [open, containerRef, anchorRef])
}
24 changes: 18 additions & 6 deletions src/hooks/useTypeaheadFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ import {useProvidedRefOrCreate} from './useProvidedRefOrCreate'

export const TYPEAHEAD_TIMEOUT = 1000

export const useTypeaheadFocus = (open: boolean, providedRef?: React.RefObject<HTMLElement>) => {
const containerRef = useProvidedRefOrCreate(providedRef)
export const useTypeaheadFocus = (
open: boolean,
providedContainerRef: React.RefObject<HTMLElement>,
providedAnchorRef?: React.RefObject<HTMLElement>
Copy link
Member Author

@siddharthkp siddharthkp Mar 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment: Added anchorRef in addition to containerRef because typeahead should activate when the menu is opened with keyboard and mouse both.

typeahead-both-ways.mov

) => {
const containerRef = useProvidedRefOrCreate(providedContainerRef)
const anchorRef = useProvidedRefOrCreate(providedAnchorRef)

React.useEffect(() => {
if (!open || !containerRef.current) return
const container = containerRef.current
const anchor = anchorRef.current

// anchor is optional, but container isn't
if (!open || !container) return

let query = ''
let timeout: number | undefined
Expand Down Expand Up @@ -83,12 +91,16 @@ export const useTypeaheadFocus = (open: boolean, providedRef?: React.RefObject<H
}

container.addEventListener('keydown', handler)
return () => container.removeEventListener('keydown', handler)
}, [open, containerRef])
anchor?.addEventListener('keydown', handler)
return () => {
container.removeEventListener('keydown', handler)
anchor?.removeEventListener('keydown', handler)
}
}, [open, containerRef, anchorRef])

const isAlphabetKey = (event: KeyboardEvent) => {
return event.key.length === 1 && /[a-z\d]/i.test(event.key)
}

return {containerRef}
return {containerRef, anchorRef}
}
Loading