{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"splitter","type":"registry:ui","title":"Splitter","description":"Resizable split panes with a draggable handle, min/max limits, and full keyboard resizing.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/splitter.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useId, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from \"react\";\n\nexport interface SplitterProps {\n  /** First pane (left or top). */\n  start: ReactNode;\n  /** Second pane (right or bottom). */\n  end: ReactNode;\n  /** \"horizontal\" puts panes side by side, \"vertical\" stacks them. */\n  direction?: \"horizontal\" | \"vertical\";\n  /** Initial size of the first pane, in % of the container. */\n  defaultSize?: number;\n  /** Smallest size of the first pane, in %. */\n  minSize?: number;\n  /** Largest size of the first pane, in %. */\n  maxSize?: number;\n  /** % moved per arrow key press (Shift moves 4x). */\n  step?: number;\n  /** Accessible name for the handle. */\n  label?: string;\n  onResize?: (size: number) => void;\n  className?: string;\n}\n\nconst round = (v: number) => Math.round(v * 10) / 10;\n\nexport function Splitter({\n  start,\n  end,\n  direction = \"horizontal\",\n  defaultSize = 28,\n  minSize = 15,\n  maxSize = 85,\n  step = 5,\n  label = \"Resize panels\",\n  onResize,\n  className,\n}: SplitterProps) {\n  const lo = Math.min(minSize, maxSize);\n  const hi = Math.max(minSize, maxSize);\n  const clamp = (v: number) => Math.min(hi, Math.max(lo, v));\n  const horizontal = direction === \"horizontal\";\n\n  const [size, setSize] = useState(() => clamp(defaultSize));\n  // Playground/prop changes reset the split (render-time sync, no effect needed).\n  const [prev, setPrev] = useState({ defaultSize, lo, hi });\n  if (prev.defaultSize !== defaultSize || prev.lo !== lo || prev.hi !== hi) {\n    setPrev({ defaultSize, lo, hi });\n    setSize(clamp(defaultSize));\n  }\n\n  const root = useRef<HTMLDivElement>(null);\n  const handle = useRef<HTMLDivElement>(null);\n  const live = useRef(size);\n  const dragging = useRef(false);\n  const id = useId();\n\n  // During a drag we write straight to the DOM; React only hears about the final size.\n  const paint = (v: number) => {\n    live.current = v;\n    root.current?.style.setProperty(\"--split\", `${v}%`);\n    handle.current?.setAttribute(\"aria-valuenow\", String(Math.round(v)));\n  };\n\n  const commit = (v: number) => {\n    const next = round(clamp(v));\n    paint(next);\n    setSize(next);\n    onResize?.(next);\n  };\n\n  const fromPointer = (e: PointerEvent) => {\n    const r = root.current!.getBoundingClientRect();\n    const p = horizontal ? (e.clientX - r.left) / r.width : (e.clientY - r.top) / r.height;\n    return clamp(p * 100);\n  };\n\n  const onDown = (e: PointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0) return;\n    e.preventDefault();\n    e.currentTarget.setPointerCapture(e.pointerId);\n    e.currentTarget.focus({ preventScroll: true });\n    dragging.current = true;\n    root.current!.dataset.dragging = \"\";\n  };\n  const onMove = (e: PointerEvent<HTMLDivElement>) => {\n    if (dragging.current) paint(fromPointer(e));\n  };\n  const onUp = () => {\n    if (!dragging.current) return;\n    dragging.current = false;\n    delete root.current!.dataset.dragging;\n    commit(live.current);\n  };\n\n  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    const s = e.shiftKey ? step * 4 : step;\n    const back = horizontal ? \"ArrowLeft\" : \"ArrowUp\";\n    const fwd = horizontal ? \"ArrowRight\" : \"ArrowDown\";\n    let next: number | null = null;\n    if (e.key === back) next = size - s;\n    else if (e.key === fwd) next = size + s;\n    else if (e.key === \"Home\") next = lo;\n    else if (e.key === \"End\") next = hi;\n    // Enter toggles between collapsed-to-min and the default split.\n    else if (e.key === \"Enter\") next = size > lo ? lo : defaultSize;\n    if (next === null) return;\n    e.preventDefault();\n    commit(next);\n  };\n\n  return (\n    <div\n      ref={root}\n      className={`group/splitter relative flex h-full w-full overflow-hidden ${horizontal ? \"flex-row\" : \"flex-col\"} ${className ?? \"\"}`}\n      style={{ [\"--split\" as string]: `${size}%` }}\n    >\n      <style href=\"splitter\" precedence=\"default\">{`\n        .splitter-pane{transition:flex-basis .32s cubic-bezier(.2,.8,.2,1)}\n        [data-dragging] .splitter-pane{transition:none}\n        [data-dragging]{cursor:var(--splitter-cursor);user-select:none}\n        @media (prefers-reduced-motion: reduce){.splitter-pane{transition:none}}\n      `}</style>\n      <div id={`${id}-a`} className=\"splitter-pane min-h-0 min-w-0 overflow-auto\" style={{ flex: \"0 0 var(--split)\" }}>\n        {start}\n      </div>\n\n      <div\n        ref={handle}\n        role=\"separator\"\n        tabIndex={0}\n        aria-label={label}\n        aria-controls={`${id}-a`}\n        aria-orientation={horizontal ? \"vertical\" : \"horizontal\"}\n        aria-valuenow={Math.round(size)}\n        aria-valuemin={Math.round(lo)}\n        aria-valuemax={Math.round(hi)}\n        onPointerDown={onDown}\n        onPointerMove={onMove}\n        onPointerUp={onUp}\n        onPointerCancel={onUp}\n        onKeyDown={onKey}\n        onDoubleClick={() => commit(defaultSize)}\n        className={`group/handle relative z-10 flex shrink-0 touch-none items-center justify-center outline-none ${\n          horizontal ? \"-mx-1.5 w-3 cursor-col-resize flex-col\" : \"-my-1.5 h-3 cursor-row-resize flex-row\"\n        }`}\n        style={{ [\"--splitter-cursor\" as string]: horizontal ? \"col-resize\" : \"row-resize\" }}\n      >\n        {/* Hairline that brightens on hover/drag/focus. */}\n        <span\n          aria-hidden\n          className={`absolute bg-border transition-[background-color,box-shadow] duration-200 group-hover/handle:bg-foreground/35 group-focus-visible/handle:bg-ring group-data-[dragging]/splitter:bg-ring group-data-[dragging]/splitter:shadow-[0_0_12px_var(--ring)] ${\n            horizontal ? \"inset-y-0 left-1/2 w-px -translate-x-1/2\" : \"inset-x-0 top-1/2 h-px -translate-y-1/2\"\n          }`}\n        />\n        {/* Grip pill. */}\n        <span\n          aria-hidden\n          className={`relative flex items-center justify-center gap-[3px] rounded-full border border-border bg-card shadow-sm transition-[transform,border-color] duration-200 group-hover/handle:scale-110 group-hover/handle:border-foreground/30 group-focus-visible/handle:border-ring group-focus-visible/handle:ring-2 group-focus-visible/handle:ring-ring/40 group-data-[dragging]/splitter:scale-110 group-data-[dragging]/splitter:border-ring ${\n            horizontal ? \"h-8 w-3 flex-col\" : \"h-3 w-8 flex-row\"\n          }`}\n        >\n          {[0, 1, 2].map((i) => (\n            <span key={i} className=\"size-[3px] rounded-full bg-muted-foreground\" />\n          ))}\n        </span>\n      </div>\n\n      <div className=\"splitter-pane min-h-0 min-w-0 flex-1 overflow-auto\">{end}</div>\n    </div>\n  );\n}\n"}]}