Skip to content

Commit 9f8fd47

Browse files
committed
fix(ui-drawer-layout,ui-a11y-utils): fix Tray closing immediately after opening and calling onDismiss
INSTUI-4828
1 parent 14a4b02 commit 9f8fd47

File tree

5 files changed

+200
-30
lines changed

5 files changed

+200
-30
lines changed

packages/ui-a11y-utils/src/FocusRegion.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,12 @@ class FocusRegion {
8585
this._contextElement = element
8686
if (options) {
8787
this._options = options
88-
this.addOrRemoveListeners(
89-
options.shouldCloseOnDocumentClick,
90-
options.shouldCloseOnEscape
91-
)
88+
if (this._active) {
89+
this.addOrRemoveListeners(
90+
options.shouldCloseOnDocumentClick,
91+
options.shouldCloseOnEscape
92+
)
93+
}
9294
}
9395
if (this._keyboardFocusRegion) {
9496
this._keyboardFocusRegion.updateElement(element)
@@ -114,6 +116,8 @@ class FocusRegion {
114116
}
115117

116118
handleDocumentClick = (event: React.PointerEvent) => {
119+
// we used event.pointerType === 'mouse' here, but it is not supported in Safari
120+
// https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pointerType
117121
if (
118122
this._options.shouldCloseOnDocumentClick &&
119123
event.button === 0 &&

packages/ui-a11y-utils/src/__tests__/FocusRegion.test.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ describe('FocusRegion', () => {
6868
const outsideElement = document.createElement('div')
6969
document.body.appendChild(outsideElement)
7070

71-
fireEvent.mouseDown(outsideElement, { button: 0 })
7271
fireEvent.click(outsideElement, { button: 0, detail: 1 })
7372

7473
await waitFor(() => {
@@ -91,7 +90,6 @@ describe('FocusRegion', () => {
9190
const outsideElement = document.createElement('div')
9291
document.body.appendChild(outsideElement)
9392

94-
fireEvent.mouseDown(outsideElement, { button: 0 })
9593
fireEvent.click(outsideElement, { button: 0, detail: 1 })
9694

9795
// Wait a bit to ensure no dismiss is called
@@ -252,6 +250,42 @@ describe('FocusRegion', () => {
252250
// The internal state should be updated
253251
expect(focusRegion).toBeDefined()
254252
})
253+
254+
it('should not add event listeners when updateElement is called on inactive region', async () => {
255+
const onDismiss = vi.fn()
256+
focusRegion = new FocusRegion(container, {
257+
shouldCloseOnDocumentClick: false,
258+
shouldCloseOnEscape: false,
259+
onDismiss
260+
})
261+
262+
// Do NOT activate the region - it should remain inactive
263+
expect(focusRegion.focused).toBe(false)
264+
265+
// Update element with options that would add listeners if region was active
266+
const newContainer = document.createElement('div')
267+
const newOptions = {
268+
shouldCloseOnDocumentClick: true,
269+
shouldCloseOnEscape: true,
270+
onDismiss
271+
}
272+
273+
focusRegion.updateElement(newContainer, newOptions)
274+
275+
// Region should still be inactive
276+
expect(focusRegion.focused).toBe(false)
277+
278+
// Click outside the container - should NOT dismiss since no listeners were added
279+
const outsideElement = document.createElement('div')
280+
document.body.appendChild(outsideElement)
281+
282+
fireEvent.click(outsideElement, { button: 0, detail: 1 })
283+
284+
fireEvent.keyUp(document, { keyCode: 27 })
285+
expect(onDismiss).not.toHaveBeenCalled()
286+
287+
document.body.removeChild(outsideElement)
288+
})
255289
})
256290

257291
describe('keyboardFocusable', () => {

packages/ui-drawer-layout/src/DrawerLayout/DrawerTray/props.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ type PropsPassedToDialog = {
102102
*/
103103
liveRegion?: LiveRegion
104104

105+
/**
106+
* Event fired when the underlying FocusRegion is dismissed in overlay mode.
107+
* This can happen if:
108+
* - `shouldCloseOnDocumentClick` is `true` and the user
109+
* clicks outside the `<DrawerLayout.Tray />`
110+
* - If `shouldCloseOnEscape` is `true` and the user presses the ESC key.
111+
*
112+
* This should be used to close the `<DrawerLayout.Tray />` in these cases
113+
*/
105114
onDismiss?: (
106115
event: React.UIEvent | React.FocusEvent,
107116
documentClick?: boolean

packages/ui-drawer-layout/src/DrawerLayout/__tests__/DrawerLayout.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@
2222
* SOFTWARE.
2323
*/
2424

25-
import { render, screen } from '@testing-library/react'
25+
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'
26+
import { vi } from 'vitest'
2627
import '@testing-library/jest-dom'
2728

29+
import { DrawerLayout } from '../index'
2830
import DrawerLayoutFixture from '../__fixtures__/DrawerLayout.fixture'
31+
import { Button } from '@instructure/ui-buttons'
32+
import { View } from '@instructure/ui-view'
2933

3034
describe('<DrawerLayout />', () => {
3135
it('should render', () => {
@@ -50,4 +54,48 @@ describe('<DrawerLayout />', () => {
5054
expect(contentWrapper).toHaveTextContent('Hello from content')
5155
expect(contentWrapper).toHaveAttribute('aria-label', 'Test DrawerContent')
5256
})
57+
58+
it('should close the tray when ESC is pressed in overlay mode', async () => {
59+
let open = true
60+
const msg = 'This is in the Tray'
61+
const onDismiss = vi.fn(() => {
62+
open = false
63+
})
64+
// Small layout width to trigger overlay mode
65+
const TestComponent = () => (
66+
<View height="25rem" as="div" position="relative">
67+
<DrawerLayout minWidth="4444px">
68+
<DrawerLayout.Tray
69+
id="DrawerLayoutTrayExample1"
70+
open={open}
71+
label="Drawer Tray Start Example"
72+
onDismiss={onDismiss}
73+
>
74+
<Button onClick={() => (open = false)}>Close Tray</Button>
75+
{msg}
76+
</DrawerLayout.Tray>
77+
<DrawerLayout.Content label="Drawer content example">
78+
<Button
79+
onClick={() => {
80+
open = true
81+
}}
82+
>
83+
Expand tray
84+
</Button>
85+
</DrawerLayout.Content>
86+
</DrawerLayout>
87+
</View>
88+
)
89+
const { rerender } = render(<TestComponent />)
90+
expect(screen.getByText(msg)).toBeInTheDocument()
91+
await act(() => new Promise((resolve) => requestAnimationFrame(resolve)))
92+
fireEvent.keyUp(document, { keyCode: 27 }) // ESC key
93+
await waitFor(() => {
94+
expect(onDismiss).toHaveBeenCalled()
95+
})
96+
rerender(<TestComponent />)
97+
await waitFor(() => {
98+
expect(screen.queryByText(msg)).not.toBeInTheDocument()
99+
})
100+
})
53101
})

packages/ui-tray/src/Tray/__tests__/Tray.test.tsx

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,31 @@
2222
* SOFTWARE.
2323
*/
2424

25-
import { render, screen, waitFor } from '@testing-library/react'
25+
import { act, fireEvent, render, screen } from '@testing-library/react'
2626
import { vi } from 'vitest'
2727
import '@testing-library/jest-dom'
28+
import { useState } from 'react'
2829

2930
import { Tray } from '../index'
3031
import type { TrayProps } from '../props'
3132

3233
describe('<Tray />', async () => {
33-
it('should render nothing and have a node with no parent when closed', async () => {
34+
beforeAll(() => {
35+
vi.useFakeTimers()
36+
})
37+
38+
afterAll(() => {
39+
vi.useRealTimers()
40+
})
41+
42+
it('should render nothing and have a node with no parent when closed', () => {
3443
render(<Tray label="Tray Example">Hello Tray</Tray>)
3544

3645
const trayContent = screen.queryByText('Hello Tray')
3746
expect(trayContent).not.toBeInTheDocument()
3847
})
3948

40-
it('should render children and have a node with a parent when open', async () => {
49+
it('should render children and have a node with a parent when open', () => {
4150
render(
4251
<Tray label="Tray Example" open>
4352
Hello Tray
@@ -48,31 +57,30 @@ describe('<Tray />', async () => {
4857
expect(trayContent).toBeInTheDocument()
4958
})
5059

51-
it('should apply the a11y attributes', async () => {
60+
it('should apply the a11y attributes', () => {
5261
render(
5362
<Tray label="Tray Example" open>
5463
Hello Tray
5564
</Tray>
5665
)
5766
const tray = screen.getByRole('dialog')
58-
5967
expect(tray).toHaveAttribute('aria-label', 'Tray Example')
6068
})
6169

62-
it('should support onOpen prop', async () => {
70+
it('should support onOpen prop', () => {
6371
const onOpen = vi.fn()
6472
render(
6573
<Tray label="Tray Example" open onOpen={onOpen}>
6674
Hello Tray
6775
</Tray>
6876
)
69-
70-
await waitFor(() => {
71-
expect(onOpen).toHaveBeenCalled()
77+
act(() => {
78+
vi.runAllTimers()
7279
})
80+
expect(onOpen).toHaveBeenCalled()
7381
})
7482

75-
it('should support onClose prop', async () => {
83+
it('should support onClose prop', () => {
7684
const onClose = vi.fn()
7785

7886
const { rerender } = render(
@@ -87,13 +95,13 @@ describe('<Tray />', async () => {
8795
Hello Tray
8896
</Tray>
8997
)
90-
91-
await waitFor(() => {
92-
expect(onClose).toHaveBeenCalled()
98+
act(() => {
99+
vi.runAllTimers()
93100
})
101+
expect(onClose).toHaveBeenCalled()
94102
})
95103

96-
it('should take a prop for finding default focus', async () => {
104+
it('should take a prop for finding default focus', () => {
97105
render(
98106
<Tray
99107
label="Tray Example"
@@ -106,10 +114,10 @@ describe('<Tray />', async () => {
106114
</Tray>
107115
)
108116
const input = screen.getByLabelText('my-input-label')
109-
110-
await waitFor(() => {
111-
expect(document.activeElement).toBe(input)
117+
act(() => {
118+
vi.runAllTimers()
112119
})
120+
expect(document.activeElement).toBe(input)
113121
})
114122

115123
describe('transition()', () => {
@@ -139,7 +147,6 @@ describe('<Tray />', async () => {
139147
}
140148
}
141149
}
142-
143150
for (const dir in placements) {
144151
describe(`when text direction is '${dir}'`, () => {
145152
for (const placement in placements[dir].enteringPlacements) {
@@ -158,15 +165,16 @@ describe('<Tray />', async () => {
158165
Hello
159166
</Tray>
160167
)
161-
await waitFor(() => {
162-
expect(onEntered).toHaveBeenCalled()
168+
act(() => {
169+
vi.runAllTimers()
163170
})
171+
expect(onEntered).toHaveBeenCalled()
164172
})
165173
}
166174

167175
for (const placement in placements[dir].exitingPlacements) {
168176
const val = placements[dir].exitingPlacements[placement]
169-
it(`returns ${val} for ${placement} when exiting`, async () => {
177+
it(`returns ${val} for ${placement} when exiting`, () => {
170178
const onExited = vi.fn()
171179
document.documentElement.setAttribute('dir', dir)
172180

@@ -192,12 +200,79 @@ describe('<Tray />', async () => {
192200
Hello
193201
</Tray>
194202
)
195-
await waitFor(() => {
196-
expect(onExited).toHaveBeenCalled()
203+
act(() => {
204+
vi.runAllTimers()
197205
})
206+
expect(onExited).toHaveBeenCalled()
198207
})
199208
}
200209
})
201210
}
202211
})
212+
213+
it('should open, close via dismiss, and reopen with shouldCloseOnDocumentClick enabled', async () => {
214+
const onDismiss = vi.fn()
215+
const onEntered = vi.fn()
216+
const onExited = vi.fn()
217+
218+
const TrayWithButton = () => {
219+
const [isOpen, setIsOpen] = useState(false)
220+
221+
const handleDismiss = () => {
222+
setIsOpen(false)
223+
onDismiss()
224+
}
225+
226+
return (
227+
<div>
228+
<div>Outside of Tray</div>
229+
<button onClick={() => setIsOpen(!isOpen)}>Toggle Tray</button>
230+
<Tray
231+
label="Tray Example"
232+
shouldCloseOnDocumentClick
233+
open={isOpen}
234+
onDismiss={handleDismiss}
235+
onEntered={onEntered}
236+
onExited={onExited}
237+
>
238+
<div>Tray Content</div>
239+
</Tray>
240+
</div>
241+
)
242+
}
243+
render(<TrayWithButton />)
244+
// 1. Open Tray
245+
expect(screen.queryByText('Tray Content')).not.toBeInTheDocument()
246+
const button = screen.getByText('Toggle Tray')
247+
fireEvent.click(button, { button: 0, detail: 1 })
248+
act(() => {
249+
vi.runAllTimers()
250+
})
251+
expect(onEntered).toHaveBeenCalled()
252+
expect(onDismiss).not.toHaveBeenCalled()
253+
expect(screen.getByText('Tray Content')).toBeInTheDocument()
254+
// 2. Close Tray be clicking outside it
255+
// event.detail and button are needed because FocusRegion.ts/handleDocumentClick
256+
fireEvent.click(document, { button: 0, detail: 1 })
257+
act(() => {
258+
vi.runAllTimers()
259+
})
260+
expect(onDismiss).toHaveBeenCalled()
261+
expect(onExited).toHaveBeenCalled()
262+
expect(screen.queryByText('Tray Content')).not.toBeInTheDocument()
263+
264+
onEntered.mockClear()
265+
onDismiss.mockClear()
266+
onExited.mockClear()
267+
268+
// 3. click Button again, Tray should reopen.
269+
fireEvent.click(button, { button: 0, detail: 1 })
270+
act(() => {
271+
vi.runAllTimers()
272+
})
273+
expect(onEntered).toHaveBeenCalled()
274+
expect(onDismiss).not.toHaveBeenCalled()
275+
expect(onExited).not.toHaveBeenCalled()
276+
expect(screen.getByText('Tray Content')).toBeInTheDocument()
277+
})
203278
})

0 commit comments

Comments
 (0)