Skip to content

Commit 11a76b3

Browse files
authored
Escape on nested Overlays (#1861)
* Add repro for draft issue overlay * types amirite * add tests for repro * clean code and all that * add test for Overlay event leaking out * Use registry pattern like useClickOutside * clean up tests * remove lint warning * remove incorrect comments * consistent comment style
1 parent 2091202 commit 11a76b3

File tree

4 files changed

+440
-19
lines changed

4 files changed

+440
-19
lines changed

src/__tests__/Overlay.test.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import {Overlay, Box, Text, ButtonDanger, Button} from '..'
33
import {render, cleanup, waitFor, fireEvent, act} from '@testing-library/react'
44
import userEvent from '@testing-library/user-event'
55
import {axe, toHaveNoViolations} from 'jest-axe'
6+
import '@testing-library/jest-dom'
67
import theme from '../theme'
78
import BaseStyles from '../BaseStyles'
89
import {ThemeProvider} from '../ThemeProvider'
10+
import {NestedOverlays, MemexNestedOverlays, MemexIssueOverlay} from '../stories/Overlay.stories'
911

1012
expect.extend(toHaveNoViolations)
1113

@@ -100,4 +102,115 @@ describe('Overlay', () => {
100102
const cancelButtons = queryAllByText('Cancel')
101103
expect(cancelButtons).toHaveLength(0)
102104
})
105+
106+
it('should close the top most overlay on escape', () => {
107+
const container = render(
108+
<ThemeProvider>
109+
<NestedOverlays />
110+
</ThemeProvider>
111+
)
112+
113+
// open first menu
114+
userEvent.click(container.getByLabelText('Add this repository to a list'))
115+
expect(container.getByText('Add to list')).toBeInTheDocument()
116+
117+
// open second menu
118+
userEvent.click(container.getByText('Create list'))
119+
expect(container.getByPlaceholderText('Name this list')).toBeInTheDocument()
120+
121+
// hitting escape on input should close the second menu but not the first
122+
fireEvent.keyDown(container.getByPlaceholderText('Name this list'), {key: 'Escape', code: 'Escape'})
123+
expect(container.queryByPlaceholderText('Name this list')).not.toBeInTheDocument()
124+
// this breaks:
125+
expect(container.getByText('Add to list')).toBeInTheDocument()
126+
127+
// hitting escape again in first overlay should close it
128+
fireEvent.keyDown(container.getByText('Add to list'), {key: 'Escape', code: 'Escape'})
129+
expect(container.queryByText('Add to list')).not.toBeInTheDocument()
130+
})
131+
132+
it('memex repro: should only close the dropdown when escape is pressed', () => {
133+
const container = render(
134+
<ThemeProvider>
135+
<MemexNestedOverlays />
136+
</ThemeProvider>
137+
)
138+
139+
// open first menu
140+
userEvent.click(container.getByLabelText('Add custom iteration'))
141+
expect(container.getByLabelText('Change duration unit')).toBeInTheDocument()
142+
143+
// open dropdown menu
144+
userEvent.click(container.getByLabelText('Change duration unit'))
145+
expect(container.getByRole('menu')).toBeInTheDocument()
146+
147+
// hitting escape on menu item should close the dropdown menu but not the overlay
148+
fireEvent.keyDown(container.getByRole('menu'), {key: 'Escape', code: 'Escape'})
149+
expect(container.queryByRole('menu')).not.toBeInTheDocument()
150+
// this breaks:
151+
expect(container.getByLabelText('Change duration unit')).toBeInTheDocument()
152+
})
153+
154+
it('memex repro: should not close overlay when input has event.preventDefault', () => {
155+
const container = render(
156+
<ThemeProvider>
157+
<MemexIssueOverlay />
158+
</ThemeProvider>
159+
)
160+
161+
// clicking the title opens overlay
162+
userEvent.click(container.getByText('Implement draft issue editor'))
163+
expect(container.getByLabelText('Change issue title')).toBeInTheDocument()
164+
165+
// clicking the button changes to input
166+
userEvent.click(container.getByLabelText('Change issue title'))
167+
expect(container.getByDisplayValue('Implement draft issue editor')).toBeInTheDocument()
168+
169+
// pressing Escape inside input brings back the button but does not close the overlay
170+
fireEvent.keyDown(container.getByDisplayValue('Implement draft issue editor'), {key: 'Escape', code: 'Escape'})
171+
expect(container.getByLabelText('Change issue title')).toBeInTheDocument()
172+
173+
// hitting Escape again should close the Overlay
174+
fireEvent.keyDown(container.getByLabelText('Change issue title'), {key: 'Escape', code: 'Escape'})
175+
expect(container.queryByLabelText('Change issue title')).not.toBeInTheDocument()
176+
})
177+
178+
// https://github.com/primer/react/issues/1802
179+
// eslint-disable-next-line jest/no-disabled-tests
180+
it.skip('memex repro: should not leak overlay events to the document', () => {
181+
const mockHandler = jest.fn()
182+
const BugRepro1802 = () => {
183+
const [isOpen, setIsOpen] = useState(false)
184+
const closeOverlay = () => setIsOpen(false)
185+
const buttonRef = useRef<HTMLButtonElement>(null)
186+
187+
React.useEffect(() => {
188+
document.addEventListener('keydown', mockHandler)
189+
return () => document.removeEventListener('keydown', mockHandler)
190+
}, [])
191+
192+
return (
193+
<ThemeProvider theme={theme}>
194+
<BaseStyles>
195+
<Button ref={buttonRef} onClick={() => setIsOpen(true)}>
196+
open overlay
197+
</Button>
198+
{isOpen ? (
199+
<Overlay returnFocusRef={buttonRef} onEscape={closeOverlay} onClickOutside={closeOverlay}>
200+
<Text>Text inside Overlay</Text>
201+
</Overlay>
202+
) : null}
203+
</BaseStyles>
204+
</ThemeProvider>
205+
)
206+
}
207+
208+
const container = render(<BugRepro1802 />)
209+
210+
userEvent.click(container.getByText('open overlay'))
211+
fireEvent.keyDown(container.getByText('Text inside Overlay'), {key: 'Escape', code: 'Escape'})
212+
213+
// if stopPropagation worked, mockHandler would not have been called
214+
expect(mockHandler).toHaveBeenCalledTimes(0)
215+
})
103216
})

