Skip to content

Commit 969e4ec

Browse files
frankieyanclaude
andcommitted
feat(sidebar): make SidebarContent a neutral panel with a landmark child
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d3f1c45 commit 969e4ec

4 files changed

Lines changed: 135 additions & 142 deletions

File tree

src/sidebar/sidebar.mdx

Lines changed: 18 additions & 17 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 (\`as\`, default \`aside\`) | 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 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

@@ -63,8 +63,8 @@ const navIsOverlay = viewportWidth < maxNavWidth + minContentWidth
6363

6464
<Box display="flex">
6565
<Sidebar align="start" isOpen={isOpen} isOverlay={navIsOverlay} overlayMode="modal" width={width} onWidthChange={setWidth}>
66-
<SidebarContent as="nav" aria-label="Main navigation">
67-
{/* nav content */}
66+
<SidebarContent aria-label="Main navigation">
67+
<nav aria-label="Main navigation">{/* nav content */}</nav>
6868
<SidebarResizeHandle aria-label="Resize sidebar" />
6969
</SidebarContent>
7070
</Sidebar>
@@ -127,22 +127,20 @@ backdrop and leaves the background interactive.
127127

128128
### Element and role
129129

130-
`SidebarContent` defaults to `aside` (a `complementary` landmark). Choose the
131-
element with `as`, and the landmark with `landmarkRole` plus an accessible name:
130+
`SidebarContent` takes the `dialog` role when it is an overlay and `overlayMode`
131+
is `dialog` or `modal`, otherwise it has no landmark role of its own. Nest your
132+
landmark element as a child and name it there:
132133

133134
<Markdown>{`
134-
| Intent | \`as\` and host props |
135+
| Intent | Landmark child |
135136
| --- | --- |
136-
| Top-level sidebar (peer of \`main\`) | \`as="aside"\` (default, \`complementary\`) |
137-
| Main navigation | \`as="nav"\` with \`aria-label\` |
138-
| Named nested pane | \`as="section"\` with \`aria-labelledby\` (a \`region\`) |
139-
| A dialog or modal overlay | \`as="div"\` (so the \`dialog\` role is valid) |
137+
| Top-level sidebar (peer of \`main\`) | \`<aside aria-label>\` (a \`complementary\` landmark) |
138+
| Main navigation | \`<nav aria-label>\` |
139+
| Named nested pane | \`<section aria-labelledby>\` (a \`region\`) |
140140
`}</Markdown>
141141

142-
When `overlayMode` is `dialog` or `modal`, the rendered role becomes `dialog`.
143-
An `aside` or `nav` cannot validly carry `role="dialog"`, so render the panel as
144-
a generic element (`as="div"`) when it becomes a dialog. The host `role` prop is
145-
ignored; the component owns the rendered role.
142+
Name the dialog on `SidebarContent`: prefer `aria-labelledby` pointing at a
143+
visible heading in the panel when there is one, or `aria-label` otherwise.
146144

147145
### Modal drawer
148146

@@ -230,15 +228,18 @@ CSS custom properties, set on `SidebarContent` (or any ancestor).
230228
- **State and persistence**`isOpen` and `width` are controlled; persist them
231229
yourself.
232230
- **The trigger** — wire your own button with `aria-expanded` / `aria-controls`.
231+
- **The landmark** — nest your `<nav>` / `<aside>` / `<section>` as a child of
232+
`SidebarContent`; the panel itself is a neutral container.
233233
- **The visual skin** — background, rounding, and padding are a child or
234234
`exceptionallySetClassName`; keep a clipping skin (`overflow: hidden`) a child
235235
so the resize handle is not cut off.
236236

237237
## Accessibility
238238

239-
- The panel is a landmark while docked (or a `plain` overlay) and a `dialog`
240-
while a `dialog` / `modal` overlay; give it an accessible name with `aria-label`
241-
or `aria-labelledby`.
239+
- When floating as a `dialog` / `modal` overlay, the panel becomes a `dialog`.
240+
Name it with `aria-labelledby` / `aria-label` on `SidebarContent`.
241+
- The panel's content should define a landmark as the component does not
242+
provide one.
242243
- A `modal` overlay traps focus, sets `aria-modal`, and returns focus to the
243244
trigger on close. The consumer applies `inert` to the main element so the rest
244245
of the page leaves the accessibility tree.

