From 7fdf7313afe7a5c02430dcce95a95b17789b7064 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sun, 28 Jun 2026 12:53:24 -0700 Subject: [PATCH 01/59] feat(sidebar): add the imperative resizable-panel engine 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 09426e3bf02ca86ac09a15b3594ea1c8cb7045e7 Mon Sep 17 00:00:00 2001 From: Frankie Yan Date: Sun, 28 Jun 2026 13:04:16 -0700 Subject: [PATCH 02/59] 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 | 57 ++++++++++ src/sidebar/sidebar.test.tsx | 96 +++++++++++++++++ src/sidebar/sidebar.tsx | 185 +++++++++++++++++++++++++++++++++ 5 files changed, 340 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..7810ee37 --- /dev/null +++ b/src/sidebar/sidebar.module.css @@ -0,0 +1,57 @@ +:root { + /* Overlay layering and viewport behaviour. */ + --reactist-sidebar-overlay-z-index: 40; + --reactist-sidebar-overlay-viewport-margin: 0px; + --reactist-sidebar-overlay-inset-block: 0px; + --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; + /* + * `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) { + .panel { + transition: none; + } +} diff --git a/src/sidebar/sidebar.test.tsx b/src/sidebar/sidebar.test.tsx new file mode 100644 index 00000000..cf7e099e --- /dev/null +++ b/src/sidebar/sidebar.test.tsx @@ -0,0 +1,96 @@ +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 =
Navigation
, + }: { contentProps?: Record; children?: React.ReactNode } = {}, +) { + return render( + + + {children} + + , + ) +} + +describe('Sidebar', () => { + it('renders the panel as an