src/hooks/useOnEscapePress.ts

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
1-
import {useEffect, useCallback} from 'react'
2-
3-
const handlers: ((e: KeyboardEvent) => void)[] = []
1+
import {useEffect, useCallback, useMemo} from 'react'
42

53
/**
64
* Calls all handlers in reverse order
75
* @param event The KeyboardEvent generated by the Escape keydown.
86
*/
97
function handleEscape(event: KeyboardEvent) {
10-
if (event.key === 'Escape' && !event.defaultPrevented) {
11-
for (let i = handlers.length - 1; i >= 0; --i) {
12-
handlers[i](event)
8+
if (!event.defaultPrevented) {
9+
for (const handler of Object.values(registry).reverse()) {
10+
handler(event)
1311
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
14-
if (event.defaultPrevented) {
15-
break
16-
}
12+
if (event.defaultPrevented) break
1713
}
1814
}
1915
}
2016

17+
type KeyboardEventCallback = (event: KeyboardEvent) => void
18+
19+
const registry: {[id: number]: KeyboardEventCallback} = {}
20+
21+
function register(id: number, handler: KeyboardEventCallback): void {
22+
registry[id] = handler
23+
}
24+
25+
function deregister(id: number) {
26+
delete registry[id]
27+
}
28+
29+
// For auto-incrementing unique identifiers for registered handlers.
30+
let handlerId = 0
31+
2132
/**
2233
* Sets up a `keydown` listener on `window.document`. If
2334
* 1) The pressed key is "Escape", and
@@ -45,19 +56,26 @@ export const useOnEscapePress = (
4556
): void => {
4657
// eslint-disable-next-line react-hooks/exhaustive-deps
4758
const escapeCallback = useCallback(onEscape, callbackDependencies)
59+
60+
const handler = useCallback<KeyboardEventCallback>(
61+
event => {
62+
if (event.key === 'Escape') escapeCallback(event)
63+
},
64+
[escapeCallback]
65+
)
66+
67+
const id = useMemo(() => handlerId++, [])
4868
useEffect(() => {
49-
if (handlers.length === 0) {
69+
if (Object.keys(registry).length === 0) {
5070
document.addEventListener('keydown', handleEscape)
5171
}
52-
handlers.push(escapeCallback)
72+
register(id, handler)
73+
5374
return () => {
54-
handlers.splice(
55-
handlers.findIndex(h => h === escapeCallback),
56-
1
57-
)
58-
if (handlers.length === 0) {
75+
deregister(id)
76+
if (Object.keys(registry).length === 0) {
5977
document.removeEventListener('keydown', handleEscape)
6078
}
6179
}
62-
}, [escapeCallback])
80+
}, [id, handler])
6381
}

src/hooks/useOverlay.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export const useOverlay = ({
2929
const overlayRef = useProvidedRefOrCreate<HTMLDivElement>(_overlayRef)
3030
useOpenAndCloseFocus({containerRef: overlayRef, returnFocusRef, initialFocusRef, preventFocusOnOpen})
3131
useOnOutsideClick({containerRef: overlayRef, ignoreClickRefs, onClickOutside})
32-
useOnEscapePress(onEscape)
32+
33+
// We only want one overlay to close at a time
34+
const preventedDefaultOnEscape: UseOverlaySettings['onEscape'] = event => {
35+
onEscape(event)
36+
event.preventDefault()
37+
}
38+
useOnEscapePress(preventedDefaultOnEscape)
3339
return {ref: overlayRef}
3440
}

0 commit comments

Comments
 (0)