diff --git a/src/index.ts b/src/index.ts index a2747233..3a95ae24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ export * from './badge' export * from './expansion-panel' export * from './menu' export * from './modal' +export * from './sidebar' export * from './tabs' export * from './tooltip' diff --git a/src/sidebar/index.ts b/src/sidebar/index.ts new file mode 100644 index 00000000..28a1f2af --- /dev/null +++ b/src/sidebar/index.ts @@ -0,0 +1 @@ +export * from './sidebar' diff --git a/src/sidebar/sidebar.mdx b/src/sidebar/sidebar.mdx new file mode 100644 index 00000000..f7a6a87b --- /dev/null +++ b/src/sidebar/sidebar.mdx @@ -0,0 +1,353 @@ +import { + ArgTypes, + Canvas, + Controls, + Description, + Markdown, + Meta, + Subtitle, + Title, +} from '@storybook/addon-docs/blocks' + +import { Sidebar, SidebarContent, SidebarPersistentContent, SidebarResizeHandle } from './sidebar' +import * as SidebarStories from './sidebar.stories' + + + + + +<Subtitle>A controlled, composable, and accessible sidebar primitive</Subtitle> + +The `Sidebar` component is a resizable panel that supports being docked on the left or right, or rendered as an overlay. It's fully controlled, so the +consumer owns the breakpoints and the state of the panel. + +## Basic usage + +To define a sidebar, render it in a flex parent. Use `align` to determine the side it's docked, `isOpen` to control its open state, and `width` for its default size. + +Use `<SidebarContent>` to host your styled panel, and a `<SidebarPersistentContent>` as its child to hold content that need to remain visible and interactive when the panel is closed. + +<Canvas of={SidebarStories.CollapsibleNav} /> + +## Anatomy + +A sidebar is comprised of the following components: + +<Markdown>{` +| Component | Renders | Responsibilities | +| --- | --- | --- | +| \`\<Sidebar\>\` | The main provider and the modal backdrop | Configuration options like \`width\`, \`resizeStep\`, \`overlayMode\`, and events like \`onDismiss\` | +| \`\<SidebarContent\>\` | The panel element, with \`role="dialog"\` when needed | The panel's size and position, transitions, dialog and modal behaviour | +| \`\<SidebarPersistentContent\>\` | An optional slot inside the panel | Hosts UI elements within the sidebar that need to be interactive while the sidebar is closed (e.g. a collapse toggle) | +| \`\<SidebarResizeHandle\>\` | An element with \`role="separator"\` | Enables pointer and keyboard resize | +`}</Markdown> + +Place the `<Sidebar>` in a flex parent, provide it the controlled `width` and `isOpen` props, and ensure that its sibling is able to absorb and yield space when the sidebar resizes. + +- **Parent**: When using a `Box`, assign `display="flex"` +- **Sibling content**: Assign `flexGrow={1}`, and `minWidth={0}`, as `<Box>` already defaults to `flex-shrink: 1` +- **State and persistence**: The open and overlay states are controlled, and persisted if required by the consumer +- **The visual skin**: The component is headless, and only controls the container's width and transition, which can be further customized. The panel is styled through the children passed into `<SidebarContent>` + +```tsx +const isOverlay = viewportWidth < maxNavWidth + minContentWidth + +<Box display="flex" overflow="hidden"> + <Sidebar align="start" isOpen={isOpen} isOverlay={isOverlay} overlayMode="modal" width={width} onWidthChange={setWidth}> + <SidebarContent aria-label="Main navigation"> + <Box as="nav" aria-label="Main navigation" height="full"> + {/* nav content */} + <SidebarResizeHandle aria-label="Resize sidebar" /> + </Box> + </SidebarContent> + </Sidebar> + <Box as="main" flexGrow={1} minWidth={0}> + {/* main content */} + </Box> +</Box> +``` + +## Props + +### `<Sidebar>` + +<Description of={Sidebar} /> + +<ArgTypes of={Sidebar} /> + +### `<SidebarContent>` + +<Description of={SidebarContent} /> + +<ArgTypes of={SidebarContent} /> + +### `<SidebarPersistentContent>` + +<Description of={SidebarPersistentContent} /> + +### `<SidebarResizeHandle>` + +<Description of={SidebarResizeHandle} /> + +<ArgTypes of={SidebarResizeHandle} /> + +## Resizing + +Render a `SidebarResizeHandle` to make the panel resizable. The handle sits on +the inner edge (right for `align="start"`, left for `align="end"`), drives a +render-free pointer drag, and commits the width through `onWidthChange` on +pointer up. Width is controlled by the consumer. + +A resizable sidebar needs `width`, `minWidth`, and `maxWidth` on `Sidebar` (with +`minWidth` below `maxWidth`), plus `resizeStep` for keyboard steps and +`defaultWidth` for the double-click reset. Without a usable range the handle +renders and focuses but cannot move, and logs a development warning. + +<Canvas of={SidebarStories.Resizable} /> + +Keyboard support, from the separator: + +- **Arrow keys** resize by `resizeStep`, edge-aware: with `align="start"` the + handle is on the right, so ArrowRight grows and ArrowLeft shrinks; `align="end"` + reverses it. Omitting `resizeStep` disables arrow stepping (Home/End still + work). +- **Home / End** jump to `minWidth` and `maxWidth`, also edge-aware: `align="end"` + swaps them, so Home jumps to `maxWidth`. +- **Double-click** resets to `defaultWidth`. +- There is no Enter / Space and no PageUp / PageDown: it is a separator, not a + button. Escape-to-dismiss belongs to the panel, not the handle. + +Resize direction is not RTL-aware. The layout mirrors under `dir="rtl"` (anchor +edge, off-edge slide, and handle edge), but the pointer and keyboard resize +direction assumes LTR, so a drag or arrow key resizes the opposite way. RTL is not +officially supported; treat this as a known limitation. + +`onWidthChange` fires on pointer up, on each keystroke during keyboard resize, and +on a double-click reset, so debounce persistence if it is expensive. A drag that +does not move the handle does not fire it. A consumer that ignores it keeps the +dragged preview width on screen (the drag writes it imperatively; +nothing overrides it until `width` changes). `aria-valuenow` and `aria-valuetext` +update on each keystroke and on commit after a drag (not mid-drag). Pass +`aria-valuetext` to localize the announced width; it defaults to `"{width}px"`. + +## Overlays + +Two props describe an overlay. `isOverlay` is dynamic: the consumer computes it +from a breakpoint, and `false` keeps the panel docked in flow. `overlayMode` is +static: it sets what the overlay is while floating. + +<Markdown>{` +| \`overlayMode\` | role=dialog | aria-modal | focus trap | Background | Backdrop | +| --- | --- | --- | --- | --- | --- | +| \`plain\` (default) | – | – | – | interactive | none | +| \`dialog\` | yes | – | – | interactive | none | +| \`modal\` | yes | yes | yes | inert / AT-hidden (auto) | auto-rendered | +`}</Markdown> + +The backdrop is not a component: `Sidebar` renders it automatically for +`overlayMode="modal"`, dims and blocks the page, and dismisses on click. A +non-modal overlay closes through its own control plus Escape; it renders no +backdrop and leaves the background interactive. + +### Element and role + +`SidebarContent` takes the `dialog` role when it is an open overlay and +`overlayMode` is `dialog` or `modal`, otherwise it has no landmark role of its +own. Nest your landmark element as a child and name it there: + +<Markdown>{` +| Intent | Landmark child | +| --- | --- | +| Top-level sidebar (peer of \`main\`) | \`<aside aria-label>\` (a \`complementary\` landmark) | +| Main navigation | \`<nav aria-label>\` | +| Named nested pane | \`<section aria-labelledby>\` (a \`region\`) | +`}</Markdown> + +Name the dialog on `SidebarContent`: prefer `aria-labelledby` pointing at a +visible heading in the panel when there is one, or `aria-label` otherwise. + +### Modal drawer + +The full modal overlay from the table above; because it inerts the background +itself, the shell no longer marks `main`. + +<Canvas of={SidebarStories.ModalDrawer} /> + +### Non-modal dialog side pane + +An end-aligned contextual pane that floats but leaves the background interactive +(no backdrop). It closes through its own control plus Escape. The rounded card +skin is a child with `overflow: hidden`, so the resize handle on the panel edge +stays outside the clip. The card is inset from the edges with the overlay inset +custom properties. + +A nonzero `--reactist-sidebar-overlay-inset-inline` stays on screen as a sliver +while the pane is closed: the panel slides off by its own width from the inset +anchor, leaving the inset gap visible (toggle the demo closed to see it). Keep the +inline inset at `0` for a fully off-screen close. + +<Canvas of={SidebarStories.DialogSidePane} /> + +### One modal overlay at a time + +Keep only one `overlayMode="modal"` sidebar open at once. A modal overlay inerts +everything outside its own panel and lays a backdrop over the page, so two open +together inert each other's panels and stack backdrops, leaving neither usable. +`Sidebar` has no cross-sidebar coordinator and the consumer owns `isOpen`, so +holding to a single open modal is the consumer's responsibility. + +The same holds across breakpoint changes: default a modal sidebar to closed when +it enters overlay mode rather than carrying an open state across the breakpoint +(the app navs already behave this way). Derive the open state from the layout, so +a docked panel is always shown and only the overlay obeys the toggle, as the +multi-sidebar example below does: + +```tsx +// Docked: always in view. Overlay: toggled by the trigger (closed until opened). +const open = isOverlay ? isOpen : true +``` + +## The toggle trigger + +Reactist ships no trigger. The toggle is the consumer's own button, wired by +hand, so it can live anywhere (a top bar, a command palette) without hoisting the +provider. Set the same `id` on `Sidebar` that the trigger points its +`aria-controls` at. + +```tsx +<button aria-expanded={isOpen} aria-controls="sidebar" onClick={toggle}> + {/* your icon / tooltip */} +</button> +``` + +## A control that stays live while closed + +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 +collapse toggle in the demo at the top of this page is the canonical case: it +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. + +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. + +Do not pair it with `unmountOnHide` on `<Sidebar>`: that unmounts the whole panel +(this control included) at the end of the close transition, so nothing is left to +stay operable while closed. The two contradict each other, and the component +warns when both are set. + +```tsx +<SidebarContent aria-label="Main navigation"> + <SidebarPersistentContent> + <button aria-expanded={isOpen} aria-controls="sidebar" data-no-autofocus onClick={toggle}> + {/* your icon / tooltip */} + </button> + </SidebarPersistentContent> + <nav aria-label="Main navigation">{/* nav content */}</nav> +</SidebarContent> +``` + +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, or `data-autofocus` to send initial focus to a +specific element; the control stays tabbable either way. `data-no-autofocus` is +subtree-scoped, so setting it on a wrapping element opts the whole panel out of +autofocus (focus stays on the trigger). The consumer owns the control, its +`aria-*`, and its positioning (e.g. a peek offset while collapsed). + +## Multiple sidebars + +Several sidebars compose in one flex shell. Tile them on the same side (a +workspace rail beside its nav) or place them on both edges (`align="start"` and +`align="end"` are the same component mirrored). Each is a `flex-shrink: 0` child +and the main content absorbs the rest, so every pane resizes from its inner edge +independently. + +Responsiveness is per sidebar: the consumer computes each panel's `isOverlay` from +its own breakpoint (a container query or a `ResizeObserver`), so a layout can drop +its panels to overlays at different widths as the viewport narrows. The demo docks +a workspace rail, a main nav, and a right details pane, then flips the details +pane to an overlay first (it needs the most room) and the nav next; the rail stays +docked. Resize the canvas to cross the breakpoints. + +<Canvas of={SidebarStories.ResponsiveShell} /> + +## Playground + +Switch alignment, overlay mode, and resizability live, and drag or keyboard-resize +the panel. The controls below map to every `<Sidebar>` prop. + +<Canvas of={SidebarStories.Playground} /> + +### API + +<Controls of={SidebarStories.Playground} /> + +## Custom properties + +The component ships sensible defaults and exposes per-instance theming through +CSS custom properties, set on `SidebarContent` (or any ancestor). + +<Markdown>{` +| Property | Default | Purpose | +| --- | --- | --- | +| \`--reactist-sidebar-overlay-z-index\` | \`40\` | Layering of a floating panel | +| \`--reactist-sidebar-collapsed-z-index\` | \`1\` | Layering of a collapsed docked panel, so a persistent control paints above the main content | +| \`--reactist-sidebar-overlay-min-viewport-gap\` | \`0px\` | Gap kept from the viewport edge by the overlay width cap | +| \`--reactist-sidebar-overlay-inset-block\` | \`0px\` | Top and bottom inset of a floating panel (shorthand) | +| \`--reactist-sidebar-overlay-inset-block-start\` | \`-inset-block\` | Top inset alone, e.g. below a title bar; unset, it falls back to the \`-inset-block\` shorthand | +| \`--reactist-sidebar-overlay-inset-block-end\` | \`-inset-block\` | Bottom inset alone; unset, it falls back to the \`-inset-block\` shorthand | +| \`--reactist-sidebar-overlay-inset-inline\` | \`0px\` | Inset of the anchored edge; also subtracted from the overlay width cap | +| \`--reactist-sidebar-backdrop-color\` | \`#000000\` | Modal backdrop color | +| \`--reactist-sidebar-backdrop-opacity\` | \`0.5\` | Modal backdrop opacity | +| \`--reactist-sidebar-backdrop-z-index\` | \`39\` | Modal backdrop layering | +| \`--reactist-sidebar-resize-handle-width\` | \`4px\` | Handle hit-area width | +| \`--reactist-sidebar-resize-handle-idle-fill\` | \`transparent\` | Handle color at rest | +| \`--reactist-sidebar-resize-handle-hover-fill\` | divider | Handle color on hover | +| \`--reactist-sidebar-resize-handle-focus-fill\` | primary | Handle color on focus | +| \`--reactist-sidebar-transition-duration\` | \`300ms\` | Panel slide and backdrop fade duration (shared) | +| \`--reactist-sidebar-transition-easing\` | \`cubic-bezier(0.4, 0, 0.2, 1)\` | Panel slide and backdrop fade easing (shared) | +`}</Markdown> + +## Data attributes + +The component sets these `data-*` attributes as part of the public contract, so +you can style or target them in tests. + +<Markdown>{` +| Element | Attribute | Values | +| --- | --- | --- | +| Panel (\`SidebarContent\`) | \`data-state\` | \`open\` / \`closed\` | +| Panel | \`data-align\` | \`start\` / \`end\` | +| Panel | \`data-overlay\` | \`true\` / \`false\` | +| Resize handle | \`data-align\` | \`start\` / \`end\` | +| Backdrop | \`data-state\` | \`open\` / \`closed\` | +| Backdrop | \`data-testid\` | \`sidebar-backdrop\` | +`}</Markdown> + +The panel and handle also forward any `data-*` you pass; the handle's own +`data-align` is applied last and wins. + +## Accessibility + +A checklist; each item links to the section with the full treatment. + +- A floating `dialog` / `modal` panel becomes a `dialog`; name it with + `aria-label` / `aria-labelledby` on `SidebarContent` (see + [Element and role](#element-and-role)). +- The panel is a neutral container; its content must define the landmark. +- A `modal` overlay traps focus, sets `aria-modal`, returns focus to the trigger + on close, and inerts everything outside the panel and backdrop (see + [Modal drawer](#modal-drawer)). +- Escape dismisses an open overlay when `dismissOverlayOnEscape` is set, only + while focus is in the panel, and respects `event.defaultPrevented` (see + [Overlays](#overlays)). +- The resize handle is a focusable `separator` while open and leaves the tab + order and accessibility tree while closed (see [Resizing](#resizing)). +- Closed contents are `inert`, except a control in + [`SidebarPersistentContent`](#a-control-that-stays-live-while-closed); when the + panel opens as a modal, add `data-no-autofocus` to keep initial focus off it. diff --git a/src/sidebar/sidebar.module.css b/src/sidebar/sidebar.module.css new file mode 100644 index 00000000..6d64beb9 --- /dev/null +++ b/src/sidebar/sidebar.module.css @@ -0,0 +1,200 @@ +:root { + /* Overlay layering and viewport behaviour. */ + --reactist-sidebar-overlay-z-index: 40; + --reactist-sidebar-collapsed-z-index: 1; + --reactist-sidebar-overlay-min-viewport-gap: 0px; + --reactist-sidebar-overlay-inset-block: 0px; + /* + * `-block` is the shorthand; the per-edge `-block-start`/`-block-end` + * longhands are read at the point of use below and fall back to it, so a + * consumer can inset a single edge (e.g. below a top title bar) or both via + * the shorthand. The longhands are deliberately not declared here: resolving + * their fallback at :root would bake in the shorthand's :root value and a + * consumer override set lower (on the panel or an ancestor) would not take. + * Inline stays a single `-inline`: the overlay anchors to one inline edge and + * is width-sized, so only that edge is a positional inset. + */ + --reactist-sidebar-overlay-inset-inline: 0px; + + /* Modal backdrop. */ + --reactist-sidebar-backdrop-color: #000000; + --reactist-sidebar-backdrop-opacity: 0.5; + --reactist-sidebar-backdrop-z-index: 39; + + /* Resize handle. `fill` is the line colour; the state prefix selects idle / hover / focus. */ + --reactist-sidebar-resize-handle-width: 4px; + --reactist-sidebar-resize-handle-idle-fill: transparent; + --reactist-sidebar-resize-handle-hover-fill: var(--reactist-divider-primary); + --reactist-sidebar-resize-handle-focus-fill: var(--reactist-actionable-primary-idle-fill); + + --reactist-sidebar-transition-duration: 300ms; + --reactist-sidebar-transition-easing: cubic-bezier(0.4, 0, 0.2, 1); +} + +.panel { + position: relative; + box-sizing: border-box; + width: var(--reactist-sidebar-width); + /* + * `contain: layout` bounds the collapse reflow without `paint`, so consumer + * popovers, focus rings, the resize handle, and an overlay card's shadow are + * never clipped to the panel box. + */ + contain: layout; +} + +/* + * Docked: the panel is an in-flow flex child that holds its width (`flex-shrink` + * comes from the Box prop). Collapsing slides it out with a negative inline + * margin so the main absorber reflows into the freed space. + */ +.panel[data-overlay='false'] { + height: 100%; + transition: margin var(--reactist-sidebar-transition-duration) + var(--reactist-sidebar-transition-easing); +} + +.panel[data-overlay='false'][data-state='closed'][data-align='start'] { + margin-inline-start: calc(-1 * var(--reactist-sidebar-width, 0px)); +} + +.panel[data-overlay='false'][data-state='closed'][data-align='end'] { + margin-inline-end: calc(-1 * var(--reactist-sidebar-width, 0px)); +} + +/* + * Collapsed + docked, the panel's own `contain: layout` traps a persistent + * control's z-index in the panel's stacking context, so it paints under a main + * that is itself a stacking context. Lift the panel to keep the control visible. + */ +.panel[data-overlay='false'][data-state='closed'] { + z-index: var(--reactist-sidebar-collapsed-z-index); +} + +/* + * Overlay: the panel floats over the content layer, anchored to its align edge + * and capped to the viewport. It slides off-edge with a compositor-only + * transform. Box geometry (insets, rounding, skin) is the consumer's; the inset + * custom properties let them inset a floating card without a specificity fight. + */ +.panel[data-overlay='true'] { + position: fixed; + inset-block-start: var( + --reactist-sidebar-overlay-inset-block-start, + var(--reactist-sidebar-overlay-inset-block) + ); + inset-block-end: var( + --reactist-sidebar-overlay-inset-block-end, + var(--reactist-sidebar-overlay-inset-block) + ); + z-index: var(--reactist-sidebar-overlay-z-index); + max-width: calc( + 100vw - var(--reactist-sidebar-overlay-inset-inline) - + var(--reactist-sidebar-overlay-min-viewport-gap) + ); + transition: transform var(--reactist-sidebar-transition-duration) + var(--reactist-sidebar-transition-easing); +} + +.panel[data-overlay='true'][data-align='start'] { + inset-inline-start: var(--reactist-sidebar-overlay-inset-inline); +} + +.panel[data-overlay='true'][data-align='end'] { + inset-inline-end: var(--reactist-sidebar-overlay-inset-inline); +} + +.panel[data-overlay='true'][data-state='closed'][data-align='start'] { + transform: translateX(-100%); +} + +.panel[data-overlay='true'][data-state='closed'][data-align='end'] { + transform: translateX(100%); +} + +/* + * `translateX` is physical, so flip the off-edge slide under RTL to stay aligned + * with the logical `inset-inline` anchor above. + */ +[dir='rtl'] .panel[data-overlay='true'][data-state='closed'][data-align='start'] { + transform: translateX(100%); +} + +[dir='rtl'] .panel[data-overlay='true'][data-state='closed'][data-align='end'] { + transform: translateX(-100%); +} + +/* + * Layout-neutral wrapper for the focus trap: it must not establish a positioning + * or stacking context, so the absolutely-positioned resize handle still resolves + * against the panel. + */ +.focusLock { + display: contents; +} + +.persistentContent, +.inertContent { + display: contents; +} + +.backdrop { + position: fixed; + inset: 0; + z-index: var(--reactist-sidebar-backdrop-z-index); + background-color: var(--reactist-sidebar-backdrop-color); + opacity: 0; + pointer-events: none; + transition: opacity var(--reactist-sidebar-transition-duration) + var(--reactist-sidebar-transition-easing); +} + +.backdrop[data-state='open'] { + opacity: var(--reactist-sidebar-backdrop-opacity); + pointer-events: auto; +} + +.resizeHandle { + position: absolute; + inset-block: 0; + z-index: 1; + width: var(--reactist-sidebar-resize-handle-width); + background-color: var(--reactist-sidebar-resize-handle-idle-fill); + cursor: col-resize; + touch-action: none; + transition: background-color 120ms ease; +} + +.resizeHandle[data-align='start'] { + inset-inline-end: 0; +} + +.resizeHandle[data-align='end'] { + inset-inline-start: 0; +} + +.resizeHandle:hover { + background-color: var(--reactist-sidebar-resize-handle-hover-fill); +} + +.resizeHandle:focus-visible { + background-color: var(--reactist-sidebar-resize-handle-focus-fill); + outline: none; +} + +/* The closed handle is `aria-hidden`; drop pointer events so it can't be grabbed. */ +.resizeHandle[aria-hidden='true'] { + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + :root { + --reactist-sidebar-transition-duration: 0s; + } + + .panel, + .backdrop, + .resizeHandle { + transition: none !important; + } +} diff --git a/src/sidebar/sidebar.stories.tsx b/src/sidebar/sidebar.stories.tsx new file mode 100644 index 00000000..a8b9f14d --- /dev/null +++ b/src/sidebar/sidebar.stories.tsx @@ -0,0 +1,643 @@ +import * as React from 'react' + +import { Box, Button, Divider, Heading, IconButton, Stack, Text } from '../index' + +import { Sidebar, SidebarContent, SidebarPersistentContent, SidebarResizeHandle } from './sidebar' + +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { SidebarAlign, SidebarOverlayMode } from './sidebar' + +const NAV_ITEMS = ['Inbox', 'Today', 'Upcoming', 'Filters & Labels', 'Projects', 'Team'] + +const DETAIL_ITEMS = ['Description', 'Sub-tasks', 'Comments', 'Activity', 'Attachments'] + +function DemoNav({ + title = 'Workspace', + navItems = NAV_ITEMS, + as = 'nav', + 'aria-label': ariaLabel, + children, +}: { + title?: string + navItems?: string[] + as?: React.ComponentProps<typeof Box>['as'] + 'aria-label'?: string + children?: React.ReactNode +}) { + return ( + <Box as={as} aria-label={ariaLabel} background="aside" height="full"> + <Box paddingLeft="large" paddingTop="large"> + <Heading level={2}>{title}</Heading> + </Box> + <Stack paddingY="medium" paddingX="xsmall"> + {navItems.map((item) => { + return ( + <Button variant="quaternary" key={item} width="full" align="start"> + {item} + </Button> + ) + })} + </Stack> + {children} + </Box> + ) +} + +function DemoRail({ + 'aria-label': ariaLabel, + children, +}: { + 'aria-label'?: string + children?: React.ReactNode +}) { + return ( + <Box as="nav" aria-label={ariaLabel} background="aside" height="full"> + <Stack space="small" padding="small" align="center"> + {['T', 'C', 'A'].map((label) => ( + <Box + key={label} + display="flex" + alignItems="center" + justifyContent="center" + style={{ + width: 36, + height: 36, + borderRadius: 8, + background: 'var(--reactist-divider-secondary)', + }} + > + <Text weight="semibold" size="caption"> + {label} + </Text> + </Box> + ))} + </Stack> + {children} + </Box> + ) +} + +const CARD_INSET_OVERRIDES = { + '--reactist-sidebar-overlay-inset-block': '12px', + '--reactist-sidebar-overlay-inset-inline': '12px', +} as React.CSSProperties + +function useShellWidth<T extends HTMLElement>() { + const shellRef = React.useRef<T>(null) + const [width, setWidth] = React.useState(Infinity) + + React.useEffect(function observeShellWidth() { + const node = shellRef.current + if (!node) return + + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + if (entry) setWidth(entry.contentRect.width) + }) + observer.observe(node) + return () => observer.disconnect() + }, []) + + return [shellRef, width] as const +} + +const meta = { + title: '🧭 Navigation & structure/Sidebar', + component: Sidebar, + parameters: { + badges: ['accessible'], + figma: { + path: 'Web › Components / Todoist › Sidebar › Main Navigation / Sidebar', + url: 'https://www.figma.com/design/LYlWNzvhMDh907l07mPPQk/Product-Library---Web?node-id=1194-17741', + }, + docs: { source: { type: 'dynamic' } }, + }, + decorators: [ + (Story: () => React.JSX.Element) => ( + <Box + position="relative" + overflow="hidden" + border="secondary" + borderRadius="standard" + background="default" + style={{ + // Set as containing block for the overlay + transform: 'translateZ(0)', + height: 420, + }} + > + <Story /> + </Box> + ), + ], +} satisfies Meta<typeof Sidebar> + +export default meta + +type Story = StoryObj<typeof meta> + +function SidebarToggleIcon() { + return ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> + <rect + x="1.75" + y="2.75" + width="12.5" + height="10.5" + rx="1.5" + stroke="currentColor" + strokeWidth="1.5" + /> + <line x1="6" x2="6" y1="3" y2="13" stroke="currentColor" strokeWidth="1.5" /> + </svg> + ) +} + +SidebarToggleIcon.displayName = 'SidebarToggleIcon' + +/** Docked nav with its collapse toggle in `<SidebarPersistentContent>`, kept reachable while collapsed. */ +export const CollapsibleNav = { + render: function CollapsibleNav() { + const [isOpen, setIsOpen] = React.useState(true) + + return ( + <Box display="flex" height="full"> + <Sidebar id="collapsible-nav" align="start" isOpen={isOpen} width={260}> + <SidebarContent> + <SidebarPersistentContent> + <div + style={{ + position: 'absolute', + top: 8, + right: 8, + zIndex: 2, + // Switch the toggle between its in-panel position and outside of it when collapsed. + // Transition at the same velocity as the panel so it appears as part of the same animation + transition: + 'margin-right var(--reactist-sidebar-transition-duration) var(--reactist-sidebar-transition-easing)', + marginRight: isOpen ? 0 : -45, + }} + > + <IconButton + variant="quaternary" + icon={<SidebarToggleIcon />} + aria-label={isOpen ? 'Collapse sidebar' : 'Open sidebar'} + aria-controls="collapsible-nav" + aria-expanded={isOpen} + onClick={() => setIsOpen((open) => !open)} + /> + </div> + </SidebarPersistentContent> + <DemoNav aria-label="Main navigation" /> + </SidebarContent> + </Sidebar> + <Box + as="main" + flexGrow={1} + minWidth={0} + paddingY="medium" + paddingX="xxlarge" + overflow="auto" + > + <Stack space="medium" paddingLeft="small"> + <Heading level="2" size="larger"> + Main content + </Heading> + <Text tone="secondary"> + Collapse the nav with the toggle in its header. While collapsed the + panel slides away and the toggle peeks at the edge, staying reachable to + reopen. + </Text> + </Stack> + </Box> + </Box> + ) + }, +} satisfies Story + +/** Docked nav with a `<SidebarResizeHandle>`: pointer drag plus keyboard resize, width committed via `onWidthChange`. */ +export const Resizable = { + render: function Resizable() { + const [width, setWidth] = React.useState(280) + + return ( + <Box display="flex" height="full"> + <Sidebar + id="resizable-nav" + align="start" + isOpen + width={width} + onWidthChange={setWidth} + minWidth={210} + maxWidth={400} + defaultWidth={280} + resizeStep={24} + > + <SidebarContent> + <DemoNav aria-label="Main navigation"> + <SidebarResizeHandle + aria-label="Resize sidebar" + data-testid="sidebar-resize-handle" + /> + </DemoNav> + </SidebarContent> + </Sidebar> + <Box as="main" flexGrow={1} minWidth={0} padding="large" overflow="auto"> + <Text> + Drag the handle on the sidebar's right edge, or focus it and use the arrow + keys, Home / End, or double-click to reset. Current width:{' '} + <Text as="span" weight="semibold"> + {width}px + </Text> + . + </Text> + </Box> + </Box> + ) + }, +} satisfies Story + +/** Modal overlay drawer: floats, traps focus, dims, and dismisses on the backdrop or Escape. */ +export const ModalDrawer = { + render: function ModalDrawer() { + const [isOpen, setIsOpen] = React.useState(false) + + return ( + <Box display="flex" height="full"> + <Sidebar + id="modal-nav" + align="start" + isOverlay + overlayMode="modal" + isOpen={isOpen} + dismissOverlayOnEscape + onDismiss={() => setIsOpen(false)} + width={260} + > + {/* The panel forwards keydowns to this handler after its own Escape + dismiss; a consumer that wants Escape kept from app-level handlers + (e.g. a global hotkey) stops propagation here. */} + <SidebarContent + aria-label="Primary navigation" + onKeyDown={(event) => { + if (event.key === 'Escape') event.stopPropagation() + }} + > + <DemoNav aria-label="Primary navigation" /> + </SidebarContent> + </Sidebar> + <Box as="main" flexGrow={1} minWidth={0} padding="large" overflow="auto"> + <Stack space="medium"> + <Button + variant="primary" + aria-expanded={isOpen} + aria-controls="modal-nav" + onClick={() => setIsOpen(true)} + > + Open menu + </Button> + <Text tone="secondary"> + Open the drawer, then dismiss it with the backdrop or the Escape key. + Focus is trapped inside the drawer while it is open. + </Text> + </Stack> + </Box> + </Box> + ) + }, +} satisfies Story + +/** End-aligned non-modal dialog pane, modelled on a contextual chat, with a rounded inset card skin. */ +export const DialogSidePane = { + render: function DialogSidePane() { + const [isOpen, setIsOpen] = React.useState(true) + const [width, setWidth] = React.useState(340) + + return ( + <Box display="flex" height="full"> + <Box as="main" flexGrow={1} minWidth={0} padding="large" overflow="auto"> + <Stack space="medium"> + <Button + variant="secondary" + aria-expanded={isOpen} + aria-controls="chat-pane" + onClick={() => setIsOpen((open) => !open)} + > + {isOpen ? 'Hide assistant' : 'Show assistant'} + </Button> + <Text tone="secondary"> + The pane floats over this content but leaves it interactive: you can + keep clicking here while it is open. + </Text> + </Stack> + </Box> + <Sidebar + id="chat-pane" + align="end" + isOverlay + overlayMode="dialog" + isOpen={isOpen} + dismissOverlayOnEscape + onDismiss={() => setIsOpen(false)} + width={width} + onWidthChange={setWidth} + minWidth={280} + maxWidth={460} + defaultWidth={340} + resizeStep={24} + > + <SidebarContent + aria-labelledby="chat-pane-heading" + style={CARD_INSET_OVERRIDES} + > + <Box + display="flex" + flexDirection="column" + position="relative" + height="full" + background="default" + borderRadius="full" + border="secondary" + overflow="hidden" + > + <Box + display="flex" + justifyContent="spaceBetween" + alignItems="center" + padding="medium" + > + <Heading level="2" size="smaller" id="chat-pane-heading"> + Assistant + </Heading> + <Button + variant="tertiary" + size="small" + onClick={() => setIsOpen(false)} + > + Close + </Button> + </Box> + <Divider weight="secondary" /> + <Box padding="medium" overflow="auto" flexGrow={1}> + <Text tone="secondary"> + Conversation history lives here. Drag the handle on the left + edge to resize the pane. + </Text> + </Box> + {/* Inside the rounded skin: the border-radius crops the handle, the + way the Automations chat does it. Placing it as a sibling of the + skin instead keeps it on the panel edge, uncropped. */} + <SidebarResizeHandle aria-label="Resize assistant" /> + </Box> + </SidebarContent> + </Sidebar> + </Box> + ) + }, +} satisfies Story + +/** + * Three sidebars in one shell: a workspace rail and a main nav on the left, a + * details pane on the right, each with its own breakpoint. As the canvas narrows + * the details pane overlays first (it needs the most room), the nav next, and the + * rail stays docked. + */ +export const ResponsiveShell = { + render: function ResponsiveShell() { + const [shellRef, shellWidth] = useShellWidth<HTMLDivElement>() + const navIsOverlay = shellWidth < 640 + const paneIsOverlay = shellWidth < 900 + + const [navOpen, setNavOpen] = React.useState(false) + const [paneOpen, setPaneOpen] = React.useState(false) + const [railWidth, setRailWidth] = React.useState(64) + const [navWidth, setNavWidth] = React.useState(240) + const [paneWidth, setPaneWidth] = React.useState(300) + + // Docked panels stay in view; overlays start closed and obey their trigger. + const isNavOpen = navIsOverlay ? navOpen : true + const isPaneOpen = paneIsOverlay ? paneOpen : true + + return ( + <Box display="flex" height="full" ref={shellRef}> + <Sidebar + id="rs-rail" + align="start" + isOpen + width={railWidth} + onWidthChange={setRailWidth} + minWidth={56} + maxWidth={120} + defaultWidth={64} + resizeStep={8} + > + <SidebarContent aria-label="Workspaces"> + <DemoRail aria-label="Workspaces"> + <SidebarResizeHandle aria-label="Resize workspace rail" /> + </DemoRail> + </SidebarContent> + </Sidebar> + <Sidebar + id="rs-nav" + align="start" + isOverlay={navIsOverlay} + overlayMode="modal" + isOpen={isNavOpen} + dismissOverlayOnEscape + onDismiss={() => setNavOpen(false)} + width={navWidth} + onWidthChange={setNavWidth} + minWidth={200} + maxWidth={320} + defaultWidth={240} + resizeStep={20} + > + <SidebarContent aria-label="Main navigation"> + <DemoNav aria-label="Main navigation"> + <SidebarResizeHandle aria-label="Resize navigation" /> + </DemoNav> + </SidebarContent> + </Sidebar> + <Box as="main" flexGrow={1} minWidth={0} padding="large" overflow="auto"> + <Stack space="medium"> + <Box display="flex" gap="small"> + {navIsOverlay ? ( + <Button + variant="secondary" + aria-expanded={navOpen} + aria-controls="rs-nav" + onClick={() => setNavOpen(true)} + > + Open nav + </Button> + ) : null} + {paneIsOverlay ? ( + <Button + variant="secondary" + aria-expanded={paneOpen} + aria-controls="rs-pane" + onClick={() => setPaneOpen((open) => !open)} + > + {paneOpen ? 'Hide details' : 'Show details'} + </Button> + ) : null} + </Box> + <Heading level="2" size="larger"> + Main content + </Heading> + <Text tone="secondary"> + Three sidebars in one shell. Resize the canvas: the details pane becomes + an overlay below 900px and the nav below 640px, each with its own + trigger, while the workspace rail stays docked. + </Text> + </Stack> + </Box> + <Sidebar + id="rs-pane" + align="end" + isOverlay={paneIsOverlay} + overlayMode="dialog" + isOpen={isPaneOpen} + dismissOverlayOnEscape + onDismiss={() => setPaneOpen(false)} + width={paneWidth} + onWidthChange={setPaneWidth} + minWidth={260} + maxWidth={420} + defaultWidth={300} + resizeStep={20} + > + {/* Keep the details pane below the modal nav and its backdrop. */} + <SidebarContent + aria-label="Details" + style={{ '--reactist-sidebar-overlay-z-index': 30 } as React.CSSProperties} + > + <DemoNav + title="Details" + as="aside" + aria-label="Details" + navItems={DETAIL_ITEMS} + > + <SidebarResizeHandle aria-label="Resize details pane" /> + </DemoNav> + </SidebarContent> + </Sidebar> + </Box> + ) + }, +} satisfies Story + +type PlaygroundArgs = { + align: SidebarAlign + isOverlay: boolean + overlayMode: SidebarOverlayMode + isOpen: boolean + width: number + resizable: boolean + dismissOverlayOnEscape: boolean + unmountOnHide: boolean +} + +export const Playground = { + args: { + align: 'start', + isOverlay: false, + overlayMode: 'plain', + isOpen: true, + width: 280, + resizable: true, + dismissOverlayOnEscape: true, + unmountOnHide: false, + }, + argTypes: { + align: { control: { type: 'inline-radio' }, options: ['start', 'end'] }, + overlayMode: { control: { type: 'inline-radio' }, options: ['plain', 'dialog', 'modal'] }, + isOverlay: { control: { type: 'boolean' } }, + isOpen: { control: { type: 'boolean' } }, + width: { control: { type: 'range', min: 210, max: 400, step: 10 } }, + resizable: { control: { type: 'boolean' } }, + dismissOverlayOnEscape: { control: { type: 'boolean' } }, + unmountOnHide: { control: { type: 'boolean' } }, + }, + render: function Playground({ + align, + isOverlay, + overlayMode, + isOpen: isOpenArg, + width: widthArg, + resizable, + dismissOverlayOnEscape, + unmountOnHide, + }: PlaygroundArgs) { + const [isOpen, setIsOpen] = React.useState(isOpenArg) + const [width, setWidth] = React.useState(widthArg) + const [args, setArgs] = React.useState({ isOpenArg, widthArg }) + + // Sync the local interactive state to the controls during render (not in an + // effect) when the args change. + if (args.isOpenArg !== isOpenArg || args.widthArg !== widthArg) { + setArgs({ isOpenArg, widthArg }) + setIsOpen(isOpenArg) + setWidth(widthArg) + } + + const sidebar = ( + <Sidebar + id="playground-sidebar" + align={align} + isOverlay={isOverlay} + overlayMode={overlayMode} + isOpen={isOpen} + dismissOverlayOnEscape={dismissOverlayOnEscape} + unmountOnHide={unmountOnHide} + onDismiss={() => setIsOpen(false)} + width={width} + onWidthChange={setWidth} + minWidth={210} + maxWidth={400} + defaultWidth={280} + resizeStep={24} + > + <SidebarContent aria-label="Playground sidebar"> + <DemoNav aria-label="Playground sidebar"> + {resizable ? <SidebarResizeHandle aria-label="Resize sidebar" /> : null} + </DemoNav> + </SidebarContent> + </Sidebar> + ) + + const main = ( + <Box as="main" flexGrow={1} minWidth={0} padding="large" overflow="auto"> + <Stack space="medium"> + <Button + variant="primary" + aria-expanded={isOpen} + aria-controls="playground-sidebar" + onClick={() => setIsOpen((open) => !open)} + > + {isOpen ? 'Close sidebar' : 'Open sidebar'} + </Button> + <Text tone="secondary"> + Use the controls to switch alignment, overlay mode, and resizability. + </Text> + </Stack> + </Box> + ) + + return ( + <Box display="flex" height="full"> + {align === 'start' ? ( + <> + {sidebar} + {main} + </> + ) : ( + <> + {main} + {sidebar} + </> + )} + </Box> + ) + }, +} satisfies StoryObj<PlaygroundArgs> diff --git a/src/sidebar/sidebar.test.tsx b/src/sidebar/sidebar.test.tsx new file mode 100644 index 00000000..0d877b8f --- /dev/null +++ b/src/sidebar/sidebar.test.tsx @@ -0,0 +1,673 @@ +import * as React from 'react' + +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'jest-axe' + +import { Sidebar, SidebarContent, SidebarPersistentContent, SidebarResizeHandle } from './sidebar' + +import type { SidebarAlign, SidebarProps } from './sidebar' + +function renderSidebar( + props: Partial<SidebarProps> = {}, + { + contentProps = {}, + children = <nav aria-label="Main navigation">Navigation</nav>, + withBackground = false, + }: { + contentProps?: Record<string, unknown> + children?: React.ReactNode + withBackground?: boolean + } = {}, +) { + const ui = (overrides: Partial<SidebarProps>) => ( + <div> + <Sidebar align="start" isOpen {...props} {...overrides}> + <SidebarContent + data-testid="sidebar-panel" + aria-label="Main navigation" + {...contentProps} + > + {children} + </SidebarContent> + </Sidebar> + {withBackground ? ( + <main> + <button type="button">Background action</button> + </main> + ) : null} + </div> + ) + const view = render(ui({})) + return { + ...view, + rerender: (overrides: Partial<SidebarProps> = {}) => view.rerender(ui(overrides)), + } +} + +describe('when isOverlay is false', () => { + it('renders a docked panel as a neutral <div> wrapping the content', () => { + const width = 280 + // `role` is omitted from the public type but we force it to prove the + // component owns the rendered role and a host role is ignored. + renderSidebar( + { align: 'end', id: 'app-sidebar', width }, + { + contentProps: { role: 'banner', exceptionallySetClassName: 'app-skin' }, + children: <div>Panel content</div>, + }, + ) + + const panel = screen.getByTestId('sidebar-panel') + expect(panel.tagName).toBe('DIV') + expect(panel).not.toHaveAttribute('role') + expect(screen.queryByRole('banner')).not.toBeInTheDocument() + // `aria-label` is applied only to the dialog role, so a docked panel drops the + // name it was given. + expect(panel).not.toHaveAttribute('aria-label') + expect(panel).toHaveClass('app-skin') + expect(panel).toHaveAttribute('id', 'app-sidebar') + expect(panel).toHaveAttribute('data-align', 'end') + expect(panel).toHaveAttribute('data-state', 'open') + expect(panel.style.getPropertyValue('--reactist-sidebar-width')).toBe(`${width}px`) + expect(panel.style.width).toBe('') + expect(screen.getByText('Panel content')).toBeInTheDocument() + }) + + it('does not add overlay semantics while docked', async () => { + const user = userEvent.setup() + const onDismiss = jest.fn() + const { container } = renderSidebar( + { + isOverlay: false, + overlayMode: 'modal', + dismissOverlayOnEscape: true, + onDismiss, + }, + { + children: ( + <nav aria-label="Main navigation"> + <button type="button">Panel item</button> + </nav> + ), + }, + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByRole('navigation', { name: 'Main navigation' })).toBeInTheDocument() + expect(screen.queryByTestId('sidebar-backdrop')).not.toBeInTheDocument() + expect(container.querySelector('[data-focus-lock-disabled]')).toHaveAttribute( + 'data-focus-lock-disabled', + 'disabled', + ) + + // Escape does not do anything while docked, even with the flag on and focus inside. + screen.getByRole('button', { name: 'Panel item' }).focus() + await user.keyboard('{Escape}') + expect(onDismiss).not.toHaveBeenCalled() + }) +}) + +describe('when overlayMode is modal', () => { + it('opens as a modal dialog: backdrop shown, background hidden from assistive tech, and focus trapped', async () => { + renderSidebar( + { isOverlay: true, overlayMode: 'modal', id: 'nav' }, + { + contentProps: { 'aria-label': 'Menu' }, + children: ( + <nav aria-label="Primary"> + <button type="button">First</button> + </nav> + ), + withBackground: true, + }, + ) + + const dialog = screen.getByRole('dialog', { name: 'Menu' }) + expect(dialog).toHaveAttribute('aria-modal', 'true') + + const backdrop = screen.getByTestId('sidebar-backdrop') + expect(backdrop).toHaveAttribute('aria-hidden', 'true') + expect(backdrop).toHaveAttribute('data-state', 'open') + + // The component neutralises the background (inert where supported, aria-hidden otherwise). + // Assert it directly: the background <main> carries the suppression marker, so its button + // drops out of the accessibility tree. + expect(screen.getByRole('main', { hidden: true })).toHaveAttribute( + 'data-suppressed', + 'true', + ) + expect(screen.queryByRole('button', { name: 'Background action' })).not.toBeInTheDocument() + + // Regression guard: the backdrop must stay OUT of that suppression set. On a mount + // that starts open+modal, SidebarContent's layout effect can run before the sibling + // backdrop's ref attaches, so suppressOthers wrongly marks the backdrop too, and `inert` + // (in supporting browsers) then kills its click-to-dismiss, soft-locking the page. + expect(backdrop).not.toHaveAttribute('data-suppressed') + + await waitFor(() => { + expect(dialog.contains(document.activeElement)).toBe(true) + }) + }) + + it('dismisses on a backdrop click and restores the background on close', async () => { + const user = userEvent.setup() + const onDismiss = jest.fn() + const { rerender } = renderSidebar( + { isOverlay: true, overlayMode: 'modal', onDismiss }, + { withBackground: true }, + ) + + expect(screen.queryByRole('button', { name: 'Background action' })).not.toBeInTheDocument() + + await user.click(screen.getByTestId('sidebar-backdrop')) + expect(onDismiss).toHaveBeenCalledTimes(1) + + rerender({ isOpen: false }) + expect(screen.getByRole('button', { name: 'Background action' })).toBeInTheDocument() + }) +}) + +describe('when overlayMode is dialog', () => { + it("opens as a non-modal dialog, preserving the content's landmark and leaving the background reachable", () => { + renderSidebar( + { isOverlay: true, overlayMode: 'dialog' }, + { children: <nav aria-label="Primary">Navigation</nav>, withBackground: true }, + ) + + const dialog = screen.getByRole('dialog', { name: 'Main navigation' }) + expect(dialog).not.toHaveAttribute('aria-modal') + + expect(within(dialog).getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() + expect(screen.queryByTestId('sidebar-backdrop')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Background action' })).toBeInTheDocument() + }) +}) + +describe('when overlayMode is plain', () => { + it('opens as a plain overlay with no dialog role, backdrop, or trap', () => { + const { container } = renderSidebar({ isOverlay: true, overlayMode: 'plain' }) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByRole('navigation', { name: 'Main navigation' })).toBeInTheDocument() + expect(screen.queryByTestId('sidebar-backdrop')).not.toBeInTheDocument() + expect(container.querySelector('[data-focus-lock-disabled]')).toHaveAttribute( + 'data-focus-lock-disabled', + 'disabled', + ) + }) +}) + +describe('dismissOverlayOnEscape', () => { + it('dismisses an open overlay on Escape from within the panel', async () => { + const user = userEvent.setup() + const onDismiss = jest.fn() + renderSidebar( + { isOverlay: true, overlayMode: 'dialog', dismissOverlayOnEscape: true, onDismiss }, + { children: <button type="button">Panel item</button> }, + ) + + await user.keyboard('{Escape}') + expect(onDismiss).not.toHaveBeenCalled() + + screen.getByRole('button', { name: 'Panel item' }).focus() + await user.keyboard('{Escape}') + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('respects defaultPrevented so a descendant can consume Escape', async () => { + const user = userEvent.setup() + const onDismiss = jest.fn() + renderSidebar( + { + isOverlay: true, + overlayMode: 'modal', + dismissOverlayOnEscape: true, + onDismiss, + }, + { + children: ( + <button + type="button" + onKeyDown={(event) => { + if (event.key === 'Escape') event.preventDefault() + }} + > + Consumes Escape + </button> + ), + }, + ) + screen.getByRole('button', { name: 'Consumes Escape' }).focus() + await user.keyboard('{Escape}') + expect(onDismiss).not.toHaveBeenCalled() + }) + + it("respects defaultPrevented from the panel's own onKeyDown handler", async () => { + const user = userEvent.setup() + const onDismiss = jest.fn() + renderSidebar( + { + isOverlay: true, + overlayMode: 'modal', + dismissOverlayOnEscape: true, + onDismiss, + }, + { + contentProps: { + onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === 'Escape') event.preventDefault() + }, + }, + children: <button type="button">Panel item</button>, + }, + ) + screen.getByRole('button', { name: 'Panel item' }).focus() + await user.keyboard('{Escape}') + expect(onDismiss).not.toHaveBeenCalled() + }) + + it('does not dismiss when dismissOverlayOnEscape is off', async () => { + const user = userEvent.setup() + const onDismiss = jest.fn() + renderSidebar( + { + isOverlay: true, + overlayMode: 'modal', + dismissOverlayOnEscape: false, + onDismiss, + }, + { children: <button type="button">Panel item</button> }, + ) + screen.getByRole('button', { name: 'Panel item' }).focus() + await user.keyboard('{Escape}') + expect(onDismiss).not.toHaveBeenCalled() + }) +}) + +describe('unmountOnHide', () => { + it('drops children on transitionend after the exit', () => { + const { rerender } = renderSidebar( + { unmountOnHide: true }, + { children: <nav aria-label="Primary">Panel body</nav> }, + ) + expect(screen.getByText('Panel body')).toBeInTheDocument() + + rerender({ isOpen: false }) + expect(screen.getByText('Panel body')).toBeInTheDocument() + + fireEvent.transitionEnd(screen.getByTestId('sidebar-panel')) + expect(screen.queryByText('Panel body')).not.toBeInTheDocument() + }) + + it('cancels a pending unmount when reopened mid-exit', () => { + const { rerender } = renderSidebar( + { unmountOnHide: true }, + { children: <nav aria-label="Primary">Panel body</nav> }, + ) + rerender({ isOpen: false }) + rerender({ isOpen: true }) + fireEvent.transitionEnd(screen.getByTestId('sidebar-panel')) + expect(screen.getByText('Panel body')).toBeInTheDocument() + }) + + it('unmounts without a transitionend under reduced motion', () => { + jest.useFakeTimers() + try { + const { rerender } = renderSidebar( + { unmountOnHide: true }, + { children: <nav aria-label="Primary">Panel body</nav> }, + ) + rerender({ isOpen: false }) + expect(screen.getByText('Panel body')).toBeInTheDocument() + act(() => { + jest.runOnlyPendingTimers() + }) + expect(screen.queryByText('Panel body')).not.toBeInTheDocument() + } finally { + jest.useRealTimers() + } + }) +}) + +describe('SidebarPersistentContent', () => { + it('renders in the panel and stays visible when open, and out of the inert when closed', async () => { + const { rerender } = renderSidebar( + { width: 280 }, + { + children: ( + <nav aria-label="Main navigation"> + <SidebarPersistentContent> + <button type="button">Toggle sidebar</button> + </SidebarPersistentContent> + <a href="#projects">Projects</a> + </nav> + ), + }, + ) + + const panel = screen.getByTestId('sidebar-panel') + const toggle = await within(panel).findByRole('button', { name: 'Toggle sidebar' }) + expect(toggle).toBeVisible() + expect(toggle.closest('[inert]')).toBeNull() + expect(screen.getByText('Projects').closest('[inert]')).toBeNull() + + rerender({ isOpen: false }) + expect(screen.getByText('Projects').closest('[inert]')).not.toBeNull() + expect(toggle.closest('[inert]')).toBeNull() + }) + + it('renders nothing and warns when misconfigured', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + + // Outside <SidebarContent>: it portals nowhere, so its child never renders. + const { unmount } = render( + <SidebarPersistentContent> + <button type="button">Ghost control</button> + </SidebarPersistentContent>, + ) + expect(screen.queryByRole('button', { name: 'Ghost control' })).not.toBeInTheDocument() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('must be nested within <SidebarContent>'), + ) + unmount() + + // With `unmountOnHide`, the persistent control cannot survive a close, defeating the slot. + warn.mockClear() + renderSidebar( + { unmountOnHide: true, width: 280 }, + { + children: ( + <SidebarPersistentContent> + <button type="button">Toggle</button> + </SidebarPersistentContent> + ), + }, + ) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('unmountOnHide')) + + warn.mockRestore() + }) +}) + +describe('resize', () => { + const WIDTH = 280 + const MIN_WIDTH = 210 + const MAX_WIDTH = 420 + const DEFAULT_WIDTH = 280 + const STEP = 40 + + function renderResizable(props: Partial<SidebarProps> = {}) { + const onWidthChange = jest.fn() + return { + onWidthChange, + ...renderSidebar( + { + id: 'sidebar', + width: WIDTH, + minWidth: MIN_WIDTH, + maxWidth: MAX_WIDTH, + defaultWidth: DEFAULT_WIDTH, + resizeStep: STEP, + onWidthChange, + ...props, + }, + { + children: ( + <> + <div>Navigation</div> + <SidebarResizeHandle aria-label="Resize sidebar" /> + </> + ), + }, + ), + } + } + + // jsdom doesn't implement the Pointer Capture API; stub it so drags run. + function stubPointerCapture(handle: HTMLElement) { + handle.setPointerCapture = jest.fn() + handle.releasePointerCapture = jest.fn() + handle.hasPointerCapture = jest.fn(() => false) + } + + it('re-clamps an out-of-range controlled width that changes on the same bound', () => { + const { rerender } = renderResizable({ width: 500 }) + const panel = document.getElementById('sidebar') as HTMLElement + const handle = screen.getByRole('separator', { name: 'Resize sidebar' }) + + expect(panel.style.getPropertyValue('--reactist-sidebar-width')).toBe(`${MAX_WIDTH}px`) + expect(handle).toHaveAttribute('aria-valuenow', String(MAX_WIDTH)) + + rerender({ width: 460 }) + + expect(panel.style.getPropertyValue('--reactist-sidebar-width')).toBe(`${MAX_WIDTH}px`) + expect(handle).toHaveAttribute('aria-valuenow', String(MAX_WIDTH)) + }) + + it('wires the separator when open and makes it non-interactive when closed', async () => { + const user = userEvent.setup() + const { onWidthChange, rerender } = renderResizable() + + const handle = screen.getByRole('separator', { name: 'Resize sidebar' }) + expect(handle).toHaveAttribute('aria-orientation', 'vertical') + expect(handle).toHaveAttribute('aria-controls', 'sidebar') + expect(handle).toHaveAttribute('aria-valuemin', String(MIN_WIDTH)) + expect(handle).toHaveAttribute('aria-valuemax', String(MAX_WIDTH)) + expect(handle).toHaveAttribute('aria-valuenow', String(WIDTH)) + expect(handle).toHaveAttribute('aria-valuetext', `${WIDTH}px`) + expect(handle).toHaveAttribute('tabindex', '0') + + rerender({ isOpen: false }) + + const closedHandle = screen.getByRole('separator', { hidden: true }) + expect(closedHandle).toHaveAttribute('tabindex', '-1') + expect(closedHandle).toHaveAttribute('aria-hidden', 'true') + + closedHandle.focus() + await user.keyboard('{ArrowRight}') + expect(onWidthChange).not.toHaveBeenCalled() + }) + + it('forwards data-* attributes to the separator and keeps its own on collision', () => { + renderSidebar( + { id: 'sidebar', width: 280, minWidth: 200, maxWidth: 420 }, + { + children: ( + <> + <nav aria-label="Main navigation">Navigation</nav> + <SidebarResizeHandle + aria-label="Resize sidebar" + data-testid="resize-handle" + data-align="consumer-should-lose" + /> + </> + ), + }, + ) + + const handle = screen.getByTestId('resize-handle') + expect(handle).toHaveAttribute('data-testid', 'resize-handle') + // the component owns data-align; a consumer collision must not win + expect(handle).toHaveAttribute('data-align', 'start') + }) + + const keyboardCases: Array<{ + align: SidebarAlign + key: string + width?: number + expected: number + description: string + }> = [ + { + align: 'start', + key: 'ArrowRight', + expected: WIDTH + STEP, + description: 'widens by resizeStep', + }, + { + align: 'start', + key: 'ArrowLeft', + expected: WIDTH - STEP, + description: 'narrows by resizeStep', + }, + { + align: 'end', + key: 'ArrowRight', + expected: WIDTH - STEP, + description: 'narrows by resizeStep', + }, + { + align: 'end', + key: 'ArrowLeft', + expected: WIDTH + STEP, + description: 'widens by resizeStep', + }, + { align: 'start', key: 'Home', expected: MIN_WIDTH, description: 'jumps to minWidth' }, + { align: 'start', key: 'End', expected: MAX_WIDTH, description: 'jumps to maxWidth' }, + { + align: 'end', + key: 'Home', + expected: MAX_WIDTH, + description: 'jumps to maxWidth (swapped)', + }, + { + align: 'end', + key: 'End', + expected: MIN_WIDTH, + description: 'jumps to minWidth (swapped)', + }, + { + align: 'start', + key: 'ArrowRight', + width: MAX_WIDTH - 10, + expected: MAX_WIDTH, + description: 'clamps a step to maxWidth', + }, + ] + + it.each(keyboardCases)( + 'align="$align": $key $description', + async ({ align, key, width, expected }) => { + const user = userEvent.setup() + const { onWidthChange } = renderResizable({ align, width: width ?? WIDTH }) + + screen.getByRole('separator', { name: 'Resize sidebar' }).focus() + await user.keyboard(`{${key}}`) + expect(onWidthChange).toHaveBeenLastCalledWith(expected) + }, + ) + + it('resets to defaultWidth on double-click', async () => { + const user = userEvent.setup() + const { onWidthChange } = renderResizable({ width: DEFAULT_WIDTH + 80 }) + await user.dblClick(screen.getByRole('separator', { name: 'Resize sidebar' })) + expect(onWidthChange).toHaveBeenLastCalledWith(DEFAULT_WIDTH) + }) + + it('commits the dragged width once on pointer up', async () => { + const user = userEvent.setup() + const { onWidthChange } = renderResizable() + const handle = screen.getByRole('separator', { name: 'Resize sidebar' }) + const panel = document.getElementById('sidebar') as HTMLElement + + stubPointerCapture(handle) + + const dragByPx = 50 + await user.pointer([ + { keys: '[MouseLeft>]', target: handle, coords: { clientX: 100, clientY: 0 } }, + { coords: { clientX: 100 + dragByPx, clientY: 0 } }, + { keys: '[/MouseLeft]' }, + ]) + + expect(panel.style.getPropertyValue('--reactist-sidebar-width')).toBe( + `${WIDTH + dragByPx}px`, + ) + expect(onWidthChange).toHaveBeenCalledTimes(1) + expect(onWidthChange).toHaveBeenLastCalledWith(WIDTH + dragByPx) + }) + + it('does not start a resize on a non-primary pointer button', async () => { + const user = userEvent.setup() + const { onWidthChange } = renderResizable() + const handle = screen.getByRole('separator', { name: 'Resize sidebar' }) + const panel = document.getElementById('sidebar') as HTMLElement + + stubPointerCapture(handle) + + await user.pointer([ + { keys: '[MouseRight>]', target: handle, coords: { clientX: 100, clientY: 0 } }, + { coords: { clientX: 150, clientY: 0 } }, + { keys: '[/MouseRight]' }, + ]) + + expect(panel.style.getPropertyValue('--reactist-sidebar-width')).toBe(`${WIDTH}px`) + expect(onWidthChange).not.toHaveBeenCalled() + }) + + it('leaves arrow keys inert when resizeStep is omitted, but keeps Home/End', () => { + const { onWidthChange } = renderResizable({ resizeStep: undefined }) + const handle = screen.getByRole('separator', { name: 'Resize sidebar' }) + + // No step configured: an arrow press neither preventDefaults nor commits. + expect(fireEvent.keyDown(handle, { key: 'ArrowRight' })).toBe(true) + expect(onWidthChange).not.toHaveBeenCalled() + + // Home/End do not depend on the step, so they still jump to the bounds. + expect(fireEvent.keyDown(handle, { key: 'End' })).toBe(false) + expect(onWidthChange).toHaveBeenLastCalledWith(MAX_WIDTH) + }) + + it('does not commit when the handle is pressed and released without moving', async () => { + const user = userEvent.setup() + const { onWidthChange } = renderResizable() + const handle = screen.getByRole('separator', { name: 'Resize sidebar' }) + stubPointerCapture(handle) + + await user.pointer([ + { keys: '[MouseLeft>]', target: handle, coords: { clientX: 100, clientY: 0 } }, + { keys: '[/MouseLeft]' }, + ]) + + expect(onWidthChange).not.toHaveBeenCalled() + }) +}) + +describe('errors', () => { + it('throws when a slot is used outside <Sidebar>', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) + expect(() => render(<SidebarContent>orphan</SidebarContent>)).toThrow( + 'must be rendered inside <Sidebar>', + ) + consoleError.mockRestore() + }) +}) + +describe('accessibility', () => { + it('has no axe violations as a docked nav', async () => { + const { container } = render( + <Sidebar align="start" isOpen id="nav" width={280} minWidth={210} maxWidth={420}> + <SidebarContent> + <nav aria-label="Main navigation"> + <a href="#projects">Projects</a> + </nav> + <SidebarResizeHandle aria-label="Resize sidebar" /> + </SidebarContent> + </Sidebar>, + ) + expect(await axe(container)).toHaveNoViolations() + }) + + it('has no axe violations as a modal overlay', async () => { + const { container } = render( + <Sidebar align="start" isOpen isOverlay overlayMode="modal" id="nav"> + <SidebarContent aria-label="Main navigation"> + <nav aria-label="Primary"> + <a href="#projects">Projects</a> + </nav> + </SidebarContent> + </Sidebar>, + ) + expect(await axe(container)).toHaveNoViolations() + }) +}) diff --git a/src/sidebar/sidebar.tsx b/src/sidebar/sidebar.tsx new file mode 100644 index 00000000..89274036 --- /dev/null +++ b/src/sidebar/sidebar.tsx @@ -0,0 +1,626 @@ +import * as React from 'react' +import { createPortal } from 'react-dom' +import FocusLock from 'react-focus-lock' + +import { suppressOthers } from 'aria-hidden' +import classNames from 'classnames' +import { useMergeRefs } from 'use-callback-ref' + +import { Box } from '../box' + +import { clamp, useResizablePanel } from './use-resizable-panel' + +import styles from './sidebar.module.css' + +import type { ObfuscatedClassName } from '../utils/common-types' + +type SidebarAlign = 'start' | 'end' + +type SidebarOverlayMode = 'plain' | 'dialog' | 'modal' + +type SidebarContextValue = { + align: SidebarAlign + overlayMode: SidebarOverlayMode + isOpen: boolean + isOverlay: boolean + overlayOpen: boolean + shouldTrap: boolean + unmountOnHide: boolean + panelId: string + panelRef: React.RefObject<HTMLDivElement | null> + backdropRef: React.RefObject<HTMLDivElement | null> + dismissOverlayOnEscape: boolean + onDismiss?: () => void + width?: number + minWidth?: number + maxWidth?: number + defaultWidth?: number + resizeStep?: number + onWidthChange?: (width: number) => void +} + +const SidebarContext = React.createContext<SidebarContextValue | null>(null) + +/** + * Scoped to `<SidebarContent>`, used to provide: + * * The live region that `<SidebarPersistentContent>` portals into + * * The panel's `unmountOnHide` option to emit warnings when it's used + * in conjunction with `<SidebarPersistentContent>` + */ +type SidebarContentContextValue = { region: HTMLElement | null; unmountOnHide: boolean } +const SidebarContentContext = React.createContext<SidebarContentContextValue | undefined>(undefined) + +function useSidebarContext(componentName: string): SidebarContextValue { + const context = React.useContext(SidebarContext) + if (context === null) { + throw new Error(`${componentName} must be rendered inside <Sidebar>.`) + } + return context +} + +// +// Sidebar (provider) +// + +type SidebarProps = { + /** + * The side the sidebar attaches to. Controls the slide direction, which side + * to apply the overlay inset (i.e. side closer to the edge of the viewport), + * and the resize handle edge (side opposite from the viewport edge) + */ + align: SidebarAlign + + /** + * Whether the sidebar is open + */ + isOpen: boolean + + /** + * Identifies the sidebar instance. Applied as the `id` of the + * `<SidebarContent>` panel and various `aria-controls`. Auto-generated + * when omitted. + */ + id?: string + + /** + * When true, the sidebar floats over the content. When false, it sits in flow + * + * @default false + */ + isOverlay?: boolean + + /** + * How the overlay behaves while floating: + * + * - `plain`: the background is interactive with no extra attributes applied + * - `dialog`: the panel is assigned the dialog role, with the background still interactive + * - `modal`: the background is blocked with a backdrop. On top of being a dialog, focus trap + * and `aria-modal` are applied to the panel + * + * @default 'plain' + */ + overlayMode?: SidebarOverlayMode + + /** + * Determines whether `onDismiss` is called when the Esc key is pressed while the + * panel has focus in overlay mode + * + * @default false + */ + dismissOverlayOnEscape?: boolean + + /** + * Called when the user intends to dismiss the sidebar + */ + onDismiss?: () => void + + /** + * Controlled width in px. The rendered width may be smaller when the viewport is + * constrained while in overlay mode + */ + width?: number + + /** + * Fired when a new width should be committed + */ + onWidthChange?: (width: number) => void + + /** + * Minimum width allowed during resize + */ + minWidth?: number + + /** + * Maximum width allowed during resize + */ + maxWidth?: number + + /** + * Width restored on a double-click reset of the handle + */ + defaultWidth?: number + + /** + * The step in px when resizing via arrow keys. When omitted or a non-positive value + * is set, arrow key resizing is disabled + */ + resizeStep?: number + + /** + * When `true`, the content unmounts at the end of the exit transition. Omit if the content's + * internal state needs to be kept + * + * @default false + */ + unmountOnHide?: boolean + + /** + * The content of the panel via `<SidebarContent>`, and an optional `<SidebarResizeHandle>` + */ + children?: React.ReactNode +} + +/** + * The host for a sidebar instance + */ +function Sidebar({ + align, + isOpen, + id, + isOverlay = false, + overlayMode = 'plain', + dismissOverlayOnEscape = false, + onDismiss, + width, + onWidthChange, + minWidth, + maxWidth, + defaultWidth, + resizeStep, + unmountOnHide = false, + children, +}: SidebarProps) { + const generatedId = React.useId() + const panelId = id ?? generatedId + const panelRef = React.useRef<HTMLDivElement>(null) + const backdropRef = React.useRef<HTMLDivElement>(null) + + const overlayOpen = isOverlay && isOpen + const shouldTrap = overlayOpen && overlayMode === 'modal' + + React.useLayoutEffect( + function suppressBackgroundWhileModal() { + if (!shouldTrap) return + const panel = panelRef.current + if (!panel) return + const kept = backdropRef.current ? [panel, backdropRef.current] : [panel] + return suppressOthers(kept) + }, + [shouldTrap, panelRef, backdropRef], + ) + + const contextValue: SidebarContextValue = { + align, + overlayMode, + isOpen, + isOverlay, + overlayOpen, + shouldTrap, + unmountOnHide, + panelId, + panelRef, + backdropRef, + dismissOverlayOnEscape, + onDismiss, + width, + minWidth, + maxWidth, + defaultWidth, + resizeStep, + onWidthChange, + } + + return ( + <SidebarContext.Provider value={contextValue}> + {children} + <SidebarBackdrop /> + </SidebarContext.Provider> + ) +} + +// +// SidebarContent +// + +type SidebarContentProps = Omit< + React.ComponentPropsWithoutRef<'div'>, + 'className' | 'role' | 'id' | 'aria-label' | 'aria-labelledby' +> & + ObfuscatedClassName & + ( + | { 'aria-label'?: string; 'aria-labelledby'?: never } + | { 'aria-label'?: never; 'aria-labelledby'?: string } + ) & { + /** + * The panel's skin and content. It is recommended to use a landmark element, e.g. `<nav>`, `<aside>`, or + * `<section>`. An optional `<SidebarResizeHandle>` should be included for resize support. + */ + children?: React.ReactNode + + /** Test identifier applied to the panel element. */ + 'data-testid'?: string + + [dataAttribute: `data-${string}`]: unknown + } + +const SIDEBAR_WIDTH_VAR = '--reactist-sidebar-width' + +/** + * Provides the positioning as a docked panel or as an overlaying dialog. It is responsible for + * the slide and collapse transitions, and the committed width. + * + * Its `aria-label`/`aria-labelledby` attributes are only applied when rendered as a dialog. + */ +const SidebarContent = React.forwardRef<HTMLDivElement, SidebarContentProps>( + function SidebarContent( + { + exceptionallySetClassName, + children, + style, + onKeyDown: consumerOnKeyDown, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + ...rest + }, + ref, + ) { + const { + align, + overlayMode, + isOpen, + isOverlay, + overlayOpen, + shouldTrap, + unmountOnHide, + panelId, + panelRef, + width, + minWidth, + maxWidth, + dismissOverlayOnEscape, + onDismiss, + } = useSidebarContext('SidebarContent') + + const mergedRef = useMergeRefs([panelRef, ref]) + const inertContentRef = React.useRef<HTMLDivElement>(null) + const [persistentRegion, setPersistentRegion] = React.useState<HTMLElement | null>(null) + + const isDialog = overlayOpen && (overlayMode === 'dialog' || overlayMode === 'modal') + const ariaModal = overlayOpen && overlayMode === 'modal' ? true : undefined + + // Note: `inert` is only supported as a prop in React 19+. When dropping support for React 18, + // add `inert={!isOpen}` to the `inertContentRef` element, and remove this effect + React.useEffect( + function inertContentWhileClosed() { + inertContentRef.current?.toggleAttribute('inert', !isOpen) + }, + [isOpen], + ) + + React.useEffect( + function warnWhenDockedCollapseHasNoWidth() { + if (!isOverlay && !isOpen && width == null && !unmountOnHide) { + // eslint-disable-next-line no-console + console.warn( + '[Sidebar]: a docked <Sidebar> needs a controlled `width` to collapse when closed; without one the closed panel stays visible while its contents are inert.', + ) + } + }, + [isOverlay, isOpen, width, unmountOnHide], + ) + + const clampedWidth = + width != null ? clamp(width, minWidth ?? width, maxWidth ?? width) : undefined + const widthStyle = + clampedWidth != null + ? ({ [SIDEBAR_WIDTH_VAR]: `${clampedWidth}px` } as React.CSSProperties) + : undefined + + const childrenToRender = useDeferredUnmount({ isOpen, unmountOnHide, panelRef }) + ? children + : null + + const persistentContentValue = React.useMemo( + () => ({ region: persistentRegion, unmountOnHide }), + [persistentRegion, unmountOnHide], + ) + + function handlePanelKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { + // Execute the consumer's handler first, allowing it to set preventDefault if necessary + consumerOnKeyDown?.(event) + if (event.defaultPrevented) return + if (overlayOpen && dismissOverlayOnEscape && event.key === 'Escape') { + onDismiss?.() + } + } + + return ( + <Box + {...rest} + as="div" + ref={mergedRef} + onKeyDown={handlePanelKeyDown} + display="flex" + flexDirection="column" + flexShrink={0} + id={panelId} + role={isDialog ? 'dialog' : undefined} + aria-modal={ariaModal} + aria-label={isDialog ? ariaLabel : undefined} + aria-labelledby={isDialog ? ariaLabelledby : undefined} + data-align={align} + data-overlay={isOverlay ? 'true' : 'false'} + data-state={isOpen ? 'open' : 'closed'} + style={{ ...style, ...widthStyle }} + className={classNames(styles.panel, exceptionallySetClassName)} + > + <FocusLock disabled={!shouldTrap} returnFocus className={styles.focusLock}> + <div ref={setPersistentRegion} className={styles.persistentContent} /> + <SidebarContentContext.Provider value={persistentContentValue}> + <div ref={inertContentRef} className={styles.inertContent}> + {childrenToRender} + </div> + </SidebarContentContext.Provider> + </FocusLock> + </Box> + ) + }, +) + +/** + * Determines whether the panel's children should be rendered. When `unmountOnHide` + * is true, the children remain mounted during the exit transition and are unmounted + * when it finishes + */ +function useDeferredUnmount({ + isOpen, + unmountOnHide, + panelRef, +}: { + isOpen: boolean + unmountOnHide: boolean + panelRef: React.RefObject<HTMLDivElement | null> +}): boolean { + const [exited, setExited] = React.useState(() => unmountOnHide && !isOpen) + const [wasOpen, setWasOpen] = React.useState(isOpen) + + if (isOpen && !wasOpen) { + setWasOpen(true) + setExited(false) + } else if (!isOpen && wasOpen) { + setWasOpen(false) + } + + React.useEffect( + function unmountAfterExitTransition() { + if (isOpen || !unmountOnHide) return + + const panel = panelRef.current + const fallbackTimeout = window.setTimeout( + () => setExited(true), + getExitTimeoutMs(panel), + ) + + function handleTransitionEnd(event: TransitionEvent) { + if (event.target === panel) { + window.clearTimeout(fallbackTimeout) + setExited(true) + } + } + + panel?.addEventListener('transitionend', handleTransitionEnd) + return function cleanup() { + window.clearTimeout(fallbackTimeout) + panel?.removeEventListener('transitionend', handleTransitionEnd) + } + }, + [isOpen, unmountOnHide, panelRef], + ) + + return isOpen || !unmountOnHide || !exited +} + +function parseCssDurationMs(value: string): number { + return value.split(',').reduce((max, part) => { + const trimmed = part.trim() + const numeric = Number.parseFloat(trimmed) + if (!Number.isFinite(numeric)) return max + return Math.max(max, trimmed.endsWith('ms') ? numeric : numeric * 1000) + }, 0) +} + +function getExitTimeoutMs(panel: HTMLElement | null): number { + if (!panel) return 0 + const style = window.getComputedStyle(panel) + const durationMs = parseCssDurationMs(style.transitionDuration) + if (durationMs === 0) return 0 + return durationMs + parseCssDurationMs(style.transitionDelay) + 50 +} + +// +// SidebarResizeHandle +// + +type SidebarResizeHandleProps = { + /** Accessible name for the separator */ + 'aria-label'?: string + + /** + * Human-readable current width for assistive tech. Derived from the width as + * `"{width}px"` when omitted + */ + 'aria-valuetext'?: string + [dataAttribute: `data-${string}`]: string | number | boolean | undefined +} + +/** + * When placed within `<SidebarContent>`, this adds drag and keyboard resize + * to the sidebar. Renders a `role="separator"` on the inner edge (right for + * `align="start"`, left for `align="end"`) and sets up `aria-controls` to the + * panel internally. + */ +function SidebarResizeHandle({ + 'aria-label': ariaLabel, + 'aria-valuetext': ariaValueText, + ...dataProps +}: SidebarResizeHandleProps) { + const { + align, + isOpen, + panelId, + panelRef, + width, + minWidth, + maxWidth, + defaultWidth, + resizeStep, + onWidthChange, + } = useSidebarContext('SidebarResizeHandle') + + // RTL note: while the panel's position respects RTL via CSS, the resize direction does not + const edge = align === 'start' ? 'right' : 'left' + const committedWidth = width ?? defaultWidth ?? 0 + const minValuePx = minWidth ?? committedWidth + const maxValuePx = maxWidth ?? committedWidth + + const { currentValuePx, onDoubleClick, onKeyDown, onPointerDown } = useResizablePanel({ + applyValue: width != null, + cssVariable: SIDEBAR_WIDTH_VAR, + defaultValuePx: defaultWidth ?? committedWidth, + disabled: !isOpen || width == null, + edge, + maxValuePx, + minValuePx, + onValueCommit: (next) => onWidthChange?.(next), + panelRef, + stepPx: resizeStep ?? 0, + valuePx: committedWidth, + }) + + const hasResizeRange = minWidth != null && maxWidth != null && minWidth < maxWidth + const resizeWarning = + width == null + ? '[Sidebar]: <SidebarResizeHandle> needs a controlled `width` on <Sidebar> to resize.' + : !hasResizeRange + ? '[Sidebar]: <SidebarResizeHandle> needs `minWidth` and `maxWidth` (with minWidth < maxWidth) on <Sidebar>.' + : null + + React.useEffect( + function warnOnDegenerateResizeConfig() { + if (resizeWarning) { + // eslint-disable-next-line no-console + console.warn(resizeWarning) + } + }, + [resizeWarning], + ) + + return ( + <div + {...dataProps} + role="separator" + tabIndex={isOpen ? 0 : -1} + aria-hidden={isOpen ? undefined : true} + aria-controls={panelId} + aria-label={ariaLabel} + aria-orientation="vertical" + aria-valuemin={minValuePx} + aria-valuemax={maxValuePx} + aria-valuenow={currentValuePx} + aria-valuetext={ariaValueText ?? `${currentValuePx}px`} + data-align={align} + onDoubleClick={onDoubleClick} + onKeyDown={onKeyDown} + onPointerDown={onPointerDown} + className={styles.resizeHandle} + /> + ) +} + +// +// SidebarPersistentContent +// + +type SidebarPersistentContentProps = { children?: React.ReactNode } + +/** + * This slot allows its children to be interactive when the sidebar is closed, and must be + * placed within `<SidebarContent>`. + * + * Typically used for a toggle that transitions between being inside the panel while open, + * and outside of it when closed. Content placed in this slot would sit outside of the region that's + * marked as inert when the panel is closed, but remain within the panel's tab order and focus trap + * (when overlayMode is modal). + */ +function SidebarPersistentContent({ children }: SidebarPersistentContentProps) { + const content = React.useContext(SidebarContentContext) + const region = content?.region ?? null + const isOutsideContent = content === undefined + const unmountsOnHide = content?.unmountOnHide ?? false + + React.useEffect( + function warnOnMisconfiguration() { + if (isOutsideContent) { + // eslint-disable-next-line no-console + console.warn( + '[Sidebar]: <SidebarPersistentContent> must be nested within <SidebarContent>.', + ) + } else if (unmountsOnHide) { + // eslint-disable-next-line no-console + console.warn( + '[Sidebar]: `unmountOnHide` on <Sidebar> overrides <SidebarPersistentContent>, which causes its contents to unmount along with the panel.', + ) + } + }, + [isOutsideContent, unmountsOnHide], + ) + + return region ? createPortal(children, region) : null +} + +// +// Backdrop +// + +function SidebarBackdrop() { + const { isOverlay, overlayMode, isOpen, onDismiss, backdropRef } = + useSidebarContext('SidebarBackdrop') + + // Note: this is required for type-compatibility with React 18. `backdropRef` can be passed to `ref` directly below once we drop support + const mergedBackdropRef = useMergeRefs([backdropRef]) + + if (!isOverlay || overlayMode !== 'modal') return null + + return ( + <div + ref={mergedBackdropRef} + aria-hidden="true" + data-state={isOpen ? 'open' : 'closed'} + data-testid="sidebar-backdrop" + className={styles.backdrop} + onClick={onDismiss} + /> + ) +} + +SidebarContent.displayName = 'SidebarContent' + +export { Sidebar, SidebarContent, SidebarPersistentContent, SidebarResizeHandle } +export type { + SidebarAlign, + SidebarContentProps, + SidebarOverlayMode, + SidebarPersistentContentProps, + SidebarProps, + SidebarResizeHandleProps, +} diff --git a/src/sidebar/use-resizable-panel.ts b/src/sidebar/use-resizable-panel.ts new file mode 100644 index 00000000..df788978 --- /dev/null +++ b/src/sidebar/use-resizable-panel.ts @@ -0,0 +1,330 @@ +import * as React from 'react' + +/** + * The panel edge a resize gesture acts on. + */ +export type ResizablePanelEdge = 'left' | 'right' | 'top' | 'bottom' + +type UseResizablePanelParams = { + /** + * When `false`, the committed value is not written to the panel, which allows + * an uncontrolled component to retain its dimensions during initial mount and + * after a resize. Defaults to `true` + */ + applyValue?: boolean + /** When set, read/write this CSS custom property instead of `width`/`height` */ + cssVariable?: string + /** Width restored on a double-click reset */ + defaultValuePx: number + /** When `true`, pointer and keyboard gestures are ignored */ + disabled?: boolean + /** The edge the handle sits on, which sets the drag direction */ + edge: ResizablePanelEdge + /** Upper clamp for the committed value */ + maxValuePx: number + /** Lower clamp for the committed value */ + minValuePx: number + /** Called with the committed value on pointer up and on each keyboard step */ + onValueCommit: (nextValuePx: number) => void + /** The element whose size the gesture writes to */ + panelRef: React.RefObject<HTMLElement | null> + /** + * Keyboard arrow-key step in px. Arrow key resizing is disabled when this + * is non-positive + */ + stepPx: number + /** The controlled, committed value in px */ + valuePx: number +} + +type DragState = { + captureTarget: HTMLElement + currentValuePx: number + pointerId: number + previousFocusedElement: HTMLElement | null + startPointerPx: number + startValuePx: number +} + +type Listeners = { + pointerEnd: () => void + pointerMove: (event: PointerEvent) => void +} + +function removeWindowListeners(listeners: Listeners) { + window.removeEventListener('pointermove', listeners.pointerMove) + window.removeEventListener('pointerup', listeners.pointerEnd) + window.removeEventListener('pointercancel', listeners.pointerEnd) +} + +export function clamp(value: number, min: number, max: number): number { + return Math.round(Math.min(max, Math.max(min, value))) +} + +function getDimension(edge: ResizablePanelEdge): 'height' | 'width' { + return edge === 'left' || edge === 'right' ? 'width' : 'height' +} + +function getPointerPx( + edge: ResizablePanelEdge, + event: Pick<PointerEvent | React.PointerEvent, 'clientX' | 'clientY'>, +): number { + return getDimension(edge) === 'width' ? event.clientX : event.clientY +} + +function getDirection(edge: ResizablePanelEdge): 1 | -1 { + return edge === 'right' || edge === 'bottom' ? 1 : -1 +} + +function getElementValuePx( + element: HTMLElement | null, + edge: ResizablePanelEdge, + fallbackValuePx: number, + cssVariable?: string, +): number { + if (!element) return fallbackValuePx + + const dimension = getDimension(edge) + const inlineValuePx = Number.parseFloat( + cssVariable ? element.style.getPropertyValue(cssVariable) : element.style[dimension], + ) + if (Number.isFinite(inlineValuePx) && inlineValuePx > 0) return inlineValuePx + + const measuredValuePx = element.getBoundingClientRect()[dimension] + return measuredValuePx > 0 ? measuredValuePx : fallbackValuePx +} + +function setElementValuePx( + element: HTMLElement | null, + edge: ResizablePanelEdge, + valuePx: number, + cssVariable?: string, +) { + if (!element) return + if (cssVariable) { + element.style.setProperty(cssVariable, `${valuePx}px`) + } else { + element.style[getDimension(edge)] = `${valuePx}px` + } +} + +function getActiveElementForRestore(): HTMLElement | null { + const activeElement = document.activeElement + return activeElement instanceof HTMLElement && activeElement !== document.body + ? activeElement + : null +} + +function getKeyboardDeltaPx(edge: ResizablePanelEdge, key: string, stepPx: number): number | null { + if (stepPx <= 0) return null + if (edge === 'left') { + if (key === 'ArrowLeft') return stepPx + if (key === 'ArrowRight') return -stepPx + } + if (edge === 'right') { + if (key === 'ArrowLeft') return -stepPx + if (key === 'ArrowRight') return stepPx + } + if (edge === 'top') { + if (key === 'ArrowUp') return stepPx + if (key === 'ArrowDown') return -stepPx + } + if (edge === 'bottom') { + if (key === 'ArrowUp') return -stepPx + if (key === 'ArrowDown') return stepPx + } + return null +} + +/** + * Returns the amount the Home/End keys should jump by based on the edge provided. + * Home always resizes towards the left or top, while End moves towards the right + * or bottom + */ +function getKeyboardJumpPx( + edge: ResizablePanelEdge, + key: string, + minValuePx: number, + maxValuePx: number, +): number | null { + const growsForward = getDirection(edge) === 1 + if (key === 'Home') return growsForward ? minValuePx : maxValuePx + if (key === 'End') return growsForward ? maxValuePx : minValuePx + return null +} + +/** + * Ported from Automations, this engine provides a performant way to + * resize a panel element with pointer devices and the keyboard. During drag, + * it writes the new dimension to either a provided CSS custom property, + * or onto the DOM element directly, bypassing the render cycle. On drag end, + * or via the keyboard, values are passed to the onValueCommit callback. + * + * Ref: https://github.com/Doist/automations/pull/3236 + */ +export function useResizablePanel({ + applyValue = true, + cssVariable, + defaultValuePx, + disabled = false, + edge, + maxValuePx, + minValuePx, + onValueCommit, + panelRef, + stepPx, + valuePx, +}: UseResizablePanelParams) { + const dragRef = React.useRef<DragState | null>(null) + const frameRef = React.useRef<number | null>(null) + const listenersRef = React.useRef<Listeners | null>(null) + const pendingValueRef = React.useRef<number | null>(null) + const currentValuePx = clamp(valuePx, minValuePx, maxValuePx) + + function clearListeners() { + if (!listenersRef.current) return + removeWindowListeners(listenersRef.current) + listenersRef.current = null + } + + function flushPreview() { + if (pendingValueRef.current === null) return + + setElementValuePx(panelRef.current, edge, pendingValueRef.current, cssVariable) + pendingValueRef.current = null + } + + function cancelFrame() { + if (frameRef.current === null) return + + window.cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + + function previewValue(nextValuePx: number) { + pendingValueRef.current = nextValuePx + if (frameRef.current !== null) return + + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null + flushPreview() + }) + } + + function commitValue(nextValuePx: number) { + const clampedValuePx = clamp(nextValuePx, minValuePx, maxValuePx) + + setElementValuePx(panelRef.current, edge, clampedValuePx, cssVariable) + onValueCommit(clampedValuePx) + } + + function endDrag() { + const drag = dragRef.current + if (!drag) return + + cancelFrame() + flushPreview() + + if (drag.currentValuePx !== drag.startValuePx) { + onValueCommit(drag.currentValuePx) + } + + if (drag.captureTarget.hasPointerCapture?.(drag.pointerId)) { + drag.captureTarget.releasePointerCapture(drag.pointerId) + } + + drag.previousFocusedElement?.focus({ preventScroll: true }) + dragRef.current = null + clearListeners() + } + + function onPointerDown(event: React.PointerEvent<HTMLDivElement>) { + if (disabled) return + if (event.button !== 0) return + + event.preventDefault() + event.stopPropagation() + endDrag() + + const startValuePx = clamp( + getElementValuePx(panelRef.current, edge, currentValuePx, cssVariable), + minValuePx, + maxValuePx, + ) + + event.currentTarget.setPointerCapture?.(event.pointerId) + dragRef.current = { + captureTarget: event.currentTarget, + currentValuePx: startValuePx, + pointerId: event.pointerId, + previousFocusedElement: getActiveElementForRestore(), + startPointerPx: getPointerPx(edge, event), + startValuePx, + } + event.currentTarget.focus({ preventScroll: true }) + + function pointerMove(moveEvent: PointerEvent) { + const drag = dragRef.current + if (!drag) return + + const pointerDeltaPx = getPointerPx(edge, moveEvent) - drag.startPointerPx + const nextValuePx = drag.startValuePx + pointerDeltaPx * getDirection(edge) + + drag.currentValuePx = clamp(nextValuePx, minValuePx, maxValuePx) + previewValue(drag.currentValuePx) + } + + listenersRef.current = { pointerEnd: endDrag, pointerMove } + window.addEventListener('pointermove', pointerMove) + window.addEventListener('pointerup', endDrag) + window.addEventListener('pointercancel', endDrag) + } + + function onDoubleClick() { + if (!disabled) commitValue(defaultValuePx) + } + + function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { + if (disabled) return + + const nextValuePx = getKeyboardJumpPx(edge, event.key, minValuePx, maxValuePx) + const deltaPx = getKeyboardDeltaPx(edge, event.key, stepPx) + + if (nextValuePx === null && deltaPx === null) return + + event.preventDefault() + commitValue( + nextValuePx ?? + getElementValuePx(panelRef.current, edge, currentValuePx, cssVariable) + + (deltaPx ?? 0), + ) + } + + React.useEffect( + function reapplyCommittedValue() { + if (!applyValue) return + setElementValuePx(panelRef.current, edge, currentValuePx, cssVariable) + }, + [applyValue, currentValuePx, edge, panelRef, cssVariable], + ) + + React.useEffect(function cleanupOnUnmount() { + return () => { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + if (listenersRef.current) { + removeWindowListeners(listenersRef.current) + listenersRef.current = null + } + } + }, []) + + return { + currentValuePx, + onDoubleClick, + onKeyDown, + onPointerDown, + } +}