Skip to content

Commit c7bbd06

Browse files
authored
ActionMenu: Replace typeahead with mnemonics (#2105)
* Replace typeahead with mnemonics * add aria-keyshortcuts as part of useMnemonics * Create gold-falcons-shake.md * support user configured aria-keyshortcuts * delete unused useTypeaheadFocus
1 parent 40da598 commit c7bbd06

File tree

7 files changed

+159
-170
lines changed

7 files changed

+159
-170
lines changed

.changeset/gold-falcons-shake.md

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: Replace typeahead behavior with single key mnemonics

src/ActionMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ 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, useMenuInitialFocus, useMnemonics} from './hooks'
77
import {Divider} from './ActionList/Divider'
88
import {ActionListContainerContext} from './ActionList/ActionListContainerContext'
99
import {Button, ButtonProps} from './Button'
@@ -112,7 +112,7 @@ const Overlay: React.FC<MenuOverlayProps> = ({children, align = 'start', ...over
112112

113113
const containerRef = React.createRef<HTMLDivElement>()
114114
const {openWithFocus} = useMenuInitialFocus(open, onOpen, containerRef)
115-
useTypeaheadFocus(open, containerRef)
115+
useMnemonics(open, containerRef)
116116

117117
return (
118118
<AnchoredOverlay

src/__tests__/hooks/useMenuTypeaheadFocus.test.tsx renamed to src/__tests__/hooks/useMnemonics.test.tsx

Lines changed: 41 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import React from 'react'
22
import {render, fireEvent, cleanup} from '@testing-library/react'
3-
import {useTypeaheadFocus} from '../../hooks'
4-
import {TYPEAHEAD_TIMEOUT} from '../../hooks/useTypeaheadFocus'
3+
import {useMnemonics} from '../../hooks'
54

6-
const Component = ({
5+
const Fixture = ({
76
onSelect = () => null,
87
hasInput = false,
98
refNotAttached = false
@@ -13,7 +12,7 @@ const Component = ({
1312
refNotAttached?: boolean
1413
}) => {
1514
const containerRef = React.createRef<HTMLDivElement>()
16-
useTypeaheadFocus(true, containerRef) // hard coding open=true for test
15+
useMnemonics(true, containerRef) // hard coding open=true for test
1716

1817
return (
1918
<>
@@ -22,9 +21,12 @@ const Component = ({
2221
{hasInput && <input autoFocus type="text" placeholder="Filter options" />}
2322
<button onKeyDown={onSelect}>button 1</button>
2423
<button onKeyDown={onSelect}>Button 2</button>
25-
<button disabled>button 3 is disabled</button>
26-
<button onKeyDown={onSelect}>button 4</button>
2724
<button onKeyDown={onSelect}>third button</button>
25+
<button disabled>fourth button is disabled</button>
26+
<button onKeyDown={onSelect}>button 5</button>
27+
<button onKeyDown={onSelect} aria-keyshortcuts="6 E">
28+
button 6
29+
</button>
2830
<span>not focusable</span>
2931
</div>
3032
</>
@@ -35,31 +37,31 @@ describe('useTypeaheadFocus', () => {
3537
afterEach(cleanup)
3638

3739
it('First element: when b is pressed, it should move focus the "b"utton 1', () => {
38-
const {getByTestId, getByText} = render(<Component />)
40+
const {getByTestId, getByText} = render(<Fixture />)
3941
const container = getByTestId('container')
4042

4143
fireEvent.keyDown(container, {key: 'b', code: 'b'})
4244
expect(getByText('button 1')).toEqual(document.activeElement)
4345
})
4446

4547
it('Not first element: when t is pressed, it should move focus the "t"hird button', () => {
46-
const {getByTestId, getByText} = render(<Component />)
48+
const {getByTestId, getByText} = render(<Fixture />)
4749
const container = getByTestId('container')
4850

4951
fireEvent.keyDown(container, {key: 't', code: 't'})
5052
expect(getByText('third button')).toEqual(document.activeElement)
5153
})
5254

5355
it('Case insensitive: when B is pressed, it should move focus the "b"utton 1', () => {
54-
const {getByTestId, getByText} = render(<Component />)
56+
const {getByTestId, getByText} = render(<Fixture />)
5557
const container = getByTestId('container')
5658

5759
fireEvent.keyDown(container, {key: 'B', code: 'B'})
5860
expect(getByText('button 1')).toEqual(document.activeElement)
5961
})
6062

6163
it('Repeating letter: when b is pressed repeatedly, it should wrap through the buttons starting with "b", skipping the disabled button', () => {
62-
const {getByTestId, getByText} = render(<Component />)
64+
const {getByTestId, getByText} = render(<Fixture />)
6365
const container = getByTestId('container')
6466

6567
fireEvent.keyDown(container, {key: 'b', code: 'b'})
@@ -69,65 +71,61 @@ describe('useTypeaheadFocus', () => {
6971
expect(getByText('Button 2')).toEqual(document.activeElement)
7072

7173
fireEvent.keyDown(container, {key: 'b', code: 'b'})
72-
expect(getByText('button 4')).toEqual(document.activeElement)
74+
expect(getByText('button 5')).toEqual(document.activeElement)
7375

76+
// should cycle back to start of the list
7477
fireEvent.keyDown(container, {key: 'b', code: 'b'})
7578
expect(getByText('button 1')).toEqual(document.activeElement)
7679
})
7780

78-
it('Timeout: when presses b, waits and presses t, it should move focus the "t"hird button', async () => {
79-
jest.useFakeTimers()
80-
const {getByTestId, getByText} = render(<Component />)
81+
it('User defined aria-keyshortcuts: Should catch all shortcuts defined by user', () => {
82+
const {getByTestId, getByText} = render(<Fixture />)
8183
const container = getByTestId('container')
8284

83-
fireEvent.keyDown(container, {key: 'b', code: 'b'})
84-
expect(getByText('button 1')).toEqual(document.activeElement)
85-
86-
// if we press t now, the query would be "bt",
87-
// and focus will stay wherever it is
88-
fireEvent.keyDown(container, {key: 't', code: 't'})
89-
expect(getByText('button 1')).toEqual(document.activeElement)
85+
fireEvent.keyDown(container, {key: '6', code: '6'})
86+
expect(getByText('button 6')).toEqual(document.activeElement)
9087

91-
// but, if we simulate typeahead timeout, then type t
92-
// it should jump to the third button
93-
jest.advanceTimersByTime(TYPEAHEAD_TIMEOUT)
88+
// send focus elsewhere
9489
fireEvent.keyDown(container, {key: 't', code: 't'})
9590
expect(getByText('third button')).toEqual(document.activeElement)
91+
92+
fireEvent.keyDown(container, {key: 'e', code: 'e'})
93+
expect(getByText('button 6')).toEqual(document.activeElement)
9694
})
9795

98-
it('Space: when user is typing and presses Space, it should not select the option', () => {
99-
const mockFunction = jest.fn()
100-
const {getByTestId, getByText} = render(<Component onSelect={mockFunction} />)
96+
it('aria-keyshortcuts: it should add aria-keyshortcuts to focusable items', () => {
97+
const {getByText} = render(<Fixture />)
10198

102-
const container = getByTestId('container')
103-
fireEvent.keyDown(container, {key: 't', code: 't'})
99+
expect(getByText('button 1')).toHaveAttribute('aria-keyshortcuts', 'b')
100+
expect(getByText('Button 2')).toHaveAttribute('aria-keyshortcuts', 'b')
101+
expect(getByText('third button')).toHaveAttribute('aria-keyshortcuts', 't')
102+
expect(getByText('button 5')).toHaveAttribute('aria-keyshortcuts', 'b')
104103

105-
const thirdButton = getByText('third button')
106-
expect(thirdButton).toEqual(document.activeElement)
107-
fireEvent.keyDown(thirdButton, {key: ' ', code: 'Space'})
108-
expect(mockFunction).not.toHaveBeenCalled()
104+
// don't overwrite aria-keyshortcuts if it's already defined
105+
expect(getByText('button 6')).toHaveAttribute('aria-keyshortcuts', '6 E')
106+
107+
// not focusable items should not have aria-keyshortcuts
108+
expect(getByText('fourth button is disabled')).not.toHaveAttribute('aria-keyshortcuts')
109+
expect(getByText('not focusable')).not.toHaveAttribute('aria-keyshortcuts')
109110
})
110111

111-
it('Space after timeout: when user is presses Space after waiting, it should select the option', () => {
112-
jest.useFakeTimers()
112+
it('Space: when user presses Space, it should select the option', () => {
113113
const mockFunction = jest.fn()
114-
const {getByTestId, getByText} = render(<Component onSelect={mockFunction} />)
114+
const {getByTestId, getByText} = render(<Fixture onSelect={mockFunction} />)
115115

116116
const container = getByTestId('container')
117117
fireEvent.keyDown(container, {key: 't', code: 't'})
118118

119119
const thirdButton = getByText('third button')
120120
expect(thirdButton).toEqual(document.activeElement)
121-
122-
jest.advanceTimersByTime(TYPEAHEAD_TIMEOUT)
123121
fireEvent.keyDown(thirdButton, {key: ' ', code: 'Space'})
124122
expect(mockFunction).toHaveBeenCalled()
125123
})
126124

127-
it('Enter: when user is presses Enter, it should instantly select the option', () => {
125+
it('Enter: when user is presses Enter, it should select the option', () => {
128126
jest.useFakeTimers()
129127
const mockFunction = jest.fn()
130-
const {getByTestId, getByText} = render(<Component onSelect={mockFunction} />)
128+
const {getByTestId, getByText} = render(<Fixture onSelect={mockFunction} />)
131129

132130
const container = getByTestId('container')
133131
fireEvent.keyDown(container, {key: 't', code: 't'})
@@ -139,44 +137,16 @@ describe('useTypeaheadFocus', () => {
139137
expect(mockFunction).toHaveBeenCalled()
140138
})
141139

142-
it('Long string: when user starts typing a longer string "button 4", focus should jump to closest match', async () => {
143-
jest.useFakeTimers()
144-
const mockFunction = jest.fn()
145-
const {getByTestId, getByText} = render(<Component onSelect={mockFunction} />)
146-
const container = getByTestId('container')
147-
148-
fireEvent.keyDown(container, {key: 'b', code: 'b'})
149-
expect(getByText('button 1')).toEqual(document.activeElement)
150-
151-
fireEvent.keyDown(container, {key: 'u', code: 'u'})
152-
expect(getByText('button 1')).toEqual(document.activeElement)
153-
154-
fireEvent.keyDown(container, {key: 't', code: 't'})
155-
fireEvent.keyDown(container, {key: 't', code: 't'})
156-
fireEvent.keyDown(container, {key: 'o', code: 'o'})
157-
fireEvent.keyDown(container, {key: 'n', code: 'n'})
158-
159-
// pressing Space should be treated as part of query
160-
// and shouldn't select the option
161-
fireEvent.keyDown(container, {key: ' ', code: 'Space'})
162-
expect(mockFunction).not.toHaveBeenCalled()
163-
expect(getByText('button 1')).toEqual(document.activeElement)
164-
165-
fireEvent.keyDown(container, {key: '4', code: '4'})
166-
// the query is now "button 4" and should move focus
167-
expect(getByText('button 4')).toEqual(document.activeElement)
168-
})
169-
170140
it('Shortcuts: when a modifier is used, typeahead should not do anything', () => {
171-
const {getByTestId, getByText} = render(<Component />)
141+
const {getByTestId, getByText} = render(<Fixture />)
172142
const container = getByTestId('container')
173143

174144
fireEvent.keyDown(container, {metaKey: true, key: 'b', code: 'b'})
175145
expect(getByText('button 1')).not.toEqual(document.activeElement)
176146
})
177147

178148
it('TextInput: when an textinput has focus, typeahead should not do anything', async () => {
179-
const {getByTestId, getByPlaceholderText} = render(<Component hasInput={true} />)
149+
const {getByTestId, getByPlaceholderText} = render(<Fixture hasInput={true} />)
180150
const container = getByTestId('container')
181151

182152
const input = getByPlaceholderText('Filter options')
@@ -187,7 +157,7 @@ describe('useTypeaheadFocus', () => {
187157
})
188158

189159
it('Missing ref: when a ref is not attached, typeahead should break the component', async () => {
190-
const {getByTestId, getByText} = render(<Component refNotAttached={true} />)
160+
const {getByTestId, getByText} = render(<Fixture refNotAttached={true} />)
191161
const container = getByTestId('container')
192162

193163
fireEvent.keyDown(container, {key: 'b', code: 'b'})

src/hooks/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ export type {UseOverlaySettings} from './useOverlay'
1111
export {useRenderForcingRef} from './useRenderForcingRef'
1212
export {useProvidedStateOrCreate} from './useProvidedStateOrCreate'
1313
export {useMenuInitialFocus} from './useMenuInitialFocus'
14-
export {useTypeaheadFocus} from './useTypeaheadFocus'
14+
export {useMnemonics} from './useMnemonics'

src/hooks/useMnemonics.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import React from 'react'
2+
import {iterateFocusableElements} from '@primer/behaviors/utils'
3+
import {useProvidedRefOrCreate} from './useProvidedRefOrCreate'
4+
5+
/*
6+
* A mnemonic indicates to the user which key to press (single)
7+
* to activate a command or navigate to a component
8+
* typically appearing in a menu title, menu item, or the text of a button.
9+
*/
10+
11+
export const useMnemonics = (open: boolean, providedRef?: React.RefObject<HTMLElement>) => {
12+
const containerRef = useProvidedRefOrCreate(providedRef)
13+
14+
React.useEffect(
15+
function addAriaKeyshortcuts() {
16+
if (!open || !containerRef.current) return
17+
const container = containerRef.current
18+
19+
const focusableItems = [...iterateFocusableElements(container)]
20+
21+
focusableItems.map(item => {
22+
// if item already has aria-keyshortcuts defined by user, skip
23+
if (item.getAttribute('aria-keyshortcuts')) return
24+
25+
const firstLetter = item.textContent?.toLowerCase()[0]
26+
if (firstLetter) item.setAttribute('aria-keyshortcuts', firstLetter)
27+
})
28+
},
29+
[open, containerRef]
30+
)
31+
32+
React.useEffect(
33+
function handleKeyDown() {
34+
if (!open || !containerRef.current) return
35+
const container = containerRef.current
36+
37+
const handler = (event: KeyboardEvent) => {
38+
// skip if a TextInput has focus
39+
const activeElement = document.activeElement as HTMLElement
40+
if (activeElement.tagName === 'INPUT') return
41+
42+
// skip if used with modifier to preserve shortcuts like ⌘ + F
43+
const hasModifier = event.ctrlKey || event.altKey || event.metaKey
44+
if (hasModifier) return
45+
46+
// skip if it's not a alphabet key
47+
if (!isAlphabetKey(event)) return
48+
49+
// if this is a typeahead event, don't propagate outside of menu
50+
event.stopPropagation()
51+
52+
const query = event.key.toLowerCase()
53+
54+
let elementToFocus: HTMLElement | undefined
55+
56+
const focusableItems = [...iterateFocusableElements(container)]
57+
58+
const itemsMatchingKey = focusableItems.filter(item => {
59+
const keyshortcuts = item
60+
.getAttribute('aria-keyshortcuts')
61+
?.split(' ')
62+
.map(shortcut => shortcut.toLowerCase())
63+
return keyshortcuts && keyshortcuts.includes(query)
64+
})
65+
66+
const currentActiveIndex = itemsMatchingKey.indexOf(activeElement)
67+
68+
// If the last element is already selected, cycle through the list
69+
if (currentActiveIndex === itemsMatchingKey.length - 1) {
70+
elementToFocus = itemsMatchingKey[0]
71+
} else {
72+
elementToFocus = itemsMatchingKey.find((item, index) => {
73+
return index > currentActiveIndex
74+
})
75+
}
76+
elementToFocus?.focus()
77+
}
78+
79+
container.addEventListener('keydown', handler)
80+
return () => container.removeEventListener('keydown', handler)
81+
},
82+
[open, containerRef]
83+
)
84+
85+
const isAlphabetKey = (event: KeyboardEvent) => {
86+
return event.key.length === 1 && /[a-z\d]/i.test(event.key)
87+
}
88+
89+
return {containerRef}
90+
}

0 commit comments

Comments
 (0)