Skip to content

Commit e219598

Browse files
authored
ActionMenu: Remove focus trap (#1984)
* 1. disable focus trap * add failing tests * add more failing tests * handle click in useMenuInitialFocus * Fix useMenuInitialFocus test * Add test for click with ActionMenu * use updated menu focus * add the option of rendering inline instead of portal * handle tab press inside open menu * Repurpose openWithFocus for initialising keyboard interaction * Add anchorRef to useTypeaheadFocus * only activate keyboard activation if the menu is open * add tab handler to Anchor as well * ActionMenu inside Overlay - overflow:visible, zIndex * merge sx prop on overlay props * update snapshot * make anchorRef optional for typeahead * use userEvent for tests * Add story for Tab testing * improve types for handler * continue to improve types * clean up hooks * move effects to one big hook * gesture type should not be in AnchoredOverlay * add changelog * Add docs for usePortal * add comments to overlayProps * remove todo * Add overlay story with overflow * tiny bit more coverage * rename usePortal to renderInPortal * Add story for position relative + overflow * change z-index to 11, add comment why * undo render inline * remove story * wrap async test in waitFor * unused variable * remove overlay overflow * Delete overlay-use-portal.md * Delete overlay-overflow.md * remove duplicate sx row * update snapshots * remove irrelevant story * remove unused imports
1 parent 13680fe commit e219598

File tree

9 files changed

+266
-23
lines changed

9 files changed

+266
-23
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
ActionMenu: Remove focus trap to enable Tab and Shift+Tab behavior

src/ActionMenu.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,19 @@ import {useSSRSafeId} from '@react-aria/ssr'
33
import {TriangleDownIcon} from '@primer/octicons-react'
44
import {AnchoredOverlay, AnchoredOverlayProps} from './AnchoredOverlay'
55
import {OverlayProps} from './Overlay'
6-
import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuInitialFocus, useTypeaheadFocus} from './hooks'
6+
import {useProvidedRefOrCreate, useProvidedStateOrCreate, useMenuKeyboardNavigation} from './hooks'
77
import {Divider} from './ActionList/Divider'
88
import {ActionListContainerContext} from './ActionList/ActionListContainerContext'
99
import {Button, ButtonProps} from './Button'
1010
import {MandateProps} from './utils/types'
1111
import {SxProp, merge} from './sx'
1212

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

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

113115
const containerRef = React.createRef<HTMLDivElement>()
114-
const {openWithFocus} = useMenuInitialFocus(open, onOpen, containerRef)
115-
useTypeaheadFocus(open, containerRef)
116+
const {openWithFocus} = useMenuKeyboardNavigation(open, onOpen, onClose, containerRef, anchorRef)
116117

