// // Copyright 2026 DXOS.org // import { useComposedRefs } from '@radix-ui/react-compose-refs'; import { createContext } from '@radix-ui/react-context'; import { Primitive } from '@radix-ui/react-primitive'; import { Slot } from '@radix-ui/react-slot'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import React, { type CSSProperties, type KeyboardEvent, type PointerEvent, type RefObject, useCallback, useEffect, useRef, useState, } from 'react'; import { type SlottableProps } from '@dxos/ui-types'; import { useThemeContext } from '../../hooks'; import { composableProps, slottable } from '../../util'; type SplitterOrientation = 'horizontal' | 'vertical'; // Animated panel visibility: collapse to the start panel, the end panel, or show both split at `size`. type SplitterMode = 'start' | 'end' | 'split'; type Position = 'start' | 'end'; // // Context // const SPLITTER_NAME = 'Splitter'; type SplitterContextValue = { orientation: SplitterOrientation; mode: SplitterMode; /** Which panel `size` measures; the other panel fills the remainder. */ anchor: Position; /** The anchored panel's extent in rem, or `undefined` for an even (50/50) split. */ size: number | undefined; transition: number; resizable: boolean; /** Lower bound (rem) applied to both panels. */ minSize: number; dragging: boolean; /** True only briefly after a `mode` change, so the collapse animates but layout reflows (resize) do not. */ animating: boolean; rootRef: RefObject; setSize: (size: number) => void; setDragging: (dragging: boolean) => void; }; const [SplitterProvider, useSplitterContext] = createContext(SPLITTER_NAME); const getRem = (): number => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; // Lower-bound clamp applied to both panels: the start panel can't drop below `minSize` nor exceed the // container minus `minSize` (reserving room for the end panel, which also carries the min). const clampStyle = (minSize: number, orientation: SplitterOrientation): CSSProperties => orientation === 'horizontal' ? { minInlineSize: `${minSize}rem`, maxInlineSize: `calc(100% - ${minSize}rem)` } : { minBlockSize: `${minSize}rem`, maxBlockSize: `calc(100% - ${minSize}rem)` }; const endMinStyle = (minSize: number, orientation: SplitterOrientation): CSSProperties => orientation === 'horizontal' ? { minInlineSize: `${minSize}rem` } : { minBlockSize: `${minSize}rem` }; // In `split` mode the anchored panel is fixed at `size` rem (clamped) and the other fills the rest; when // `size` is undefined both panels grow equally (50/50). Otherwise one panel fills (flex-grow) and the // other collapses to zero (content stays mounted, clipped). const panelStyle = ( position: Position, mode: SplitterMode, anchor: Position, size: number | undefined, minSize: number, orientation: SplitterOrientation, ): CSSProperties => { if (mode !== 'split') { const fills = (mode === 'start' && position === 'start') || (mode === 'end' && position === 'end'); return { flexGrow: fills ? 1 : 0, flexShrink: 1, flexBasis: 0 }; } // No explicit size: split evenly. if (size === undefined) { return { flexGrow: 1, flexShrink: 1, flexBasis: 0, ...endMinStyle(minSize, orientation) }; } return position === anchor ? { flexGrow: 0, flexShrink: 1, flexBasis: `${size}rem`, ...clampStyle(minSize, orientation) } : { flexGrow: 1, flexShrink: 1, flexBasis: 0, ...endMinStyle(minSize, orientation) }; }; // Absolute position for the handle, centered on the split point (`size` rem from the anchored edge, or // the middle for an even split). The offset is clamped to the same bounds as the anchored panel // (`minSize` .. `100% - minSize`) so a sudden container shrink (e.g. opening devtools) that caps the // panel keeps the handle pinned to the panel edge instead of floating into the other panel. const handlePosition = ( orientation: SplitterOrientation, anchor: Position, size: number | undefined, minSize: number, ): CSSProperties => { if (size === undefined) { return orientation === 'horizontal' ? { insetBlock: 0, insetInlineStart: '50%', transform: 'translateX(-50%)' } : { insetInline: 0, insetBlockStart: '50%', transform: 'translateY(-50%)' }; } const offset = `clamp(${minSize}rem, ${size}rem, calc(100% - ${minSize}rem))`; if (orientation === 'horizontal') { return anchor === 'end' ? { insetBlock: 0, insetInlineEnd: offset, transform: 'translateX(50%)' } : { insetBlock: 0, insetInlineStart: offset, transform: 'translateX(-50%)' }; } return anchor === 'end' ? { insetInline: 0, insetBlockEnd: offset, transform: 'translateY(50%)' } : { insetInline: 0, insetBlockStart: offset, transform: 'translateY(-50%)' }; }; // // Root // const ROOT_NAME = 'Splitter.Root'; type SplitterRootElementProps = { orientation?: SplitterOrientation; mode?: SplitterMode; /** Which panel `size` measures (defaults to `start`); the other panel fills the remainder. */ anchor?: Position; /** The anchored panel's extent in rem (controlled). */ size?: number; defaultSize?: number; onSizeChange?: (size: number) => void; transition?: number; resizable?: boolean; /** Lower bound (rem) applied to both panels. */ minSize?: number; }; type SplitterRootProps = SlottableProps; const SplitterRoot = slottable( ( { asChild, children, orientation = 'vertical', mode = 'split', anchor = 'start', size: sizeProp, defaultSize, onSizeChange, transition = 250, resizable = false, minSize = 0, ...props }, forwardedRef, ) => { const { tx } = useThemeContext(); const rootRef = useRef(null); const composedRef = useComposedRefs(forwardedRef, rootRef); const [size, setSize] = useControllableState({ prop: sizeProp, defaultProp: defaultSize, onChange: onSizeChange, }); const [dragging, setDragging] = useState(false); // Animate ONLY for a brief window right after a `mode` change (the collapse). The rest of the time the // transition is off, so layout reflows from a container/window resize never animate (no jitter) — this // avoids relying on observing/throttling resize events at all. const [animating, setAnimating] = useState(false); const previousMode = useRef(mode); useEffect(() => { if (previousMode.current === mode) { return; } previousMode.current = mode; if (transition <= 0) { return; } setAnimating(true); const timer = setTimeout(() => setAnimating(false), transition); return () => clearTimeout(timer); }, [mode, transition]); const { className, ...rest } = composableProps(props); const Comp = asChild ? Slot : Primitive.div; return ( {children} ); }, ); SplitterRoot.displayName = ROOT_NAME; // // Panel // const PANEL_NAME = 'Splitter.Panel'; type SplitterPanelProps = SlottableProps<{ position: Position }>; const SplitterPanel = slottable( ({ asChild, children, position, ...props }, forwardedRef) => { const { tx } = useThemeContext(); const { orientation, mode, anchor, size, minSize, transition, dragging, animating } = useSplitterContext(PANEL_NAME); const { className, style, ...rest } = composableProps(props); const Comp = asChild ? Slot : Primitive.div; // Only animate during the brief post-mode-change window (collapse), never while dragging or on a plain // container/window resize — so the panels track layout reflows instantly without jitter. const animate = transition > 0 && animating && !dragging; return ( {children} ); }, ); SplitterPanel.displayName = PANEL_NAME; // // Handle // const HANDLE_NAME = 'Splitter.Handle'; type SplitterHandleProps = SlottableProps; const SplitterHandle = slottable(({ asChild, children, ...props }, forwardedRef) => { const { tx } = useThemeContext(); const { orientation, anchor, size, resizable, minSize, rootRef, setSize, setDragging } = useSplitterContext(HANDLE_NAME); const { className, ...rest } = composableProps(props); const Comp = asChild ? Slot : Primitive.div; // Container extent (rem) along the split axis; the upper bound is `extent - minSize`. const extentRem = useCallback((): number => { const rect = rootRef.current?.getBoundingClientRect(); if (!rect) { return (size ?? minSize) + minSize; } return (orientation === 'horizontal' ? rect.width : rect.height) / getRem(); }, [orientation, rootRef, size, minSize]); const handlePointerDown = useCallback( (event: PointerEvent) => { if (event.button !== 0) { return; } event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); }, [setDragging], ); // Map the pointer position within the Root to the anchored panel's extent (rem), clamped so neither // panel drops below `minSize`. For an `end` anchor the extent is measured from the far edge. const handlePointerMove = useCallback( (event: PointerEvent) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) { return; } const root = rootRef.current; if (!root) { return; } const rect = root.getBoundingClientRect(); const rem = getRem(); const offset = (orientation === 'horizontal' ? event.clientX - rect.left : event.clientY - rect.top) / rem; const extent = (orientation === 'horizontal' ? rect.width : rect.height) / rem; const next = anchor === 'end' ? extent - offset : offset; setSize(Math.min(Math.max(next, minSize), Math.max(minSize, extent - minSize))); }, [orientation, anchor, minSize, rootRef, setSize], ); const handlePointerUp = useCallback( (event: PointerEvent) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } setDragging(false); }, [setDragging], ); // Arrow keys nudge the start panel's size (rem); Home/End jump to the bounds. // Keeps the resize affordance usable without a pointer. const handleKeyDown = useCallback( (event: KeyboardEvent) => { const horizontal = orientation === 'horizontal'; const step = event.shiftKey ? 4 : 1; // Start from the current size, or the midpoint when the split is even (unsized). const base = size ?? extentRem() / 2; let next: number | undefined; if (event.key === (horizontal ? 'ArrowRight' : 'ArrowDown')) { next = base + step; } else if (event.key === (horizontal ? 'ArrowLeft' : 'ArrowUp')) { next = base - step; } else if (event.key === 'Home') { next = minSize; } else if (event.key === 'End') { next = extentRem() - minSize; } if (next === undefined) { return; } event.preventDefault(); setSize(Math.min(Math.max(next, minSize), Math.max(minSize, extentRem() - minSize))); }, [orientation, size, minSize, extentRem, setSize], ); if (!resizable) { return null; } return ( {children} ); }); SplitterHandle.displayName = HANDLE_NAME; // // Splitter // const Splitter = { Root: SplitterRoot, Panel: SplitterPanel, Handle: SplitterHandle, }; export { Splitter }; export type { SplitterHandleProps, SplitterMode, SplitterOrientation, SplitterPanelProps, SplitterRootProps };