"use client"; import { animate, type MotionValue, motion, motionValue, useReducedMotion, useTransform, } from "motion/react"; import { Children, type KeyboardEvent, type PointerEvent, type ReactElement, type ReactNode, isValidElement, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { SPRING_LAYOUT } from "@/lib/ease"; import { capturePointer, releasePointer, TOUCH_GESTURE_CLASS } from "@/lib/touch"; import { cn } from "@/lib/utils"; export type ResizableDirection = "horizontal" | "vertical"; export interface ResizablePanelProps { /** Initial size in percent. Panels without one share what is left. */ defaultSize?: number; /** Minimum size in percent while expanded. */ minSize?: number; /** Maximum size in percent. */ maxSize?: number; /** Snap shut when dragged below `collapseThreshold`. */ collapsible?: boolean; /** Size in percent the panel rests at when collapsed. */ collapsedSize?: number; /** Drag below this size (percent) to collapse. Defaults to half of `minSize`. */ collapseThreshold?: number; /** Accessible name for the handle that resizes this panel (the handle after it). */ handleLabel?: string; className?: string; children?: ReactNode; } /** * Declarative panel. `ResizablePanels` reads these props and renders the * panel itself, so this component only renders when used on its own. */ export function ResizablePanel({ className, children }: ResizablePanelProps) { return
{children}
; } export interface ResizablePanelsProps { direction?: ResizableDirection; /** Sizes in percent (controlled), one per panel, summing to 100. */ sizes?: number[]; /** Overrides each panel's `defaultSize`. */ defaultSizes?: number[]; onSizesChange?: (sizes: number[]) => void; /** Show a grip pill on each handle. */ withHandle?: boolean; className?: string; /** `ResizablePanel` elements. Handles are inserted between them. */ children: ReactNode; } type PanelConfig = Required< Pick< ResizablePanelProps, "minSize" | "maxSize" | "collapsible" | "collapsedSize" | "collapseThreshold" > > & { defaultSize?: number; handleLabel?: string; className?: string; children?: ReactNode; }; const KEY_STEP = 2; const KEY_STEP_LARGE = 10; const EPSILON = 0.01; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const round = (value: number) => Math.round(value * 100) / 100; function resolveDefaults(configs: PanelConfig[], override?: number[]) { if (override && override.length === configs.length) return override; const fixed = configs.reduce((sum, c) => sum + (c.defaultSize ?? 0), 0); const free = configs.filter((c) => c.defaultSize === undefined).length; const share = free ? Math.max(0, 100 - fixed) / free : 0; return configs.map((c) => c.defaultSize ?? share); } function PanelFrame({ id, size, config, collapsed, }: { id: string; size: MotionValue; config: PanelConfig; collapsed: boolean; }) { // Flex-grow proportional to the percentage: handles keep their pixel width // and the panels split exactly what is left. const flex = useTransform(size, (v) => `${Math.max(v, 0)} 1 0px`); return ( {config.children} ); } export function ResizablePanels({ direction = "horizontal", sizes: sizesProp, defaultSizes, onSizesChange, withHandle = true, className, children, }: ResizablePanelsProps) { const reduce = useReducedMotion(); const baseId = useId(); const containerRef = useRef(null); const horizontal = direction === "horizontal"; const configs = useMemo( () => Children.toArray(children) .filter((child): child is ReactElement => isValidElement(child), ) .map(({ props }) => { const minSize = props.minSize ?? 10; return { defaultSize: props.defaultSize, minSize, maxSize: props.maxSize ?? 100, collapsible: props.collapsible ?? false, collapsedSize: props.collapsedSize ?? 0, collapseThreshold: props.collapseThreshold ?? ((props.collapsedSize ?? 0) + minSize) / 2, handleLabel: props.handleLabel, className: props.className, children: props.children, }; }), [children], ); const [initial] = useState(() => resolveDefaults(configs, defaultSizes)); const [internal, setInternal] = useState(initial); const controlled = sizesProp !== undefined; const sizes = controlled ? sizesProp : internal; // One motion value per panel drives layout; React state stays the source // of truth for aria values. Created once per panel slot. const motionSizes = useRef[]>([]); if (motionSizes.current.length !== configs.length) { motionSizes.current = configs.map( (_, i) => motionSizes.current[i] ?? motionValue(sizes[i] ?? 0), ); } const targets = useRef([...sizes]); const animateNext = useRef(new Set()); useEffect(() => { sizes.forEach((size, i) => { const mv = motionSizes.current[i]; if (!mv || Math.abs((targets.current[i] ?? -1) - size) < EPSILON) return; targets.current[i] = size; if (animateNext.current.has(i) && !reduce) { animate(mv, size, SPRING_LAYOUT); } else { mv.stop(); mv.set(size); } }); animateNext.current.clear(); }, [sizes, reduce]); const commit = useCallback( (next: number[], animated: number[] = []) => { for (const i of animated) animateNext.current.add(i); const rounded = next.map(round); if (!controlled) setInternal(rounded); onSizesChange?.(rounded); }, [controlled, onSizesChange], ); const isCollapsed = useCallback( (i: number, value = sizes[i] ?? 0) => { const config = configs[i]; return Boolean( config?.collapsible && value <= config.collapsedSize + EPSILON, ); }, [configs, sizes], ); /** * Resolve a requested size for panel `i` against the pair (i, i + 1): * clamps to min/max on both sides and snaps collapsible panels shut below * their threshold. */ const resolvePair = useCallback( (base: number[], i: number, requested: number) => { const a = configs[i]; const b = configs[i + 1]; if (!a || !b) return base; const total = (base[i] ?? 0) + (base[i + 1] ?? 0); const settle = (config: PanelConfig, raw: number) => { if (raw < config.minSize) { if (config.collapsible && raw < config.collapseThreshold) { return config.collapsedSize; } return config.minSize; } return Math.min(raw, config.maxSize); }; let nextA = settle(a, clamp(requested, 0, total)); let nextB = settle(b, total - nextA); nextA = total - nextB; // If B's limits pushed A out of its own range, re-settle A and give B the rest. if (nextA > a.maxSize || (nextA < a.minSize && !isCollapsedFor(a, nextA))) { nextA = settle(a, nextA); nextB = total - nextA; } const next = [...base]; next[i] = nextA; next[i + 1] = nextB; return next; }, [configs], ); const drag = useRef<{ index: number; pointerId: number; start: number; length: number; base: number[]; } | null>(null); const onPointerDown = (event: PointerEvent, index: number) => { if (event.button !== 0) return; const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; event.preventDefault(); event.currentTarget.focus(); capturePointer(event.currentTarget, event.pointerId); drag.current = { index, pointerId: event.pointerId, start: horizontal ? event.clientX : event.clientY, length: horizontal ? rect.width : rect.height, base: [...sizes], }; }; const onPointerMove = (event: PointerEvent) => { const state = drag.current; if (!state || state.pointerId !== event.pointerId || !state.length) return; const delta = (((horizontal ? event.clientX : event.clientY) - state.start) / state.length) * 100; const next = resolvePair( state.base, state.index, (state.base[state.index] ?? 0) + delta, ); if (next.every((v, i) => Math.abs(v - (sizes[i] ?? 0)) < EPSILON)) return; // Snaps animate only when the collapsed state flips relative to now. const pair = [state.index, state.index + 1]; const flipped = pair.some((i) => isCollapsed(i, next[i]) !== isCollapsed(i)); commit(next, flipped ? pair : []); }; const endDrag = (event: PointerEvent) => { if (drag.current?.pointerId !== event.pointerId) return; releasePointer(event.currentTarget, event.pointerId); drag.current = null; }; const resizeBy = (index: number, requested: number) => { commit(resolvePair(sizes, index, requested), [index, index + 1]); }; const onKeyDown = (event: KeyboardEvent, index: number) => { const config = configs[index]; if (!config) return; const current = sizes[index] ?? 0; const step = event.shiftKey ? KEY_STEP_LARGE : KEY_STEP; const decrease = horizontal ? "ArrowLeft" : "ArrowUp"; const increase = horizontal ? "ArrowRight" : "ArrowDown"; switch (event.key) { case decrease: event.preventDefault(); // From the minimum, one more step snaps a collapsible panel shut. resizeBy( index, config.collapsible && current - step < config.minSize ? config.collapsedSize : current - step, ); return; case increase: event.preventDefault(); resizeBy( index, isCollapsed(index) ? Math.max(config.minSize, current + step) : current + step, ); return; case "Home": event.preventDefault(); resizeBy(index, config.collapsible ? config.collapsedSize : config.minSize); return; case "End": event.preventDefault(); resizeBy(index, config.maxSize); return; case "Enter": if (!config.collapsible) return; event.preventDefault(); resizeBy( index, isCollapsed(index) ? (initial[index] ?? config.minSize) > config.collapsedSize ? (initial[index] ?? config.minSize) : config.minSize : config.collapsedSize, ); return; default: return; } }; const resetAll = () => { commit( [...initial], initial.map((_, i) => i), ); }; const items: ReactNode[] = []; configs.forEach((config, i) => { const mv = motionSizes.current[i]; if (!mv) return; const panelId = `${baseId}-panel-${i}`; items.push( , ); if (i < configs.length - 1) { const value = Math.round(sizes[i] ?? 0); items.push( // biome-ignore lint/a11y/useSemanticElements: a focusable window splitter (WAI-ARIA) is a separator widget;
cannot take focus or children.
onPointerDown(event, i)} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} onKeyDown={(event) => onKeyDown(event, i)} onDoubleClick={resetAll} className={cn( "group/handle relative z-10 flex shrink-0 touch-none items-center justify-center bg-border outline-none", TOUCH_GESTURE_CLASS, horizontal ? "w-px cursor-col-resize before:absolute before:inset-y-0 before:-inset-x-1.5 before:content-['']" : "h-px cursor-row-resize before:absolute before:inset-x-0 before:-inset-y-1.5 before:content-['']", "transition-colors hover:bg-ring/60 focus-visible:bg-ring", )} > {withHandle ? ( ) : null}
, ); } }); return (
{items}
); } function isCollapsedFor(config: PanelConfig, value: number) { return config.collapsible && value <= config.collapsedSize + EPSILON; }