-
Notifications
You must be signed in to change notification settings - Fork 609
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
Changes from all commits
14f15ca
d471a74
8d13770
36d6738
06c236c
ac52a48
ca07811
1762561
5ce902b
818676e
88f30dd
b2585cc
5ee1c35
74b7a74
7e23db7
c951b48
0bd23cd
d5281c2
95df88d
9821bf0
0110352
7399216
7f75827
f0b7614
1be39c3
345d90a
21a157a
ff413d9
bfbd8da
e13b60a
ad927b7
342138d
d8c1c20
bc79239
48cfaaa
f99b197
872d84c
1ac46a4
7a65d7e
1b01aa9
530774e
ec0d943
26afdce
20013b2
546f7fc
4486717
b4a288b
c513785
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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' | ||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = { | ||
|
@@ -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 | ||
|
@@ -125,6 +126,7 @@ const Overlay: React.FC<MenuOverlayProps> = ({children, align = 'start', ...over | |
align={align} | ||
overlayProps={overlayProps} | ||
focusZoneSettings={{focusOutBehavior: 'wrap'}} | ||
focusTrapSettings={{disabled: true}} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
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' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. explanation: Added userEvent because fireEvent doesn't support (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' | ||
|
@@ -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'}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
}) | ||
|
@@ -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}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
||
|
@@ -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) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
*/ | ||
|
@@ -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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.movafter, 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()) | ||
|
@@ -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} | ||
} |
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 | ||
*/ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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-menu-before.movafter, pressing 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 = ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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