Skip to content

Commit b4eb36d

Browse files
frankieyanclaude
andcommitted
feat(sidebar): hide the background from assistive tech for modal overlays
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 969e4ec commit b4eb36d

3 files changed

Lines changed: 46 additions & 5 deletions

File tree

src/sidebar/sidebar.mdx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ styles the real elements while the provider holds state and behavior.
3535
| Part | Renders | Owns |
3636
| --- | --- | --- |
3737
| \`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 while modal |
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 |
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

@@ -117,7 +117,7 @@ static: it sets what the overlay is while floating.
117117
| --- | --- | --- | --- | --- | --- |
118118
| \`plain\` (default) | – | – | – | interactive | none |
119119
| \`dialog\` | yes | – | – | interactive | none |
120-
| \`modal\` | yes | yes | yes | inert (consumer-applied) | auto-rendered |
120+
| \`modal\` | yes | yes | yes | inert (consumer) + AT-hidden (auto) | auto-rendered |
121121
`}</Markdown>
122122

123123
The backdrop is not a component: `Sidebar` renders it automatically for
@@ -240,9 +240,11 @@ CSS custom properties, set on `SidebarContent` (or any ancestor).
240240
Name it with `aria-labelledby` / `aria-label` on `SidebarContent`.
241241
- The panel's content should define a landmark as the component does not
242242
provide one.
243-
- A `modal` overlay traps focus, sets `aria-modal`, and returns focus to the
244-
trigger on close. The consumer applies `inert` to the main element so the rest
245-
of the page leaves the accessibility tree.
243+
- A `modal` overlay traps focus, sets `aria-modal`, returns focus to the trigger
244+
on close, and hides the rest of the page from assistive technology
245+
automatically (it marks sibling content `aria-hidden`). Still apply `inert` to
246+
the main element: it also blocks pointer interaction, which the automatic
247+
hiding does not.
246248
- Escape dismisses an open overlay when `dismissOverlayOnEscape` is set, and respects
247249
`event.defaultPrevented` so app-level key handling can opt out.
248250
- The resize handle is a focusable `separator` while open, and leaves the tab

src/sidebar/sidebar.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,34 @@ describe('modal backdrop', () => {
145145
})
146146
})
147147

148+
describe('modal background', () => {
149+
const tree = (isOpen: boolean, overlayMode: 'modal' | 'dialog') => (
150+
<div>
151+
<Sidebar align="start" isOpen={isOpen} isOverlay overlayMode={overlayMode}>
152+
<SidebarContent aria-label="Menu">
153+
<nav aria-label="Primary">Nav</nav>
154+
</SidebarContent>
155+
</Sidebar>
156+
<main>
157+
<button type="button">Background action</button>
158+
</main>
159+
</div>
160+
)
161+
162+
it('hides the background from accessibility queries while a modal overlay is open, restoring on close', () => {
163+
const { rerender } = render(tree(true, 'modal'))
164+
expect(screen.queryByRole('button', { name: 'Background action' })).not.toBeInTheDocument()
165+
166+
rerender(tree(false, 'modal'))
167+
expect(screen.getByRole('button', { name: 'Background action' })).toBeInTheDocument()
168+
})
169+
170+
it('leaves the background reachable for a non-modal (dialog) overlay', () => {
171+
render(tree(true, 'dialog'))
172+
expect(screen.getByRole('button', { name: 'Background action' })).toBeInTheDocument()
173+
})
174+
})
175+
148176
describe('Escape dismissal', () => {
149177
it('dismisses an open overlay on Escape when enabled', () => {
150178
const onDismiss = jest.fn()

src/sidebar/sidebar.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as React from 'react'
22
import FocusLock from 'react-focus-lock'
33

4+
import { hideOthers } from 'aria-hidden'
45
import classNames from 'classnames'
56
import { useMergeRefs } from 'use-callback-ref'
67

@@ -305,6 +306,16 @@ const SidebarContent = React.forwardRef<HTMLDivElement, SidebarContentProps>(
305306
const isDialog = overlayOpen && (overlayMode === 'dialog' || overlayMode === 'modal')
306307
const ariaModal = overlayOpen && overlayMode === 'modal' ? true : undefined
307308

309+
React.useLayoutEffect(
310+
function hideBackgroundFromAssistiveTech() {
311+
if (!shouldTrap) return
312+
const panel = panelRef.current
313+
if (!panel) return
314+
return hideOthers(panel)
315+
},
316+
[shouldTrap, panelRef],
317+
)
318+
308319
const widthStyle =
309320
width != null
310321
? ({

0 commit comments

Comments
 (0)