Skip to content

Commit e48617a

Browse files
frankieyanclaude
andcommitted
fix(sidebar): scope the Escape dismiss to the panel
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b4eb36d commit e48617a

3 files changed

Lines changed: 68 additions & 31 deletions

File tree

src/sidebar/sidebar.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ styles the real elements while the provider holds state and behavior.
3434
<Markdown>{`
3535
| Part | Renders | Owns |
3636
| --- | --- | --- |
37-
| \`Sidebar\` | the modal backdrop only (a sibling of the panel) | All controlled state and behavior: open, overlay, width and bounds, dismiss; derives the overlay state; runs the Escape dismiss effect; provides context |
38-
| \`SidebarContent\` | the panel element (a neutral \`<div>\` with \`role="dialog"\` when needed) | Positioning, the slide / collapse transition, dialog semantics, the committed width, and the focus trap and assistive-tech hiding while modal |
37+
| \`Sidebar\` | the modal backdrop only (a sibling of the panel) | All controlled state and behavior: open, overlay, width and bounds, dismiss; derives the overlay state; provides context |
38+
| \`SidebarContent\` | the panel element (a neutral \`<div>\` with \`role="dialog"\` when needed) | Positioning, the slide / collapse transition, dialog semantics, the committed width, the focus trap and assistive-tech hiding while modal, and panel-scoped Escape-to-dismiss |
3939
| \`SidebarResizeHandle\` | a \`role="separator"\` | The pointer and keyboard resize affordance and its ARIA; self-positions on the inner edge from \`align\` |
4040
`}</Markdown>
4141

@@ -245,7 +245,9 @@ CSS custom properties, set on `SidebarContent` (or any ancestor).
245245
automatically (it marks sibling content `aria-hidden`). Still apply `inert` to
246246
the main element: it also blocks pointer interaction, which the automatic
247247
hiding does not.
248-
- Escape dismisses an open overlay when `dismissOverlayOnEscape` is set, and respects
249-
`event.defaultPrevented` so app-level key handling can opt out.
248+
- Escape dismisses an open overlay when `dismissOverlayOnEscape` is set, but only
249+
while focus is within the panel (it is handled on the panel, not app-wide). It
250+
respects `event.defaultPrevented`, so a child that consumes Escape (an open menu
251+
or tooltip) keeps it.
250252
- The resize handle is a focusable `separator` while open, and leaves the tab
251253
order and the accessibility tree while closed.

src/sidebar/sidebar.test.tsx

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -174,41 +174,74 @@ describe('modal background', () => {
174174
})
175175

