From c231d10f6a1eb80e60a00dc92ceba5725ac9ba8c Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 25 Jul 2026 05:11:55 -0700 Subject: [PATCH 1/9] feat(sidebar): port the resizable-panel engine from Automations Co-Authored-By: Claude --- src/sidebar/use-resizable-panel.ts | 297 +++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 src/sidebar/use-resizable-panel.ts diff --git a/src/sidebar/use-resizable-panel.ts b/src/sidebar/use-resizable-panel.ts new file mode 100644 index 00000000..c0fecf98 --- /dev/null +++ b/src/sidebar/use-resizable-panel.ts @@ -0,0 +1,297 @@ +import * as React from 'react' + +/** + * The panel edge a resize gesture acts on. The sidebar only ever resizes along + * the inline axis (`left` / `right`), but the engine supports the block axis too + * so it can be reused for other resizable surfaces. + */ +export type ResizablePanelEdge = 'left' | 'right' | 'top' | 'bottom' + +type UseResizablePanelParams = { + /** 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 + /** Keyboard arrow-key step in px. */ + 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 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, +): 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, +): number { + if (!element) return fallbackValuePx + + const dimension = getDimension(edge) + const inlineValuePx = Number.parseFloat(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) { + if (element) element.style[getDimension(edge)] = `${valuePx}px` +} + +function getActiveElementForRestore(): HTMLElement | null { + if (typeof document === 'undefined') return 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 (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 +} + +/** + * The imperative, render-free resize engine ported from Automations. A pointer + * drag writes the panel size straight to the DOM (batched to one write per + * animation frame) and commits once on pointer up; keyboard resize commits on + * each keystroke. Nothing re-renders React per frame, so the panel's children + * and any backdrop stay untouched during a drag. + * + * The handlers are plain functions; the React Compiler memoizes them. The one + * constraint that keeps it Compiler-clean: `panelRef.current` is only read inside + * event handlers, the animation-frame callback, or an effect, never during render. + */ +export function useResizablePanel({ + defaultValuePx, + disabled = false, + edge, + maxValuePx, + minValuePx, + onValueCommit, + panelRef, + stepPx, + valuePx, +}: UseResizablePanelParams) { + const dragRef = React.useRef(null) + const frameRef = React.useRef(null) + const listenersRef = React.useRef(null) + const pendingValueRef = React.useRef(null) + const [isResizing, setIsResizing] = React.useState(false) + const currentValuePx = clamp(valuePx, minValuePx, maxValuePx) + + function clearListeners() { + const listeners = listenersRef.current + if (!listeners) return + + window.removeEventListener('pointermove', listeners.pointerMove) + window.removeEventListener('pointerup', listeners.pointerEnd) + window.removeEventListener('pointercancel', listeners.pointerEnd) + listenersRef.current = null + } + + function flushPreview() { + if (pendingValueRef.current === null) return + + setElementValuePx(panelRef.current, edge, pendingValueRef.current) + 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) + onValueCommit(clampedValuePx) + } + + function endDrag() { + const drag = dragRef.current + if (!drag) return + + cancelFrame() + flushPreview() + onValueCommit(drag.currentValuePx) + + if ( + typeof drag.captureTarget.hasPointerCapture === 'function' && + drag.captureTarget.hasPointerCapture(drag.pointerId) + ) { + drag.captureTarget.releasePointerCapture(drag.pointerId) + } + + drag.previousFocusedElement?.focus({ preventScroll: true }) + dragRef.current = null + setIsResizing(false) + clearListeners() + } + + function onPointerDown(event: React.PointerEvent) { + if (disabled) return + + event.preventDefault() + event.stopPropagation() + endDrag() + + const startValuePx = clamp( + getElementValuePx(panelRef.current, edge, currentValuePx), + 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 }) + setIsResizing(true) + clearListeners() + + const 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) { + if (disabled) return + + const nextValuePx = + event.key === 'Home' ? minValuePx : event.key === 'End' ? maxValuePx : null + const deltaPx = getKeyboardDeltaPx(edge, event.key, stepPx) + + if (nextValuePx === null && deltaPx === null) return + + event.preventDefault() + commitValue( + nextValuePx ?? + getElementValuePx(panelRef.current, edge, currentValuePx) + (deltaPx ?? 0), + ) + } + + React.useEffect( + function reapplyCommittedValue() { + setElementValuePx(panelRef.current, edge, currentValuePx) + }, + [currentValuePx, edge, panelRef], + ) + + React.useEffect(function cleanupOnUnmount() { + return () => { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + + const listeners = listenersRef.current + if (listeners) { + window.removeEventListener('pointermove', listeners.pointerMove) + window.removeEventListener('pointerup', listeners.pointerEnd) + window.removeEventListener('pointercancel', listeners.pointerEnd) + listenersRef.current = null + } + } + }, []) + + return { + currentValuePx, + isResizing, + onDoubleClick, + onKeyDown, + onPointerDown, + } +} From 2bdd5a3e2b4c1a5b626991e9a275caa89b3ae5c4 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 25 Jul 2026 05:12:10 -0700 Subject: [PATCH 2/9] feat(sidebar): adapt the resize engine for Sidebar Co-Authored-By: Claude --- src/sidebar/use-resizable-panel.ts | 147 ++++++++++++++++++----------- 1 file changed, 90 insertions(+), 57 deletions(-) diff --git a/src/sidebar/use-resizable-panel.ts b/src/sidebar/use-resizable-panel.ts index c0fecf98..df788978 100644 --- a/src/sidebar/use-resizable-panel.ts +++ b/src/sidebar/use-resizable-panel.ts @@ -1,30 +1,39 @@ import * as React from 'react' /** - * The panel edge a resize gesture acts on. The sidebar only ever resizes along - * the inline axis (`left` / `right`), but the engine supports the block axis too - * so it can be reused for other resizable surfaces. + * The panel edge a resize gesture acts on. */ export type ResizablePanelEdge = 'left' | 'right' | 'top' | 'bottom' type UseResizablePanelParams = { - /** Width restored on a double-click reset. */ + /** + * 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. */ + /** When `true`, pointer and keyboard gestures are ignored */ disabled?: boolean - /** The edge the handle sits on, which sets the drag direction. */ + /** The edge the handle sits on, which sets the drag direction */ edge: ResizablePanelEdge - /** Upper clamp for the committed value. */ + /** Upper clamp for the committed value */ maxValuePx: number - /** Lower clamp for the committed value. */ + /** Lower clamp for the committed value */ minValuePx: number - /** Called with the committed value on pointer up and on each keyboard step. */ + /** 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. */ + /** The element whose size the gesture writes to */ panelRef: React.RefObject - /** Keyboard arrow-key step in px. */ + /** + * Keyboard arrow-key step in px. Arrow key resizing is disabled when this + * is non-positive + */ stepPx: number - /** The controlled, committed value in px. */ + /** The controlled, committed value in px */ valuePx: number } @@ -42,7 +51,13 @@ type Listeners = { pointerMove: (event: PointerEvent) => void } -function clamp(value: number, min: number, max: number): number { +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))) } @@ -65,24 +80,35 @@ function getElementValuePx( element: HTMLElement | null, edge: ResizablePanelEdge, fallbackValuePx: number, + cssVariable?: string, ): number { if (!element) return fallbackValuePx const dimension = getDimension(edge) - const inlineValuePx = Number.parseFloat(element.style[dimension]) + 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) { - if (element) element.style[getDimension(edge)] = `${valuePx}px` +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 { - if (typeof document === 'undefined') return null - const activeElement = document.activeElement return activeElement instanceof HTMLElement && activeElement !== document.body ? activeElement @@ -90,6 +116,7 @@ function getActiveElementForRestore(): HTMLElement | 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 @@ -110,17 +137,34 @@ function getKeyboardDeltaPx(edge: ResizablePanelEdge, key: string, stepPx: numbe } /** - * The imperative, render-free resize engine ported from Automations. A pointer - * drag writes the panel size straight to the DOM (batched to one write per - * animation frame) and commits once on pointer up; keyboard resize commits on - * each keystroke. Nothing re-renders React per frame, so the panel's children - * and any backdrop stay untouched during a drag. + * 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. * - * The handlers are plain functions; the React Compiler memoizes them. The one - * constraint that keeps it Compiler-clean: `panelRef.current` is only read inside - * event handlers, the animation-frame callback, or an effect, never during render. + * Ref: https://github.com/Doist/automations/pull/3236 */ export function useResizablePanel({ + applyValue = true, + cssVariable, defaultValuePx, disabled = false, edge, @@ -135,23 +179,18 @@ export function useResizablePanel({ const frameRef = React.useRef(null) const listenersRef = React.useRef(null) const pendingValueRef = React.useRef(null) - const [isResizing, setIsResizing] = React.useState(false) const currentValuePx = clamp(valuePx, minValuePx, maxValuePx) function clearListeners() { - const listeners = listenersRef.current - if (!listeners) return - - window.removeEventListener('pointermove', listeners.pointerMove) - window.removeEventListener('pointerup', listeners.pointerEnd) - window.removeEventListener('pointercancel', listeners.pointerEnd) + if (!listenersRef.current) return + removeWindowListeners(listenersRef.current) listenersRef.current = null } function flushPreview() { if (pendingValueRef.current === null) return - setElementValuePx(panelRef.current, edge, pendingValueRef.current) + setElementValuePx(panelRef.current, edge, pendingValueRef.current, cssVariable) pendingValueRef.current = null } @@ -175,7 +214,7 @@ export function useResizablePanel({ function commitValue(nextValuePx: number) { const clampedValuePx = clamp(nextValuePx, minValuePx, maxValuePx) - setElementValuePx(panelRef.current, edge, clampedValuePx) + setElementValuePx(panelRef.current, edge, clampedValuePx, cssVariable) onValueCommit(clampedValuePx) } @@ -185,30 +224,30 @@ export function useResizablePanel({ cancelFrame() flushPreview() - onValueCommit(drag.currentValuePx) - if ( - typeof drag.captureTarget.hasPointerCapture === 'function' && - drag.captureTarget.hasPointerCapture(drag.pointerId) - ) { + 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 - setIsResizing(false) clearListeners() } function onPointerDown(event: React.PointerEvent) { if (disabled) return + if (event.button !== 0) return event.preventDefault() event.stopPropagation() endDrag() const startValuePx = clamp( - getElementValuePx(panelRef.current, edge, currentValuePx), + getElementValuePx(panelRef.current, edge, currentValuePx, cssVariable), minValuePx, maxValuePx, ) @@ -223,10 +262,8 @@ export function useResizablePanel({ startValuePx, } event.currentTarget.focus({ preventScroll: true }) - setIsResizing(true) - clearListeners() - const pointerMove = (moveEvent: PointerEvent) => { + function pointerMove(moveEvent: PointerEvent) { const drag = dragRef.current if (!drag) return @@ -250,8 +287,7 @@ export function useResizablePanel({ function onKeyDown(event: React.KeyboardEvent) { if (disabled) return - const nextValuePx = - event.key === 'Home' ? minValuePx : event.key === 'End' ? maxValuePx : null + const nextValuePx = getKeyboardJumpPx(edge, event.key, minValuePx, maxValuePx) const deltaPx = getKeyboardDeltaPx(edge, event.key, stepPx) if (nextValuePx === null && deltaPx === null) return @@ -259,15 +295,17 @@ export function useResizablePanel({ event.preventDefault() commitValue( nextValuePx ?? - getElementValuePx(panelRef.current, edge, currentValuePx) + (deltaPx ?? 0), + getElementValuePx(panelRef.current, edge, currentValuePx, cssVariable) + + (deltaPx ?? 0), ) } React.useEffect( function reapplyCommittedValue() { - setElementValuePx(panelRef.current, edge, currentValuePx) + if (!applyValue) return + setElementValuePx(panelRef.current, edge, currentValuePx, cssVariable) }, - [currentValuePx, edge, panelRef], + [applyValue, currentValuePx, edge, panelRef, cssVariable], ) React.useEffect(function cleanupOnUnmount() { @@ -276,12 +314,8 @@ export function useResizablePanel({ window.cancelAnimationFrame(frameRef.current) frameRef.current = null } - - const listeners = listenersRef.current - if (listeners) { - window.removeEventListener('pointermove', listeners.pointerMove) - window.removeEventListener('pointerup', listeners.pointerEnd) - window.removeEventListener('pointercancel', listeners.pointerEnd) + if (listenersRef.current) { + removeWindowListeners(listenersRef.current) listenersRef.current = null } } @@ -289,7 +323,6 @@ export function useResizablePanel({ return { currentValuePx, - isResizing, onDoubleClick, onKeyDown, onPointerDown, From 9bd6774a578007d7db0179bb08d71944df1c9b5b Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sat, 25 Jul 2026 05:14:44 -0700 Subject: [PATCH 3/9] feat(sidebar): add docked Sidebar with controlled width and collapse Co-Authored-By: Claude --- src/index.ts | 1 + src/sidebar/index.ts | 1 + src/sidebar/sidebar.module.css | 45 ++++++++ src/sidebar/sidebar.test.tsx | 84 +++++++++++++++ src/sidebar/sidebar.tsx | 184 +++++++++++++++++++++++++++++++++ 5 files changed, 315 insertions(+) create mode 100644 src/sidebar/index.ts create mode 100644 src/sidebar/sidebar.module.css create mode 100644 src/sidebar/sidebar.test.tsx create mode 100644 src/sidebar/sidebar.tsx 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.module.css b/src/sidebar/sidebar.module.css new file mode 100644 index 00000000..9e279f48 --- /dev/null +++ b/src/sidebar/sidebar.module.css @@ -0,0 +1,45 @@ +:root { + --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)); +} + +@media (prefers-reduced-motion: reduce) { + :root { + --reactist-sidebar-transition-duration: 0s; + } + + .panel { + transition: none !important; + } +} diff --git a/src/sidebar/sidebar.test.tsx b/src/sidebar/sidebar.test.tsx new file mode 100644 index 00000000..9010b0d1 --- /dev/null +++ b/src/sidebar/sidebar.test.tsx @@ -0,0 +1,84 @@ +import * as React from 'react' + +import { render, screen } from '@testing-library/react' + +import { Sidebar, SidebarContent } from './sidebar' + +import type { SidebarProps } from './sidebar' + +function renderSidebar( + props: Partial = {}, + { + contentProps = {}, + children = , + withBackground = false, + }: { + contentProps?: Record + children?: React.ReactNode + withBackground?: boolean + } = {}, +) { + const ui = (overrides: Partial) => ( +
+ + + {children} + + + {withBackground ? ( +
+ +
+ ) : null} +
+ ) + const view = render(ui({})) + return { + ...view, + rerender: (overrides: Partial = {}) => view.rerender(ui(overrides)), + } +} + +describe('when isOverlay is false', () => { + it('renders a docked panel as a neutral
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:
Panel content
, + }, + ) + + 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() + }) +}) + +describe('errors', () => { + it('throws when a slot is used outside ', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) + expect(() => render(orphan)).toThrow( + 'must be rendered inside ', + ) + consoleError.mockRestore() + }) +}) diff --git a/src/sidebar/sidebar.tsx b/src/sidebar/sidebar.tsx new file mode 100644 index 00000000..290e50d2 --- /dev/null +++ b/src/sidebar/sidebar.tsx @@ -0,0 +1,184 @@ +import * as React from 'react' + +import classNames from 'classnames' +import { useMergeRefs } from 'use-callback-ref' + +import { Box } from '../box' + +import { clamp } from './use-resizable-panel' + +import styles from './sidebar.module.css' + +import type { ObfuscatedClassName } from '../utils/common-types' + +type SidebarAlign = 'start' | 'end' + +type SidebarContextValue = { + align: SidebarAlign + isOpen: boolean + panelId: string + panelRef: React.RefObject + width?: number + minWidth?: number + maxWidth?: number +} + +const SidebarContext = React.createContext(null) + +function useSidebarContext(componentName: string): SidebarContextValue { + const context = React.useContext(SidebarContext) + if (context === null) { + throw new Error(`${componentName} must be rendered inside .`) + } + return context +} + +// +// Sidebar (provider) +// + +type SidebarProps = { + /** + * The side the sidebar attaches to. Controls the slide direction + */ + align: SidebarAlign + + /** + * Whether the sidebar is open + */ + isOpen: boolean + + /** + * Identifies the sidebar instance. Applied as the `id` of the + * `` panel. Auto-generated when omitted. + */ + id?: string + + /** + * Controlled width in px + */ + width?: number + + /** + * Lower bound for the controlled width + */ + minWidth?: number + + /** + * Upper bound for the controlled width + */ + maxWidth?: number + + /** + * The content of the panel via `` + */ + children?: React.ReactNode +} + +/** + * The host for a sidebar instance + */ +function Sidebar({ align, isOpen, id, width, minWidth, maxWidth, children }: SidebarProps) { + const generatedId = React.useId() + const panelId = id ?? generatedId + const panelRef = React.useRef(null) + + const contextValue: SidebarContextValue = { + align, + isOpen, + panelId, + panelRef, + width, + minWidth, + maxWidth, + } + + return {children} +} + +// +// 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. `