Skip to content

Commit aa56554

Browse files
frankieyanclaude
andcommitted
feat(sidebar): add SidebarPersistentContent and inert closed content
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent be7c2f7 commit aa56554

5 files changed

Lines changed: 229 additions & 8 deletions

File tree

src/sidebar/sidebar.mdx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ 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; 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 |
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, making its contents \`inert\` while closed, and panel-scoped Escape-to-dismiss |
39+
| \`SidebarPersistentContent\` | an optional slot inside the panel | A control that stays live while the sidebar is closed (e.g. a collapse toggle): kept out of the closed-state \`inert\`, while it rides the collapse transition and joins the focus trap |
3940
| \`SidebarResizeHandle\` | a \`role="separator"\` | The pointer and keyboard resize affordance and its ARIA; self-positions on the inner edge from \`align\` |
4041
`}</Markdown>
4142

@@ -187,6 +188,27 @@ provider. Set the same `id` on `Sidebar` that the trigger points its
187188
</button>
188189
```
189190

191+
## A control that stays live while closed
192+
193+
While a sidebar is closed, its contents are made `inert`, so off-screen controls leave the tab order and the accessibility tree. `SidebarPersistentContent` is the exception: a control placed in it stays usable while the sidebar is closed, the canonical case being a collapse toggle that peeks out of the slid-off panel and reopens it. Unlike the external trigger above, this is for a toggle that belongs _in_ the panel.
194+
195+
<Canvas of={SidebarStories.CollapsibleNav} />
196+
197+
Its children render inside the panel, so they ride the collapse transition and join the focus trap when modal, but outside the closed-state `inert`. It works at any nesting depth, so a toggle already living deep in the content does not need hoisting.
198+
199+
```tsx
200+
<SidebarContent aria-label="Main navigation">
201+
<SidebarPersistentContent>
202+
<button aria-expanded={isOpen} aria-controls="sidebar" data-no-autofocus onClick={toggle}>
203+
{/* your icon / tooltip */}
204+
</button>
205+
</SidebarPersistentContent>
206+
<nav aria-label="Main navigation">{/* nav content */}</nav>
207+
</SidebarContent>
208+
```
209+
210+
When the panel opens as a modal, the trap's initial focus goes to the first focusable, often the persistent control. Whether that is wanted is the consumer's call: add `data-no-autofocus` to your control (a react-focus-lock attribute) to keep a toggle from grabbing it, the way `Modal` opts its close button out, or `data-autofocus` to send initial focus to a specific element; the control stays tabbable either way. The consumer owns the control, its `aria-*`, and its positioning (e.g. a peek offset while collapsed).
211+
190212
## Left and right sidebars
191213

192214
A left nav and a right pane around the main absorber is the full shell contract.
@@ -256,3 +278,9 @@ CSS custom properties, set on `SidebarContent` (or any ancestor).
256278
or tooltip) keeps it.
257279
- The resize handle is a focusable `separator` while open, and leaves the tab
258280
order and the accessibility tree while closed.
281+
- While closed, the panel's contents are made `inert`, so off-screen controls
282+
leave the tab order and the accessibility tree. A control in
283+
`SidebarPersistentContent` is exempt and stays interactive. When the panel opens
284+
as a modal it receives initial focus by default (it is first in the trap), so add
285+
`data-no-autofocus` to it if it should not grab focus, as `Modal` does for its
286+
close button.

src/sidebar/sidebar.module.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
:root {
22
/* Overlay layering and viewport behaviour. */
33
--reactist-sidebar-overlay-z-index: 40;
4+
--reactist-sidebar-collapsed-z-index: 1;
45
--reactist-sidebar-overlay-viewport-margin: 0px;
56
--reactist-sidebar-overlay-inset-block: 0px;
67
--reactist-sidebar-overlay-inset-inline: 0px;
@@ -51,6 +52,15 @@
5152
margin-inline-end: calc(-1 * var(--reactist-sidebar-width, 0px));
5253
}
5354

55+
/*
56+
* Collapsed + docked, the panel's own `contain: layout` traps a persistent
57+
* control's z-index in the panel's stacking context, so it paints under a main
58+
* that is itself a stacking context. Lift the panel to keep the control visible.
59+
*/
60+
.panel[data-overlay='false'][data-state='closed'] {
61+
z-index: var(--reactist-sidebar-collapsed-z-index);
62+
}
63+
5464
/*
5565
* Overlay: the panel floats over the content layer, anchored to its align edge
5666
* and capped to the viewport. It slides off-edge with a compositor-only
@@ -92,6 +102,11 @@
92102
display: contents;
93103
}
94104

105+
.persistentContent,
106+
.inertContent {
107+
display: contents;
108+
}
109+
95110
.backdrop {
96111
position: fixed;
97112
inset: 0;

src/sidebar/sidebar.stories.tsx

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import * as React from 'react'
22

3-
import { Box, Button, Heading, Stack, Text } from '../index'
3+
import { Box, Button, Heading, IconButton, Stack, Text } from '../index'
44

5-
import { Sidebar, SidebarContent, SidebarResizeHandle } from './sidebar'
5+
import { Sidebar, SidebarContent, SidebarPersistentContent, SidebarResizeHandle } from './sidebar'
66

77
import type { Meta, StoryObj } from '@storybook/react-vite'
88
import type { SidebarAlign, SidebarOverlayMode } from './sidebar'
@@ -102,8 +102,11 @@ const CARD_SKIN = {
102102

103103
/**
104104
* Applies `inert` to the main element while a modal sidebar is open. This is the
105-
* consumer's job (the sidebar can't set attributes on a sibling); it is wired
106-
* imperatively here because this `@types/react` does not type the `inert` prop.
105+
* consumer's job (the sidebar can't set attributes on a sibling). It is set
106+
* imperatively rather than via an `inert` prop because Reactist supports React 18,
107+
* where `@types/react` doesn't type `inert` and the runtime doesn't toggle it from a
108+
* boolean; first-class `inert` support is React 19+.
109+
* @see https://github.com/facebook/react/blob/main/CHANGELOG.md#1900-december-5-2024 — React 19.0.0: "Add support for `inert`" (#24730 by @eps1lon)
107110
*/
108111
function useInert(active: boolean) {
109112
const ref = React.useRef<HTMLElement>(null)
@@ -221,6 +224,97 @@ export const Docked = {
221224
},
222225
} satisfies Story
223226

227+
function SidebarToggleIcon() {
228+
return (
229+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
230+
<rect
231+
x="1.75"
232+
y="2.75"
233+
width="12.5"
234+
height="10.5"
235+
rx="1.5"
236+
stroke="currentColor"
237+
strokeWidth="1.5"
238+
/>
239+
<line x1="6" x2="6" y1="3" y2="13" stroke="currentColor" strokeWidth="1.5" />
240+
</svg>
241+
)
242+
}
243+
244+
SidebarToggleIcon.displayName = 'SidebarToggleIcon'
245+
246+
/**
247+
* A docked main nav whose collapse toggle lives in `<SidebarPersistentContent>`, the
248+
* pattern Todoist, Comms, and Automations share. The toggle rides the collapse
249+
* animation and peeks at the freed edge while collapsed, staying reachable to reopen.
250+
*/
251+
export const CollapsibleNav = {
252+
render: function CollapsibleNav() {
253+
const [isOpen, setIsOpen] = React.useState(true)
254+
255+
const toggle = (
256+
<IconButton
257+
variant="secondary"
258+
icon={<SidebarToggleIcon />}
259+
aria-label={isOpen ? 'Collapse sidebar' : 'Open sidebar'}
260+
aria-controls="collapsible-nav"
261+
aria-expanded={isOpen}
262+
onClick={() => setIsOpen((open) => !open)}
263+
/>
264+
)
265+
266+
return (
267+
<Box display="flex" height="full">
268+
<Sidebar id="collapsible-nav" align="start" isOpen={isOpen} width={260}>
269+
<SidebarContent style={PANEL_SKIN}>
270+
<SidebarPersistentContent>
271+
{isOpen ? (
272+
<Box display="flex" justifyContent="flexEnd" padding="small">
273+
{toggle}
274+
</Box>
275+
) : (
276+
<div
277+
style={{
278+
position: 'absolute',
279+
top: 8,
280+
left: '100%',
281+
zIndex: 2,
282+
}}
283+
>
284+
{toggle}
285+
</div>
286+
)}
287+
</SidebarPersistentContent>
288+
<Box as="nav" aria-label="Primary">
289+
<DemoNav />
290+
</Box>
291+
</SidebarContent>
292+
</Sidebar>
293+
<Box
294+
as="main"
295+
flexGrow={1}
296+
minWidth={0}
297+
paddingY="large"
298+
paddingRight="large"
299+
paddingLeft={isOpen ? 'large' : 'xxlarge'}
300+
overflow="auto"
301+
>
302+
<Stack space="medium">
303+
<Heading level="2" size="larger">
304+
Main content
305+
</Heading>
306+
<Text tone="secondary">
307+
Collapse the nav with the toggle in its header. While collapsed the
308+
panel slides away and the toggle peeks at the edge, staying reachable to
309+
reopen.
310+
</Text>
311+
</Stack>
312+
</Box>
313+
</Box>
314+
)
315+
},
316+
} satisfies Story
317+
224318
/**
225319
* Adding a `<SidebarResizeHandle>` makes the panel resizable: the handle sits on
226320
* the inner edge (right for `align="start"`), drives a render-free pointer drag,

src/sidebar/sidebar.test.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar
44
import userEvent from '@testing-library/user-event'
55
import { axe } from 'jest-axe'
66

7-
import { Sidebar, SidebarContent, SidebarResizeHandle } from './sidebar'
7+
import { Sidebar, SidebarContent, SidebarPersistentContent, SidebarResizeHandle } from './sidebar'
88

99
import type { SidebarAlign, SidebarProps } from './sidebar'
1010

@@ -292,6 +292,34 @@ describe('unmountOnHide', () => {
292292
})
293293
})
294294

295+
describe('SidebarPersistentContent', () => {
296+
it('renders in the panel and stays visible when open, and out of the inert when closed', async () => {
297+
const { rerender } = renderSidebar(
298+
{},
299+
{
300+
children: (
301+
<nav aria-label="Main navigation">
302+
<SidebarPersistentContent>
303+
<button type="button">Toggle sidebar</button>
304+
</SidebarPersistentContent>
305+
<a href="#projects">Projects</a>
306+
</nav>
307+
),
308+
},
309+
)
310+
311+
const panel = screen.getByTestId('sidebar-panel')
312+
const toggle = await within(panel).findByRole('button', { name: 'Toggle sidebar' })
313+
expect(toggle).toBeVisible()
314+
expect(toggle.closest('[inert]')).toBeNull()
315+
expect(screen.getByText('Projects').closest('[inert]')).toBeNull()
316+
317+
rerender({ isOpen: false })
318+
expect(screen.getByText('Projects').closest('[inert]')).not.toBeNull()
319+
expect(toggle.closest('[inert]')).toBeNull()
320+
})
321+
})
322+
295323
describe('resize', () => {
296324
const WIDTH = 280
297325
const MIN_WIDTH = 210

src/sidebar/sidebar.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from 'react'
2+
import { createPortal } from 'react-dom'
23
import FocusLock from 'react-focus-lock'
34

45
import { hideOthers } from 'aria-hidden'
@@ -43,6 +44,13 @@ type SidebarContextValue = {
4344

4445
const SidebarContext = React.createContext<SidebarContextValue | null>(null)
4546

47+
/**
48+
* Scoped to `<SidebarContent>`: carries the live region that `<SidebarPersistentContent>`
49+
* portals into. `undefined` (the default) means there is no enclosing `<SidebarContent>`;
50+
* `null` means one is present but its region element has not attached yet.
51+
*/
52+
const SidebarContentContext = React.createContext<HTMLElement | null | undefined>(undefined)
53+
4654
function useSidebarContext(componentName: string): SidebarContextValue {
4755
const context = React.useContext(SidebarContext)
4856
if (context === null) {
@@ -291,6 +299,8 @@ const SidebarContent = React.forwardRef<HTMLDivElement, SidebarContentProps>(
291299
} = useSidebarContext('SidebarContent')
292300

293301
const mergedRef = useMergeRefs([panelRef, ref])
302+
const inertContentRef = React.useRef<HTMLDivElement>(null)
303+
const [persistentRegion, setPersistentRegion] = React.useState<HTMLElement | null>(null)
294304

295305
const isDialog = overlayOpen && (overlayMode === 'dialog' || overlayMode === 'modal')
296306
const ariaModal = overlayOpen && overlayMode === 'modal' ? true : undefined
@@ -305,6 +315,14 @@ const SidebarContent = React.forwardRef<HTMLDivElement, SidebarContentProps>(
305315
[shouldTrap, panelRef],
306316
)
307317

318+
// `inert` is a prop only in React 19+; toggle it imperatively to support React 18.
319+
React.useEffect(
320+
function inertContentWhileClosed() {
321+
inertContentRef.current?.toggleAttribute('inert', !isOpen)
322+
},
323+
[isOpen],
324+
)
325+
308326
const widthStyle =
309327
width != null
310328
? ({ [SIDEBAR_WIDTH_VAR]: `${width}px` } as React.CSSProperties)
@@ -352,7 +370,12 @@ const SidebarContent = React.forwardRef<HTMLDivElement, SidebarContentProps>(
352370
className={styles.focusLock}
353371
data-testid="sidebar-focus-lock"
354372
>
355-
{childrenToRender}
373+
<div ref={setPersistentRegion} className={styles.persistentContent} />
374+
<SidebarContentContext.Provider value={persistentRegion}>
375+
<div ref={inertContentRef} className={styles.inertContent}>
376+
{childrenToRender}
377+
</div>
378+
</SidebarContentContext.Provider>
356379
</FocusLock>
357380
</Box>
358381
)
@@ -532,6 +555,39 @@ function SidebarResizeHandle({
532555
)
533556
}
534557

558+
//
559+
// SidebarPersistentContent
560+
//
561+
562+
/**
563+
* Renders its children inside the panel but outside the closed-state `inert`
564+
* wrapper, so a control placed here (e.g. a collapse toggle) stays operable while
565+
* the sidebar is closed, rides the collapse transition, and joins the focus trap
566+
* when modal. Portals into a region `<SidebarContent>` provides, so it works at any
567+
* depth within it; used outside a `<SidebarContent>` it warns and renders nothing.
568+
* The consumer owns the control, its `aria-*`, and its positioning.
569+
*
570+
* @see Sidebar
571+
* @see SidebarContent
572+
*/
573+
function SidebarPersistentContent({ children }: { children?: React.ReactNode }) {
574+
const region = React.useContext(SidebarContentContext)
575+
576+
React.useEffect(
577+
function warnWhenOutsideContent() {
578+
if (region === undefined) {
579+
// eslint-disable-next-line no-console
580+
console.warn(
581+
'[Sidebar]: <SidebarPersistentContent> must be nested within <SidebarContent>; its children will not render.',
582+
)
583+
}
584+
},
585+
[region],
586+
)
587+
588+
return region ? createPortal(children, region) : null
589+
}
590+
535591
//
536592
// Backdrop (auto-rendered for modal overlays)
537593
//
@@ -564,7 +620,7 @@ function SidebarBackdrop() {
564620

565621
SidebarContent.displayName = 'SidebarContent'
566622

567-
export { Sidebar, SidebarContent, SidebarResizeHandle, useSidebar }
623+
export { Sidebar, SidebarContent, SidebarPersistentContent, SidebarResizeHandle, useSidebar }
568624
export type {
569625
SidebarAlign,
570626
SidebarContentProps,

0 commit comments

Comments
 (0)