import { useStore } from '@nanostores/react' import { Children, type CSSProperties, isValidElement, type ReactElement, type ReactNode, type PointerEvent as ReactPointerEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { cn } from '@/lib/utils' import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes' import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context' type PaneSide = 'left' | 'right' type WidthValue = string | number interface PaneRoleMarker { __paneShellRole?: 'pane' | 'main' } export interface PaneProps { children?: ReactNode className?: string defaultOpen?: boolean /** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */ disabled?: boolean /** * When the pane is collapsed, reveal its contents as a fixed overlay on hover * (or keyboard focus) instead of leaving it fully hidden. The overlay floats * over the main content — it does not reserve grid space, so the collapsed * track stays at 0px. */ hoverReveal?: boolean /** Called with the reveal state whenever a collapsed hoverReveal pane floats in/out. */ onHoverRevealChange?: (revealed: boolean) => void id: string maxWidth?: WidthValue minWidth?: WidthValue resizable?: boolean side: PaneSide width?: WidthValue } export interface PaneMainProps { children?: ReactNode className?: string } export interface PaneShellProps { children?: ReactNode className?: string style?: CSSProperties } interface CollectedPane { defaultOpen: boolean disabled: boolean id: string resizable: boolean side: PaneSide width: string } const DEFAULT_WIDTH = '16rem' const DEFAULT_RESIZE_MIN_WIDTH = 160 const widthToCss = (value: WidthValue | undefined, fallback: string) => value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value const remPx = () => typeof window === 'undefined' ? 16 : Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16 // Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping. function widthToPx(value: WidthValue | undefined) { if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined } const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/) if (!match) { return undefined } return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1) } function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement { return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role } function collectPanes(children: ReactNode) { const left: CollectedPane[] = [] const right: CollectedPane[] = [] let mainCount = 0 Children.forEach(children, child => { if (isRole(child, 'main')) { mainCount++ return } if (!isRole(child, 'pane')) { return } const props = child.props as PaneProps const entry: CollectedPane = { defaultOpen: props.defaultOpen ?? true, disabled: props.disabled ?? false, id: props.id, resizable: props.resizable ?? false, side: props.side, width: widthToCss(props.width, DEFAULT_WIDTH) } ;(props.side === 'left' ? left : right).push(entry) }) return { left, mainCount, right } } function trackForPane(pane: CollectedPane, states: Record) { const stateOpen = states[pane.id]?.open ?? pane.defaultOpen const open = !pane.disabled && stateOpen if (!open) { return { open: false, track: '0px' } } const override = pane.resizable ? states[pane.id]?.widthOverride : undefined return { open: true, track: override !== undefined ? `${override}px` : pane.width } } export function PaneShell({ children, className, style }: PaneShellProps) { const paneStates = useStore($paneStates) const { left, mainCount, right } = useMemo(() => collectPanes(children), [children]) if (import.meta.env.DEV && mainCount > 1) { console.warn('[PaneShell] expected at most one , got', mainCount) } const ctxValue = useMemo(() => { const paneById = new Map() const tracks: string[] = [] const cssVars: Record = {} let column = 1 for (const pane of left) { const { open, track } = trackForPane(pane, paneStates) tracks.push(track) paneById.set(pane.id, { column, open, side: 'left' }) cssVars[`--pane-${pane.id}-width`] = track column++ } tracks.push('minmax(0,1fr)') const mainColumn = column++ for (const pane of right) { const { open, track } = trackForPane(pane, paneStates) tracks.push(track) paneById.set(pane.id, { column, open, side: 'right' }) cssVars[`--pane-${pane.id}-width`] = track column++ } return { cssVars, gridTemplate: tracks.join(' '), mainColumn, paneById } satisfies PaneShellContextValue & { cssVars: Record gridTemplate: string } }, [left, paneStates, right]) const composedStyle = useMemo( () => ({ ...ctxValue.cssVars, ...style, gridTemplateColumns: ctxValue.gridTemplate }), [ctxValue.cssVars, ctxValue.gridTemplate, style] ) return (
{children}
) } export function Pane({ children, className, defaultOpen = true, disabled = false, hoverReveal = false, id, maxWidth, minWidth, onHoverRevealChange, resizable = false, width }: PaneProps) { const ctx = useContext(PaneShellContext) const paneStates = useStore($paneStates) const registered = useRef(false) const paneRef = useRef(null) const [hoverRevealed, setHoverRevealed] = useState(false) useEffect(() => { if (registered.current) { return } registered.current = true ensurePaneRegistered(id, { open: defaultOpen }) }, [defaultOpen, id]) const slot = ctx?.paneById.get(id) const open = Boolean(slot?.open && !disabled) const canResize = open && resizable const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY const side = slot?.side ?? 'left' // Collapsed + hoverReveal: float the pane contents over the main column on // hover/focus instead of leaving them fully hidden. Honors any persisted // resize width so the overlay matches the pane's expanded size. const overlayActive = !open && hoverReveal && !disabled const overlayWidth = resizable && paneStates[id]?.widthOverride !== undefined ? `${paneStates[id]?.widthOverride}px` : widthToCss(width, DEFAULT_WIDTH) // Collapse the reveal whenever the pane reopens or gets disabled so it never // sticks "open" underneath the now-expanded track. useEffect(() => { if (!overlayActive && hoverRevealed) { setHoverRevealed(false) } }, [overlayActive, hoverRevealed]) // Surface the effective reveal state to consumers (e.g. so the sidebar can // render its full content while floated, not just while the track is open). useEffect(() => { onHoverRevealChange?.(overlayActive && hoverRevealed) }, [onHoverRevealChange, overlayActive, hoverRevealed]) const startResize = useCallback( (event: ReactPointerEvent) => { const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0 if (!canResize || paneWidth <= 0) { return } event.preventDefault() const handle = event.currentTarget const { pointerId, clientX: startX } = event const dir = side === 'left' ? 1 : -1 const restoreCursor = document.body.style.cursor const restoreSelect = document.body.style.userSelect handle.setPointerCapture?.(pointerId) document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' const onMove = (e: PointerEvent) => { const next = paneWidth + (e.clientX - startX) * dir setPaneWidthOverride(id, Math.round(Math.min(hi, Math.max(lo, next)))) } const cleanup = () => { document.body.style.cursor = restoreCursor document.body.style.userSelect = restoreSelect handle.releasePointerCapture?.(pointerId) window.removeEventListener('pointermove', onMove, true) window.removeEventListener('pointerup', cleanup, true) window.removeEventListener('pointercancel', cleanup, true) window.removeEventListener('blur', cleanup) } window.addEventListener('pointermove', onMove, true) window.addEventListener('pointerup', cleanup, true) window.addEventListener('pointercancel', cleanup, true) window.addEventListener('blur', cleanup) }, [canResize, hi, id, lo, side] ) if (!ctx) { if (import.meta.env.DEV) { console.warn(`[Pane:${id}] must be rendered inside `) } return null } if (!slot) { return null } // Collapsed hover-reveal track: keep the grid cell at 0px (no reserved space) // but don't clip — the trigger strip and the floating overlay both escape the // zero-width box via absolute positioning, so the panel renders over the main // content rather than pushing it. if (overlayActive) { const revealEdge = slot.side === 'left' ? { left: 0 } : { right: 0 } return (
{/* Edge hot-zone: hovering it (or moving onto the revealed overlay) keeps the panel open. Sits above main content but is invisible. */}
) } return (
{canResize && (
)} {children}
) } ;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane' export function PaneMain({ children, className }: PaneMainProps) { const ctx = useContext(PaneShellContext) if (!ctx) { if (import.meta.env.DEV) { console.warn('[PaneMain] must be rendered inside ') } return null } return (
{children}
) } ;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main'