-
Notifications
You must be signed in to change notification settings - Fork 535
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
ActionMenu2 + DropdownMenu2: Initial focus is based on key used to op…
…en menu (#1810) * Add menu focus hook for ActionMenu * remove first alphabet logic for now * remove unnecessary story * useMenuFocus in dropdownMenu * add tests for hook! * add changeset * rename hook to useMenuInitialFocus * Don't need this ref anymore * dont need tabindex either
- Loading branch information
1 parent
5e4b60c
commit 35ad708
Showing
7 changed files
with
171 additions
and
30 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
--- | ||
'@primer/react': patch | ||
--- | ||
|
||
ActionMenu2 + DropdownMenu2: Focus the correct element when Menu is opened with keyboard. [See detailed spec.](https://github.com/github/primer/issues/518#issuecomment-999104848) | ||
|
||
- ArrowDown | Space | Enter: first element | ||
- ArrowUp: last element |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import React from 'react' | ||
import {render, fireEvent, cleanup, waitFor} from '@testing-library/react' | ||
import {useMenuInitialFocus} from '../../hooks' | ||
|
||
const Component = () => { | ||
const [open, setOpen] = React.useState(false) | ||
const onOpen = () => setOpen(!open) | ||
const {containerRef, openWithFocus} = useMenuInitialFocus(open, onOpen) | ||
|
||
return ( | ||
<> | ||
<button onClick={() => setOpen(true)} onKeyDown={event => openWithFocus('anchor-key-press', event)}> | ||
open container | ||
</button> | ||
{open && ( | ||
<div ref={containerRef}> | ||
<span>not focusable</span> | ||
<button>first focusable element</button> | ||
<button>second focusable element</button> | ||
<button>third focusable element</button> | ||
<span>not focusable</span> | ||
</div> | ||
)} | ||
</> | ||
) | ||
} | ||
|
||
describe('useMenuFocus', () => { | ||
afterEach(cleanup) | ||
|
||
it('should focus first element when opened with Enter', async () => { | ||
const {getByText} = render(<Component />) | ||
const button = getByText('open container') | ||
|
||
fireEvent.keyDown(button, {key: 'Enter', code: 'Enter', keyCode: 13, charCode: 13}) | ||
|
||
/** 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(() => { | ||
const firstButton = getByText('first focusable element') | ||
expect(firstButton).toEqual(document.activeElement) | ||
}) | ||
}) | ||
|
||
it('should focus first element when opened with ArrowDown', async () => { | ||
const {getByText} = render(<Component />) | ||
const button = getByText('open container') | ||
|
||
fireEvent.keyDown(button, {key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, charCode: 40}) | ||
|
||
await waitFor(() => { | ||
const firstButton = getByText('first focusable element') | ||
expect(firstButton).toEqual(document.activeElement) | ||
}) | ||
}) | ||
|
||
it('should focus last element when opened with ArrowUp', async () => { | ||
const {getByText} = render(<Component />) | ||
const button = getByText('open container') | ||
|
||
fireEvent.keyDown(button, {key: 'ArrowUp', code: 'ArrowUp', keyCode: 38, charCode: 38}) | ||
|
||
await waitFor(() => { | ||
const thirdButton = getByText('third focusable element') | ||
expect(thirdButton).toEqual(document.activeElement) | ||
}) | ||
}) | ||
|
||
it('should focus neither when a different letter is pressed', async () => { | ||
const {getByText} = render(<Component />) | ||
const button = getByText('open container') | ||
|
||
fireEvent.keyDown(button, {key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, charCode: 39}) | ||
|
||
await waitFor(() => { | ||
const firstButton = getByText('first focusable element') | ||
const thirdButton = getByText('third focusable element') | ||
expect(firstButton).not.toEqual(document.activeElement) | ||
expect(thirdButton).not.toEqual(document.activeElement) | ||
expect(document.body).toEqual(document.activeElement) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import React from 'react' | ||
import {iterateFocusableElements} from '@primer/behaviors/utils' | ||
|
||
type Gesture = 'anchor-click' | 'anchor-key-press' | ||
type Callback = (gesture: Gesture, event?: React.KeyboardEvent<HTMLElement>) => unknown | ||
|
||
export const useMenuInitialFocus = (open: boolean, onOpen?: Callback) => { | ||
const containerRef = React.createRef<HTMLDivElement>() | ||
const [openingKey, setOpeningKey] = React.useState<string | undefined>(undefined) | ||
|
||
const openWithFocus: Callback = (gesture, event) => { | ||
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 | ||
* ArrowDown | Space | Enter: first element | ||
* ArrowUp: last element | ||
*/ | ||
|
||
React.useEffect(() => { | ||
if (!open) return | ||
if (!openingKey || !containerRef.current) return | ||
|
||
const iterable = iterateFocusableElements(containerRef.current) | ||
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()) | ||
} else if (['ArrowUp'].includes(openingKey)) { | ||
const elements = [...iterable] | ||
const lastElement = elements[elements.length - 1] | ||
setTimeout(() => lastElement.focus()) | ||
} | ||
}, [open, openingKey, containerRef]) | ||
|
||
return {containerRef, openWithFocus} | ||
} |