176176
describe('Escape dismissal', () => {
177-
it('dismisses an open overlay on Escape when enabled', () => {
177+
it('dismisses an open overlay on Escape from within the panel when enabled', () => {
178178
const onDismiss = jest.fn()
179179
renderSidebar({
180180
isOverlay: true,
181181
overlayMode: 'modal',
182182
dismissOverlayOnEscape: true,
183183
onDismiss,
184184
})
185-
fireEvent.keyDown(document.body, { key: 'Escape' })
185+
fireEvent.keyDown(screen.getByTestId('sidebar-panel'), { key: 'Escape' })
186186
expect(onDismiss).toHaveBeenCalledTimes(1)
187187
})
188188

189-
it('respects defaultPrevented so app handlers can opt out', () => {
189+
it('dismisses a non-modal overlay on Escape from within the panel', () => {
190190
const onDismiss = jest.fn()
191191
renderSidebar({
192192
isOverlay: true,
193-
overlayMode: 'modal',
193+
overlayMode: 'dialog',
194194
dismissOverlayOnEscape: true,
195195
onDismiss,
196196
})
197+
fireEvent.keyDown(screen.getByTestId('sidebar-panel'), { key: 'Escape' })
198+
expect(onDismiss).toHaveBeenCalledTimes(1)
199+
})
197200

198-
function preventEscape(event: KeyboardEvent) {
199-
if (event.key === 'Escape') event.preventDefault()
200-
}
201-
document.body.addEventListener('keydown', preventEscape, { capture: true })
201+
it('ignores Escape fired outside the panel', () => {
202+
const onDismiss = jest.fn()
203+
renderSidebar({
204+
isOverlay: true,
205+
overlayMode: 'dialog',
206+
dismissOverlayOnEscape: true,
207+
onDismiss,
208+
})
202209
fireEvent.keyDown(document.body, { key: 'Escape' })
203-
document.body.removeEventListener('keydown', preventEscape, { capture: true })
210+
expect(onDismiss).not.toHaveBeenCalled()
211+
})
204212

213+
it('respects defaultPrevented so a descendant can consume Escape', () => {
214+
const onDismiss = jest.fn()
215+
renderSidebar(
216+
{
217+
isOverlay: true,
218+
overlayMode: 'modal',
219+
dismissOverlayOnEscape: true,
220+
onDismiss,
221+
},
222+
{
223+
children: (
224+
<button
225+
type="button"
226+
onKeyDown={(event) => {
227+
if (event.key === 'Escape') event.preventDefault()
228+
}}
229+
>
230+
Consumes Escape
231+
</button>
232+
),
233+
},
234+
)
235+
fireEvent.keyDown(screen.getByRole('button', { name: 'Consumes Escape' }), {
236+
key: 'Escape',
237+
})
205238
expect(onDismiss).not.toHaveBeenCalled()
206239
})
207240

208241
it('does not dismiss while docked', () => {
209242
const onDismiss = jest.fn()
210243
renderSidebar({ isOverlay: false, dismissOverlayOnEscape: true, onDismiss })
211-
fireEvent.keyDown(document.body, { key: 'Escape' })
244+
fireEvent.keyDown(screen.getByTestId('sidebar-panel'), { key: 'Escape' })
212245
expect(onDismiss).not.toHaveBeenCalled()
213246
})
214247

@@ -220,7 +253,7 @@ describe('Escape dismissal', () => {
220253
dismissOverlayOnEscape: false,
221254
onDismiss,
222255
})
223-
fireEvent.keyDown(document.body, { key: 'Escape' })
256+
fireEvent.keyDown(screen.getByTestId('sidebar-panel'), { key: 'Escape' })
224257
expect(onDismiss).not.toHaveBeenCalled()
225258
})
226259
})

src/sidebar/sidebar.tsx

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type SidebarContextValue = {
3131
unmountOnHide: boolean
3232
panelId: string
3333
panelRef: React.RefObject<HTMLDivElement | null>
34+
dismissOverlayOnEscape: boolean
3435
onDismiss?: () => void
3536
width?: number
3637
minWidth?: number
@@ -191,22 +192,6 @@ function Sidebar({
191192
const overlayOpen = isOverlay && isOpen
192193
const shouldTrap = overlayOpen && overlayMode === 'modal'
193194

194-
React.useEffect(
195-
function dismissOverlayOnEscapeEffect() {
196-
if (!overlayOpen || !dismissOverlayOnEscape) return
197-
198-
function handleEscapeKeyDown(event: KeyboardEvent) {
199-
if (event.key === 'Escape' && !event.defaultPrevented) {
200-
onDismiss?.()
201-
}
202-
}
203-
204-
window.addEventListener('keydown', handleEscapeKeyDown)
205-
return () => window.removeEventListener('keydown', handleEscapeKeyDown)
206-
},
207-
[overlayOpen, dismissOverlayOnEscape, onDismiss],
208-
)
209-
210195
const contextValue: SidebarContextValue = {
211196
align,
212197
overlayMode,
@@ -217,6 +202,7 @@ function Sidebar({
217202
unmountOnHide,
218203
panelId,
219204
panelRef,
205+
dismissOverlayOnEscape,
220206
onDismiss,
221207
width,
222208
minWidth,
@@ -282,6 +268,7 @@ const SidebarContent = React.forwardRef<HTMLDivElement, SidebarContentProps>(
282268
exceptionallySetClassName,
283269
children,
284270
style,
271+
onKeyDown: consumerOnKeyDown,
285272
'aria-label': ariaLabel,
286273
'aria-labelledby': ariaLabelledby,
287274
...rest
@@ -299,6 +286,8 @@ const SidebarContent = React.forwardRef<HTMLDivElement, SidebarContentProps>(
299286
panelId,
300287
panelRef,
301288
width,
289+
dismissOverlayOnEscape,
290+
onDismiss,
302291
} = useSidebarContext('SidebarContent')
303292

304293
const mergedRef = useMergeRefs([panelRef, ref])
@@ -328,11 +317,24 @@ const SidebarContent = React.forwardRef<HTMLDivElement, SidebarContentProps>(
328317
? children
329318
: null
330319

320+
function handlePanelKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
321+
if (
322+
overlayOpen &&
323+
dismissOverlayOnEscape &&
324+
event.key === 'Escape' &&
325+
!event.defaultPrevented
326+
) {
327+
onDismiss?.()
328+
}
329+
consumerOnKeyDown?.(event)
330+
}
331+
331332
return (
332333
<Box
333334
{...rest}
334335
as="div"
335336
ref={mergedRef}
337+
onKeyDown={handlePanelKeyDown}
336338
display="flex"
337339
flexDirection="column"
338340
flexShrink={0}

0 commit comments

Comments
 (0)