Skip to content

Commit 46593a4

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 46593a4

File tree

6 files changed

+216
-30
lines changed

6 files changed

+216
-30
lines changed

CLAUDE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
InstUI is a React component library and design system. **Lerna 8 monorepo** with 100+ packages.
66

77
**Key Resources:**
8+
89
- Website: https://instructure.design
910
- AI docs: https://instructure.design/llms.txt
1011
- Component docs: https://instructure.design/markdowns/[ComponentName].md
@@ -27,6 +28,7 @@ InstUI is a React component library and design system. **Lerna 8 monorepo** with
2728
```
2829

2930
**Important file locations:**
31+
3032
- Component source: `/packages/ui-*/src/`
3133
- Component tests: Co-located with components
3234
- Theme types: `/packages/shared-types/src/ComponentThemeVariables.ts`
@@ -76,11 +78,13 @@ npm run clean-node && npm install # Nuclear option (removes all node_modu
7678
## Finding Component Information
7779

7880
**To find available components and their packages:**
81+
7982
- Check `/packages/__docs__/src/components.ts` - lists all components with their package locations
8083

8184
**Each component has two READMEs:**
8285

8386
1. **Component README**: `/packages/[package]/src/[Component]/README.md`
87+
8488
- What the component does and usage examples
8589
- **Check this first** - it has the detailed information
8690
- These READMEs are also used to render the component documentation pages on instructure.design
@@ -89,6 +93,7 @@ npm run clean-node && npm install # Nuclear option (removes all node_modu
8993
- Package overview, installation, exports
9094

9195
**For complete API details of a component:**
96+
9297
- Props: Check `props.ts` files in component source
9398
- Theme variables: Check `theme.ts` files in component source
9499

@@ -108,6 +113,7 @@ npm run clean-node && npm install # Nuclear option (removes all node_modu
108113
A change is **breaking** if it requires consumers to modify their code:
109114

110115
**Breaking changes (avoid):**
116+
111117
- ❌ Removing or renaming a prop
112118
- ❌ Changing a prop's type or behavior
113119
- ❌ Removing or renaming a component
@@ -116,6 +122,7 @@ A change is **breaking** if it requires consumers to modify their code:
116122
- ❌ Removing exported utilities or functions
117123

118124
**Not breaking changes (preferred):**
125+
119126
- ✅ Adding new optional props
120127
- ✅ Adding new components
121128
- ✅ Bug fixes that restore intended behavior
@@ -129,11 +136,17 @@ When a breaking change is explicitly requested, document it clearly in the commi
129136
## Testing Requirements
130137

131138
All components **MUST**:
139+
132140
1. Have unit tests (Vitest + React Testing Library in `*.test.tsx`)
133141
2. Have visual regression tests in `/regression-test`
134142
3. Pass accessibility audits (WCAG 2.1 AA)
135143
4. Support RTL languages
136144

145+
### Writing tests
146+
147+
- Try to use `vitest`'s fake timers in unit tests
148+
- When simulating mouse events (e.g., `fireEvent.click`), use `{ button: 0, detail: 1 }` if the click events don't seem to work. This is needed because `FocusRegion.ts/handleDocumentClick` uses `event.detail` and `event.button` to determine if the event was a mouse/touch click.
149+
137150
### Running Tests
138151

139152
```bash
@@ -158,11 +171,13 @@ See `/regression-test/README.md` for detailed instructions.
158171
InstUI uses custom markdown with special code blocks (using [gray-matter](https://github.com/jonschlinkert/gray-matter) YAML syntax) for interactive examples.
159172

160173
**Code block types:**
174+
161175
- `type: code` - Syntax highlighting only
162176
- `type: embed` - Rendered component only
163177
- `type: example` - Interactive (rendered + editable) ⭐ Most common
164178

165179
**IMPORTANT:**
180+
166181
- Always write functional component examples with hooks
167182
- All InstUI components are available without imports in examples
168183

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

Lines changed: 9 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,9 @@ 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
121+
// TODO: Check this in the future, because the linked Webkit bug is marked as fixed in 2025-09
117122
if (
118123
this._options.shouldCloseOnDocumentClick &&
119124
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
})

0 commit comments

Comments
 (0)