src/sidebar/sidebar.stories.tsx

Lines changed: 40 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,10 @@ export const Docked = {
194194
return (
195195
<Box display="flex" height="full">
196196
<Sidebar id="docked-nav" align="start" isOpen={isOpen} width={260}>
197-
<SidebarContent as="nav" aria-label="Primary" style={PANEL_SKIN}>
198-
<DemoNav />
197+
<SidebarContent style={PANEL_SKIN}>
198+
<Box as="nav" aria-label="Primary">
199+
<DemoNav />
200+
</Box>
199201
</SidebarContent>
200202
</Sidebar>
201203
<Box as="main" flexGrow={1} minWidth={0} padding="large" overflow="auto">
@@ -243,12 +245,10 @@ export const Resizable = {
243245
defaultWidth={280}
244246
resizeStep={24}
245247
>
246-
<SidebarContent
247-
as="nav"
248-
aria-label="Primary"
249-
style={{ ...PANEL_SKIN, ...HANDLE_VISIBLE }}
250-
>
251-
<DemoNav />
248+
<SidebarContent style={{ ...PANEL_SKIN, ...HANDLE_VISIBLE }}>
249+
<Box as="nav" aria-label="Primary">
250+
<DemoNav />
251+
</Box>
252252
<SidebarResizeHandle aria-label="Resize sidebar" />
253253
</SidebarContent>
254254
</Sidebar>
@@ -271,8 +271,9 @@ export const Resizable = {
271271
* A modal overlay drawer (`isOverlay` + `overlayMode="modal"`). It floats over
272272
* the content, traps focus, renders a dimming backdrop, and dismisses on the
273273
* backdrop click or Escape. The consumer applies `inert` to the main element
274-
* while the drawer is open. A modal/dialog overlay renders a generic element
275-
* (`as="div"`) so the `dialog` role is valid.
274+
* while the drawer is open. The panel becomes a `dialog` (named via `aria-label`
275+
* on `SidebarContent`), and the consumer's `<nav>` landmark remains a child,
276+
* preserved inside the dialog.
276277
*/
277278
export const ModalDrawer = {
278279
render: function ModalDrawer() {
@@ -291,8 +292,10 @@ export const ModalDrawer = {
291292
onDismiss={() => setIsOpen(false)}
292293
width={260}
293294
>
294-
<SidebarContent as="div" aria-label="Primary navigation" style={PANEL_SKIN}>
295-
<DemoNav />
295+
<SidebarContent aria-label="Primary navigation" style={PANEL_SKIN}>
296+
<Box as="nav" aria-label="Primary">
297+
<DemoNav />
298+
</Box>
296299
</SidebarContent>
297300
</Sidebar>
298301
<Box
@@ -369,7 +372,7 @@ export const DialogSidePane = {
369372
defaultWidth={340}
370373
resizeStep={24}
371374
>
372-
<SidebarContent as="div" aria-label="Assistant" style={CARD_INSETS}>
375+
<SidebarContent aria-labelledby="chat-pane-heading" style={CARD_INSETS}>
373376
<div style={CARD_SKIN}>
374377
<Box
375378
padding="medium"
@@ -382,7 +385,7 @@ export const DialogSidePane = {
382385
justifyContent="spaceBetween"
383386
alignItems="center"
384387
>
385-
<Heading level="2" size="smaller">
388+
<Heading level="2" size="smaller" id="chat-pane-heading">
386389
Assistant
387390
</Heading>
388391
<Button
@@ -416,8 +419,9 @@ export const DialogSidePane = {
416419
* Responsive shell. The consumer computes `isOverlay` from the container width:
417420
* above the breakpoint the nav is docked in flow; below it, the nav becomes a
418421
* modal drawer with a trigger. Resize the canvas (or use the viewport toolbar) to
419-
* cross the breakpoint. The panel stays `as="div"` with `landmarkRole="navigation"`
420-
* so it is a navigation landmark while docked and a dialog while a modal overlay.
422+
* cross the breakpoint. The `<nav>` landmark is a child of `SidebarContent`, so it
423+
* remains a navigation landmark while docked and is preserved inside the dialog while
424+
* a modal overlay (the panel itself becomes the dialog).
421425
*/
422426
export const Responsive = {
423427
render: function Responsive() {
@@ -439,13 +443,10 @@ export const Responsive = {
439443
onDismiss={() => setIsOpen(false)}
440444
width={240}
441445
>
442-
<SidebarContent
443-
as="div"
444-
landmarkRole="navigation"
445-
aria-label="Primary navigation"
446-
style={PANEL_SKIN}
447-
>
448-
<DemoNav />
446+
<SidebarContent aria-label="Primary navigation" style={PANEL_SKIN}>
447+
<Box as="nav" aria-label="Primary navigation">
448+
<DemoNav />
449+
</Box>
449450
</SidebarContent>
450451
</Sidebar>
451452
<Box
@@ -503,12 +504,10 @@ export const StackedSidebars = {
503504
defaultWidth={64}
504505
resizeStep={8}
505506
>
506-
<SidebarContent
507-
as="nav"
508-
aria-label="Workspaces"
509-
style={{ ...RAIL_SKIN, ...HANDLE_VISIBLE }}
510-
>
511-
<DemoRail />
507+
<SidebarContent style={{ ...RAIL_SKIN, ...HANDLE_VISIBLE }}>
508+
<Box as="nav" aria-label="Workspaces">
509+
<DemoRail />
510+
</Box>
512511
<SidebarResizeHandle aria-label="Resize workspace rail" />
513512
</SidebarContent>
514513
</Sidebar>
@@ -523,12 +522,10 @@ export const StackedSidebars = {
523522
defaultWidth={260}
524523
resizeStep={20}
525524
>
526-
<SidebarContent
527-
as="section"
528-
aria-label="Conversations"
529-
style={{ ...PANEL_SKIN, ...HANDLE_VISIBLE }}
530-
>
531-
<DemoNav title="Conversations" />
525+
<SidebarContent style={{ ...PANEL_SKIN, ...HANDLE_VISIBLE }}>
526+
<Box as="section" aria-label="Conversations">
527+
<DemoNav title="Conversations" />
528+
</Box>
532529
<SidebarResizeHandle aria-label="Resize conversation list" />
533530
</SidebarContent>
534531
</Sidebar>
@@ -572,12 +569,10 @@ export const LeftAndRight = {
572569
defaultWidth={240}
573570
resizeStep={20}
574571
>
575-
<SidebarContent
576-
as="nav"
577-
aria-label="Main navigation"
578-
style={{ ...PANEL_SKIN, ...HANDLE_VISIBLE }}
579-
>
580-
<DemoNav />
572+
<SidebarContent style={{ ...PANEL_SKIN, ...HANDLE_VISIBLE }}>
573+
<Box as="nav" aria-label="Main navigation">
574+
<DemoNav />
575+
</Box>
581576
<SidebarResizeHandle aria-label="Resize navigation" />
582577
</SidebarContent>
583578
</Sidebar>
@@ -603,12 +598,10 @@ export const LeftAndRight = {
603598
defaultWidth={320}
604599
resizeStep={20}
605600
>
606-
<SidebarContent
607-
as="aside"
608-
aria-label="Details"
609-
style={{ ...PANEL_SKIN_END, ...HANDLE_VISIBLE }}
610-
>
611-
<DemoNav title="Details" />
601+
<SidebarContent style={{ ...PANEL_SKIN_END, ...HANDLE_VISIBLE }}>
602+
<Box as="aside" aria-label="Details">
603+
<DemoNav title="Details" />
604+
</Box>
612605
<SidebarResizeHandle aria-label="Resize details pane" />
613606
</SidebarContent>
614607
</Sidebar>
@@ -687,7 +680,6 @@ export const Playground = {
687680
resizeStep={24}
688681
>
689682
<SidebarContent
690-
as={isOverlay && overlayMode !== 'plain' ? 'div' : 'aside'}
691683
aria-label="Playground sidebar"
692684
style={{ ...PANEL_SKIN, ...HANDLE_VISIBLE }}
693685
>

0 commit comments

Comments
 (0)