117118
return (
118119
<AnchoredOverlay
@@ -125,6 +126,7 @@ const Overlay: React.FC<MenuOverlayProps> = ({children, align = 'start', ...over
125126
align={align}
126127
overlayProps={overlayProps}
127128
focusZoneSettings={{focusOutBehavior: 'wrap'}}
129+
focusTrapSettings={{disabled: true}}
128130
>
129131
<div ref={containerRef}>
130132
<ActionListContainerContext.Provider

src/__tests__/ActionMenu.test.tsx

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {cleanup, render as HTMLRender, waitFor, fireEvent} from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
23
import 'babel-polyfill'
34
import {axe, toHaveNoViolations} from 'jest-axe'
45
import React from 'react'
@@ -58,9 +59,7 @@ describe('ActionMenu', () => {
5859
const component = HTMLRender(<Example />)
5960
const button = component.getByText('Toggle Menu')
6061

61-
// We pass keycode here to navigate a implementation detail in react-testing-library
62-
// https://github.com/testing-library/react-testing-library/issues/269#issuecomment-455854112
63-
fireEvent.keyDown(button, {key: 'Enter', charCode: 13})
62+
fireEvent.keyDown(button, {key: 'Enter'})
6463
expect(component.getByRole('menu')).toBeInTheDocument()
6564
cleanup()
6665
})
@@ -83,6 +82,9 @@ describe('ActionMenu', () => {
8382

8483
fireEvent.click(button)
8584
const menuItems = await waitFor(() => component.getAllByRole('menuitem'))
85+
86+
// We pass keycode here to navigate a implementation detail in react-testing-library
87+
// https://github.com/testing-library/react-testing-library/issues/269#issuecomment-455854112
8688
fireEvent.keyPress(menuItems[0], {key: 'Enter', charCode: 13})
8789
expect(component.queryByRole('menu')).toBeNull()
8890

@@ -136,6 +138,86 @@ describe('ActionMenu', () => {
136138
cleanup()
137139
})
138140

141+
it('should keep focus on Button when menu is opened with click', async () => {
142+
const component = HTMLRender(<Example />)
143+
const button = component.getByRole('button')
144+
145+
userEvent.tab() // tab into the story, this should focus on the first button
146+
expect(button).toEqual(document.activeElement) // trust, but verify
147+
148+
fireEvent.click(button)
149+
expect(component.queryByRole('menu')).toBeInTheDocument()
150+
151+
/** We use waitFor because the hook uses an effect with setTimeout
152+
* and we need to wait for that to happen in the next tick
153+
*/
154+
await waitFor(() => expect(document.activeElement).toEqual(button))
155+
156+
cleanup()
157+
})
158+
159+
it('should select first element when ArrowDown is pressed after opening Menu with click', async () => {
160+
const component = HTMLRender(<Example />)
161+
162+
const button = component.getByText('Toggle Menu')
163+
button.focus() // browsers do this automatically on click, but tests don't
164+
fireEvent.click(button)
165+
expect(component.queryByRole('menu')).toBeInTheDocument()
166+
167+
// button should be the active element
168+
fireEvent.keyDown(document.activeElement!, {key: 'ArrowDown', code: 'ArrowDown'})
169+
170+
await waitFor(() => {
171+
expect(component.getAllByRole('menuitem')[0]).toEqual(document.activeElement)
172+
})
173+
174+
cleanup()
175+
})
176+
177+
it('should select last element when ArrowUp is pressed after opening Menu with click', async () => {
178+
const component = HTMLRender(<Example />)
179+
180+
const button = component.getByText('Toggle Menu')
181+
button.focus() // browsers do this automatically on click, but tests don't
182+
fireEvent.click(button)
183+
expect(component.queryByRole('menu')).toBeInTheDocument()
184+
185+
// button should be the active element
186+
fireEvent.keyDown(document.activeElement!, {key: 'ArrowUp', code: 'ArrowUp'})
187+
188+
await waitFor(() => {
189+
expect(component.getAllByRole('menuitem').pop()).toEqual(document.activeElement)
190+
})
191+
192+
cleanup()
193+
})
194+
195+
it('should close the menu if Tab is pressed and move to next element', async () => {
196+
const component = HTMLRender(
197+
<>
198+
<Example />
199+
<input type="text" placeholder="next focusable element" />
200+
</>
201+
)
202+
const anchor = component.getByRole('button')
203+
204+
userEvent.tab() // tab into the story, this should focus on the first button
205+
expect(anchor).toEqual(document.activeElement) // trust, but verify
206+
207+
fireEvent.keyDown(anchor, {key: 'Enter'})
208+
expect(component.queryByRole('menu')).toBeInTheDocument()
209+
210+
expect(component.getAllByRole('menuitem')[0]).toEqual(document.activeElement)
211+
212+
await waitFor(() => {
213+
userEvent.tab()
214+
expect(document.activeElement).toEqual(component.getByPlaceholderText('next focusable element'))
215+
expect(component.queryByRole('menu')).not.toBeInTheDocument()
216+
})
217+
218+
cleanup()
219+
})
220+
139221
it('should have no axe violations', async () => {
140222
const {container} = HTMLRender(<Example />)
141223
const results = await axe(container)

src/__tests__/hooks/useMenuInitialFocus.test.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ const Component = () => {
77
const onOpen = () => setOpen(!open)
88

99
const containerRef = React.createRef<HTMLDivElement>()
10-
const {openWithFocus} = useMenuInitialFocus(open, onOpen, containerRef)
10+
const anchorRef = React.createRef<HTMLButtonElement>()
11+
const {openWithFocus} = useMenuInitialFocus(open, onOpen, containerRef, anchorRef)
1112

1213
return (
1314
<>
14-
<button onClick={() => setOpen(true)} onKeyDown={event => openWithFocus('anchor-key-press', event)}>
15+
<button
16+
ref={anchorRef}
17+
onClick={() => openWithFocus('anchor-click')}
18+
onKeyDown={event => openWithFocus('anchor-key-press', event)}
19+
>
1520
open container
1621
</button>
1722
{open && (
@@ -83,4 +88,17 @@ describe('useMenuInitialFocus', () => {
8388
expect(document.body).toEqual(document.activeElement)
8489
})
8590
})
91+
92+
it('should keep focus on trigger when opened with click', async () => {
93+
const {getByText} = render(<Component />)
94+
95+
const button = getByText('open container')
96+
button.focus() // browsers do this automatically on click, but tests don't
97+
expect(button).toEqual(document.activeElement)
98+
fireEvent.click(button)
99+
100+
await waitFor(() => {
101+
expect(button).toEqual(document.activeElement)
102+
})
103+
})
86104
})

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export {useRenderForcingRef} from './useRenderForcingRef'
1212
export {useProvidedStateOrCreate} from './useProvidedStateOrCreate'
1313
export {useMenuInitialFocus} from './useMenuInitialFocus'
1414
export {useTypeaheadFocus} from './useTypeaheadFocus'
15+
export {useMenuKeyboardNavigation} from './useMenuKeyboardNavigation'

src/hooks/useMenuInitialFocus.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,25 @@ import {useProvidedRefOrCreate} from './useProvidedRefOrCreate'
55
type Gesture = 'anchor-click' | 'anchor-key-press'
66
type Callback = (gesture: Gesture, event?: React.KeyboardEvent<HTMLElement>) => unknown
77

8-
export const useMenuInitialFocus = (open: boolean, onOpen?: Callback, providedRef?: React.RefObject<HTMLElement>) => {
9-
const containerRef = useProvidedRefOrCreate(providedRef)
8+
export const useMenuInitialFocus = (
9+
open: boolean,
10+
onOpen?: Callback,
11+
providedContainerRef?: React.RefObject<HTMLElement>,
12+
providedAnchorRef?: React.RefObject<HTMLElement>
13+
) => {
14+
const containerRef = useProvidedRefOrCreate(providedContainerRef)
15+
const anchorRef = useProvidedRefOrCreate(providedAnchorRef)
1016
const [openingKey, setOpeningKey] = React.useState<string | undefined>(undefined)
1117

1218
const openWithFocus: Callback = (gesture, event) => {
19+
if (gesture === 'anchor-click') setOpeningKey('mouse-click')
1320
if (gesture === 'anchor-key-press' && event) setOpeningKey(event.code)
14-
else setOpeningKey(undefined)
1521
if (typeof onOpen === 'function') onOpen(gesture, event)
1622
}
1723

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

2734
const iterable = iterateFocusableElements(containerRef.current)
28-
if (['ArrowDown', 'Space', 'Enter'].includes(openingKey)) {
35+
36+
if (openingKey === 'mouse-click') {
37+
if (anchorRef.current) anchorRef.current.focus()
38+
else throw new Error('For focus management, please attach anchorRef')
39+
} else if (['ArrowDown', 'Space', 'Enter'].includes(openingKey)) {
2940
const firstElement = iterable.next().value
3041
/** We push imperative focus to the next tick to prevent React's batching */
3142
setTimeout(() => firstElement?.focus())
@@ -34,7 +45,7 @@ export const useMenuInitialFocus = (open: boolean, onOpen?: Callback, providedRe
3445
const lastElement = elements[elements.length - 1]
3546
setTimeout(() => lastElement.focus())
3647
}
37-
}, [open, openingKey, containerRef])
48+
}, [open, openingKey, containerRef, anchorRef])
3849

39-
return {containerRef, openWithFocus}
50+
return {containerRef, anchorRef, openWithFocus}
4051
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import React from 'react'
2+
import {iterateFocusableElements} from '@primer/behaviors/utils'
3+
import {useMenuInitialFocus} from './useMenuInitialFocus'
4+
import {useTypeaheadFocus} from './useTypeaheadFocus'
5+
import {MenuContextProps} from '../ActionMenu'
6+
7+
/**
8+
* Keyboard navigation is a mix of 4 hooks
9+
* 1. useMenuInitialFocus
10+
* 2. useTypeaheadFocus
11+
* 3. useCloseMenuOnTab
12+
* 4. useMoveFocusToMenuItem
13+
*/
14+
export const useMenuKeyboardNavigation = (
15+
open: boolean,
16+
onOpen: MenuContextProps['onOpen'],
17+
onClose: MenuContextProps['onClose'],
18+
containerRef: React.RefObject<HTMLElement>,
19+
anchorRef: React.RefObject<HTMLElement>
20+
) => {
21+
const {openWithFocus} = useMenuInitialFocus(open, onOpen, containerRef, anchorRef)
22+
useTypeaheadFocus(open, containerRef, anchorRef)
23+
useCloseMenuOnTab(open, onClose, containerRef, anchorRef)
24+
useMoveFocusToMenuItem(open, containerRef, anchorRef)
25+
26+
return {openWithFocus}
27+
}
28+
29+
/**
30+
* When Tab or Shift+Tab is pressed, the menu should close
31+
* and the focus should naturally move to the next item
32+
*/
33+
const useCloseMenuOnTab = (
34+
open: boolean,
35+
onClose: MenuContextProps['onClose'],
36+
containerRef: React.RefObject<HTMLElement>,
37+
anchorRef: React.RefObject<HTMLElement>
38+
) => {
39+
React.useEffect(() => {
40+
const container = containerRef.current
41+
const anchor = anchorRef.current
42+
43+
const handler = (event: KeyboardEvent) => {
44+
if (open && event.key === 'Tab') onClose?.('tab')
45+
}
46+
47+
container?.addEventListener('keydown', handler)
48+
anchor?.addEventListener('keydown', handler)
49+
return () => {
50+
container?.removeEventListener('keydown', handler)
51+
anchor?.removeEventListener('keydown', handler)
52+
}
53+
}, [open, onClose, containerRef, anchorRef])
54+
}
55+
56+
/**
57+
* When Arrow Keys are pressed and the focus is on the anchor,
58+
* focus should move to a menu item
59+
*/
60+
const useMoveFocusToMenuItem = (
61+
open: boolean,
62+
containerRef: React.RefObject<HTMLElement>,
63+
anchorRef: React.RefObject<HTMLElement>
64+
) => {
65+
React.useEffect(() => {
66+
const container = containerRef.current
67+
const anchor = anchorRef.current
68+
69+
const handler = (event: KeyboardEvent) => {
70+
if (!open || !container) return
71+
72+
const iterable = iterateFocusableElements(container)
73+
74+
if (event.key === 'ArrowDown') {
75+
const firstElement = iterable.next().value
76+
/** We push imperative focus to the next tick to prevent React's batching */
77+
setTimeout(() => firstElement?.focus())
78+
} else if (event.key === 'ArrowUp') {
79+
const elements = [...iterable]
80+
const lastElement = elements[elements.length - 1]
81+
setTimeout(() => lastElement.focus())
82+
}
83+
}
84+
85+
anchor?.addEventListener('keydown', handler)
86+
return () => anchor?.addEventListener('keydown', handler)
87+
}, [open, containerRef, anchorRef])
88+
}

src/hooks/useTypeaheadFocus.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@ import {useProvidedRefOrCreate} from './useProvidedRefOrCreate'
44

55
export const TYPEAHEAD_TIMEOUT = 1000
66

7-
export const useTypeaheadFocus = (open: boolean, providedRef?: React.RefObject<HTMLElement>) => {
8-
const containerRef = useProvidedRefOrCreate(providedRef)
7+
export const useTypeaheadFocus = (
8+
open: boolean,
9+
providedContainerRef: React.RefObject<HTMLElement>,
10+
providedAnchorRef?: React.RefObject<HTMLElement>
11+
) => {
12+
const containerRef = useProvidedRefOrCreate(providedContainerRef)
13+
const anchorRef = useProvidedRefOrCreate(providedAnchorRef)
914

1015
React.useEffect(() => {
11-
if (!open || !containerRef.current) return
1216
const container = containerRef.current
17+
const anchor = anchorRef.current
18+
19+
// anchor is optional, but container isn't
20+
if (!open || !container) return
1321

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

8593
container.addEventListener('keydown', handler)
86-
return () => container.removeEventListener('keydown', handler)
87-
}, [open, containerRef])
94+
anchor?.addEventListener('keydown', handler)
95+
return () => {
96+
container.removeEventListener('keydown', handler)
97+
anchor?.removeEventListener('keydown', handler)
98+
}
99+
}, [open, containerRef, anchorRef])
88100

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

93-
return {containerRef}
105+
return {containerRef, anchorRef}
94106
}

0 commit comments

Comments
 (0)