{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "resizable-panels",
  "type": "registry:ui",
  "title": "Resizable Panels",
  "description": "Split panes with draggable, keyboard-operable dividers, px or % limits, snapping collapsible panels and nested groups.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/resizable-panels.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/** A size: a number is a percentage of the group, or a string such as \"240px\" or \"30%\". */\nexport type PanelSize = number | `${number}px` | `${number}%`;\n\nexport interface ResizablePanel {\n  /** Stable id; also used for the panel's DOM id (aria-controls). */\n  id: string;\n  content: React.ReactNode;\n  /** Accessible name used for the separator (\"Resize Sidebar\"). Defaults to the id. */\n  label?: string;\n  /** Initial size in percent. Panels without one share the remaining space. */\n  defaultSize?: number;\n  minSize?: PanelSize;\n  maxSize?: PanelSize;\n  /** Can be collapsed by double-clicking or pressing Enter on a separator, by dragging past half its minimum, or via useResizablePanel(). */\n  collapsible?: boolean;\n  /** Size while collapsed (e.g. \"44px\" for an icon rail). Defaults to 0. */\n  collapsedSize?: PanelSize;\n  /** Start collapsed. */\n  defaultCollapsed?: boolean;\n  className?: string;\n}\n\nexport interface ResizablePanelsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  panels: ResizablePanel[];\n  /** Split direction: \"horizontal\" places panels side by side. */\n  direction?: \"horizontal\" | \"vertical\";\n  /** Initial sizes in percent (one per panel). Pass saved values from onLayout to restore a layout. */\n  defaultSizes?: number[];\n  /** Called with the sizes in percent whenever a resize is committed (drag end, keyboard, collapse). */\n  onLayout?: (sizes: number[]) => void;\n  /** Separator look. */\n  handle?: \"line\" | \"grip\" | \"bar\";\n  /** Percent moved per arrow key press (Shift moves 4×). */\n  keyboardStep?: number;\n  /** Duration of collapse / expand and keyboard moves, in ms. Dragging always follows the pointer. */\n  collapseDuration?: number;\n  /** Separator highlight and focus colour. */\n  accent?: string;\n}\n\nexport interface ResizablePanelApi {\n  /** Current size in percent. */\n  size: number;\n  collapsed: boolean;\n  collapsible: boolean;\n  collapse: () => void;\n  expand: () => void;\n  toggle: () => void;\n}\n\nconst PanelContext = React.createContext<ResizablePanelApi | null>(null);\n\n/** Read and control the enclosing panel from its content (e.g. a collapse button in a header). */\nexport function useResizablePanel(): ResizablePanelApi {\n  const api = React.useContext(PanelContext);\n  if (!api) throw new Error(\"useResizablePanel must be used inside a ResizablePanels panel.\");\n  return api;\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*  Layout maths (all sizes in percent of the space shared by the panels)     */\n/* -------------------------------------------------------------------------- */\n\nconst EPS = 0.01;\n\ninterface Constraint {\n  min: number;\n  max: number;\n  collapsed: number;\n  collapsible: boolean;\n}\n\ninterface Layout {\n  sizes: number[];\n  collapsed: boolean[];\n}\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\nconst sum = (list: number[]) => list.reduce((a, b) => a + b, 0);\n\n/** Converts a size spec to percent. Pixel values need the measured space (0 before the first measure). */\nfunction toPercent(size: PanelSize | undefined, fallback: number, space: number) {\n  if (size === undefined) return fallback;\n  if (typeof size === \"number\") return size;\n  const n = parseFloat(size);\n  if (size.endsWith(\"%\")) return n;\n  return space > 0 ? (n / space) * 100 : fallback;\n}\n\nfunction constraintsFor(panels: ResizablePanel[], space: number): Constraint[] {\n  return panels.map((p) => {\n    const min = clamp(toPercent(p.minSize, 0, space), 0, 100);\n    const max = clamp(toPercent(p.maxSize, 100, space), min, 100);\n    return { min, max, collapsed: clamp(toPercent(p.collapsedSize, 0, space), 0, min), collapsible: !!p.collapsible };\n  });\n}\n\n/** Clamps every panel to its constraints and spreads any surplus or deficit, nearest to `pivot` first. */\nfunction fitLayout(sizes: number[], collapsed: boolean[], cons: Constraint[], pivot = -1): number[] {\n  const next = sizes.map((s, i) => (collapsed[i] ? cons[i].collapsed : clamp(s, cons[i].min, cons[i].max)));\n  let diff = 100 - sum(next);\n  const order = next\n    .map((_, i) => i)\n    .filter((i) => i !== pivot && !collapsed[i])\n    .sort((a, b) => (pivot < 0 ? b - a : Math.abs(a - pivot) - Math.abs(b - pivot) || a - b));\n  for (const i of order) {\n    if (Math.abs(diff) < EPS) break;\n    const room = diff > 0 ? cons[i].max - next[i] : cons[i].min - next[i];\n    const d = diff > 0 ? Math.min(diff, room) : Math.max(diff, room);\n    next[i] += d;\n    diff -= d;\n  }\n  // Impossible constraints: give the remainder to the pivot (or the last open panel) so sizes still add up.\n  if (Math.abs(diff) >= EPS) {\n    const target = pivot >= 0 ? pivot : Math.max(0, collapsed.lastIndexOf(false));\n    next[target] += diff;\n  }\n  return next;\n}\n\n/**\n * Moves separator `handle` (between panels handle and handle + 1) by `delta` percent from `base`.\n * The panel in front of the move grows; panels behind it shrink in order, down to their minimums.\n * A collapsible neighbour dragged past half its minimum snaps shut, and a collapsed one snaps open.\n */\nfunction moveHandle(base: number[], handle: number, delta: number, cons: Constraint[], keyboard = false): number[] {\n  if (Math.abs(delta) < EPS) return base;\n  const forward = delta > 0;\n  const grower = forward ? handle : handle + 1;\n  const chain: number[] = [];\n  if (forward) for (let i = handle + 1; i < base.length; i++) chain.push(i);\n  else for (let i = handle; i >= 0; i--) chain.push(i);\n\n  const g = cons[grower];\n  const growerCollapsed = g.collapsible && base[grower] <= g.collapsed + EPS;\n  let amount = Math.abs(delta);\n  if (growerCollapsed) {\n    // Pointer drags must pass half the minimum to snap open; a key press opens straight away.\n    if (!keyboard && amount < (g.min - g.collapsed) / 2) return base;\n    amount = Math.max(amount, g.min - g.collapsed);\n  }\n  const cap = g.max - base[grower];\n  amount = Math.min(amount, cap);\n  if (amount < EPS) return base;\n\n  const next = base.slice();\n  let remaining = amount;\n  chain.forEach((i, k) => {\n    if (remaining < EPS) return;\n    const c = cons[i];\n    const isCollapsed = c.collapsible && base[i] <= c.collapsed + EPS;\n    if (k === 0 && c.collapsible && !isCollapsed) {\n      const target = base[i] - Math.abs(delta);\n      const freed = base[i] - c.collapsed;\n      if (target < (c.min + c.collapsed) / 2 && freed <= cap + EPS) {\n        next[i] = c.collapsed;\n        remaining -= freed;\n        return;\n      }\n    }\n    const floor = isCollapsed ? c.collapsed : c.min;\n    const freed = Math.min(remaining, Math.max(0, base[i] - floor));\n    next[i] = base[i] - freed;\n    remaining -= freed;\n  });\n\n  const given = amount - remaining;\n  if (growerCollapsed && given < g.min - g.collapsed - EPS) return base;\n  next[grower] = base[grower] + given;\n  return next;\n}\n\nfunction initialLayout(panels: ResizablePanel[], defaultSizes?: number[]): Layout {\n  const collapsed = panels.map((p) => !!(p.collapsible && p.defaultCollapsed));\n  const cons = constraintsFor(panels, 0);\n  const given = panels.map((p, i) => (collapsed[i] ? cons[i].collapsed : (defaultSizes?.[i] ?? p.defaultSize)));\n  const fixed = sum(given.filter((s): s is number => s !== undefined));\n  const open = given.filter((s) => s === undefined).length;\n  const share = open ? Math.max(0, 100 - fixed) / open : 0;\n  const sizes = given.map((s) => s ?? share);\n  return { sizes: fitLayout(sizes, collapsed, cons), collapsed };\n}\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/* -------------------------------------------------------------------------- */\n/*  Component                                                                 */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Split panes with draggable, keyboard-operable separators. Supports any number of panels, px or %\n * min / max sizes, collapsible panels with snapping, nested groups and an onLayout callback for persistence.\n */\nexport function ResizablePanels({\n  panels,\n  direction = \"horizontal\",\n  defaultSizes,\n  onLayout,\n  handle = \"grip\",\n  keyboardStep = 5,\n  collapseDuration = 220,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: ResizablePanelsProps) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const horizontal = direction === \"horizontal\";\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const panelRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const handleRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const restoreRef = React.useRef(new Map<number, number>());\n  const dragRef = React.useRef<{ handle: number; start: number; base: number[]; latest: number[]; cons: Constraint[]; space: number } | null>(null);\n  const [layout, setLayout] = React.useState<Layout>(() => initialLayout(panels, defaultSizes));\n  const [space, setSpace] = React.useState(0);\n\n  // Keep the layout in step with the panel count if panels are added or removed.\n  const [panelCount, setPanelCount] = React.useState(panels.length);\n  if (panels.length !== panelCount) {\n    setPanelCount(panels.length);\n    setLayout(initialLayout(panels, defaultSizes));\n  }\n\n  const cons = constraintsFor(panels, space);\n  const { sizes, collapsed } = layout;\n  // Effective limits for a separator's panel, given what every other panel can give or take.\n  const floorOf = (c: Constraint) => (c.collapsible ? c.collapsed : c.min);\n  const ariaLimits = (i: number) => {\n    const others = cons.filter((_, j) => j !== i);\n    const min = Math.max(floorOf(cons[i]), 100 - sum(others.map((c) => c.max)));\n    const max = Math.min(cons[i].max, 100 - sum(others.map(floorOf)));\n    return { min: Math.round(min), max: Math.round(Math.max(min, max)) };\n  };\n  const panelId = (i: number) => `${uid}-${panels[i]?.id ?? i}`;\n\n  /** Space shared by the panels (group size minus separators), in px. */\n  const measureSpace = React.useCallback(() => {\n    let total = 0;\n    for (const el of panelRefs.current) if (el) total += horizontal ? el.offsetWidth : el.offsetHeight;\n    return total;\n  }, [horizontal]);\n\n  // Re-measure on resize so pixel constraints stay true as the group grows or shrinks.\n  const onResize = React.useEffectEvent(() => {\n    const next = measureSpace();\n    if (next <= 0) return;\n    setSpace((prev) => (Math.abs(prev - next) < 0.5 ? prev : next));\n    setLayout((prev) => {\n      const fitted = fitLayout(prev.sizes, prev.collapsed, constraintsFor(panels, next));\n      return fitted.every((s, i) => Math.abs(s - prev.sizes[i]) < EPS) ? prev : { ...prev, sizes: fitted.map(round) };\n    });\n  });\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const ro = new ResizeObserver(() => onResize());\n    ro.observe(root);\n    return () => ro.disconnect();\n  }, []);\n\n  const commit = (nextSizes: number[], c: Constraint[], before: number[]) => {\n    const nextCollapsed = nextSizes.map((s, i) => c[i].collapsible && s <= c[i].collapsed + EPS);\n    nextCollapsed.forEach((isCollapsed, i) => {\n      // Remember the open size so expanding returns to it.\n      if (isCollapsed && !collapsed[i] && before[i] > c[i].collapsed + EPS) restoreRef.current.set(i, before[i]);\n    });\n    const rounded = nextSizes.map(round);\n    setLayout({ sizes: rounded, collapsed: nextCollapsed });\n    onLayout?.(rounded);\n  };\n\n  const currentConstraints = () => constraintsFor(panels, measureSpace() || space);\n\n  /* -------------------------------- collapse -------------------------------- */\n\n  const setCollapsed = (index: number, shut: boolean) => {\n    const c = currentConstraints();\n    if (!c[index]?.collapsible || collapsed[index] === shut) return;\n    const nextCollapsed = collapsed.slice();\n    nextCollapsed[index] = shut;\n    const next = sizes.slice();\n    if (shut) {\n      restoreRef.current.set(index, sizes[index]);\n      next[index] = c[index].collapsed;\n    } else {\n      const remembered = restoreRef.current.get(index) ?? panels[index].defaultSize ?? c[index].min * 2;\n      next[index] = clamp(remembered, Math.max(c[index].min, EPS), c[index].max);\n    }\n    const fitted = fitLayout(next, nextCollapsed, c, index).map(round);\n    setLayout({ sizes: fitted, collapsed: nextCollapsed });\n    onLayout?.(fitted);\n  };\n\n  /** The panel a separator collapses: the one before it if collapsible, otherwise the one after. */\n  const collapseTarget = (handleIndex: number) =>\n    panels[handleIndex]?.collapsible ? handleIndex : panels[handleIndex + 1]?.collapsible ? handleIndex + 1 : -1;\n\n  /* --------------------------------- pointer --------------------------------- */\n\n  const applyToDom = (next: number[], c: Constraint[]) => {\n    next.forEach((s, i) => {\n      const el = panelRefs.current[i];\n      if (el) el.style.flexGrow = String(s);\n    });\n    handleRefs.current.forEach((el, i) => {\n      if (el) {\n        el.setAttribute(\"aria-valuenow\", String(Math.round(next[i])));\n        el.toggleAttribute(\"data-collapsed\", c[i].collapsible && next[i] <= c[i].collapsed + EPS);\n      }\n    });\n  };\n\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>, index: number) => {\n    if (event.button !== 0 || event.detail > 1) return;\n    // Not preventDefault: the native mousedown focuses the separator without a keyboard focus ring.\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const s = measureSpace() || space;\n    dragRef.current = {\n      handle: index,\n      start: horizontal ? event.clientX : event.clientY,\n      base: sizes,\n      latest: sizes,\n      cons: constraintsFor(panels, s),\n      space: s,\n    };\n    rootRef.current?.setAttribute(\"data-dragging\", \"\");\n  };\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current;\n    if (!drag || drag.space <= 0) return;\n    const delta = (((horizontal ? event.clientX : event.clientY) - drag.start) / drag.space) * 100;\n    // Writes straight to the DOM; React state is committed once, on release.\n    drag.latest = moveHandle(drag.base, drag.handle, delta, drag.cons);\n    applyToDom(drag.latest, drag.cons);\n  };\n\n  const endDrag = () => {\n    const drag = dragRef.current;\n    if (!drag) return;\n    dragRef.current = null;\n    rootRef.current?.removeAttribute(\"data-dragging\");\n    if (drag.latest !== drag.base) commit(drag.latest, drag.cons, drag.base);\n  };\n\n  /* -------------------------------- keyboard -------------------------------- */\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>, index: number) => {\n    const c = currentConstraints();\n    const step = keyboardStep * (event.shiftKey ? 4 : 1);\n    const before = sizes[index];\n    let delta = 0;\n    switch (event.key) {\n      case horizontal ? \"ArrowLeft\" : \"ArrowUp\":\n        delta = -step;\n        break;\n      case horizontal ? \"ArrowRight\" : \"ArrowDown\":\n        delta = step;\n        break;\n      case \"Home\":\n        delta = (c[index].collapsible ? c[index].collapsed : c[index].min) - before;\n        break;\n      case \"End\":\n        delta = c[index].max - before;\n        break;\n      case \"Enter\": {\n        const target = collapseTarget(index);\n        if (target >= 0) {\n          event.preventDefault();\n          setCollapsed(target, !collapsed[target]);\n        }\n        return;\n      }\n      default:\n        return;\n    }\n    event.preventDefault();\n    const next = moveHandle(sizes, index, delta, c, true);\n    if (next !== sizes) commit(next, c, sizes);\n  };\n\n  /* --------------------------------- render --------------------------------- */\n\n  const duration = reduceMotion ? 0 : collapseDuration;\n\n  return (\n    <div\n      ref={rootRef}\n      className={`group/rp flex h-full w-full min-h-0 min-w-0 ${horizontal ? \"flex-row\" : \"flex-col\"} data-[dragging]:select-none ${className}`}\n      style={{ [\"--rp-accent\" as string]: accent, [\"--rp-duration\" as string]: `${duration}ms`, ...style }}\n      {...props}\n    >\n      {panels.map((panel, i) => {\n        const size = sizes[i] ?? 0;\n        const api: ResizablePanelApi = {\n          size: round(size),\n          collapsed: !!collapsed[i],\n          collapsible: !!panel.collapsible,\n          collapse: () => setCollapsed(i, true),\n          expand: () => setCollapsed(i, false),\n          toggle: () => setCollapsed(i, !collapsed[i]),\n        };\n        const last = i === panels.length - 1;\n        return (\n          <React.Fragment key={panel.id}>\n            <div\n              ref={(node) => {\n                panelRefs.current[i] = node;\n              }}\n              id={panelId(i)}\n              data-panel={panel.id}\n              data-collapsed={collapsed[i] || undefined}\n              className={`relative min-h-0 min-w-0 overflow-hidden transition-[flex-grow] duration-[var(--rp-duration)] ease-[cubic-bezier(0.22,1,0.36,1)] in-data-[dragging]:transition-none ${panel.className ?? \"\"}`}\n              style={{ flexGrow: round(size), flexShrink: 1, flexBasis: 0 }}\n            >\n              <PanelContext.Provider value={api}>{panel.content}</PanelContext.Provider>\n            </div>\n            {!last && (\n              <div\n                ref={(node) => {\n                  handleRefs.current[i] = node;\n                }}\n                role=\"separator\"\n                tabIndex={0}\n                aria-orientation={horizontal ? \"vertical\" : \"horizontal\"}\n                aria-controls={panelId(i)}\n                aria-label={`Resize ${panel.label ?? panel.id}`}\n                aria-valuenow={Math.round(size)}\n                aria-valuemin={ariaLimits(i).min}\n                aria-valuemax={ariaLimits(i).max}\n                data-collapsed={(cons[i].collapsible && collapsed[i]) || undefined}\n                onPointerDown={(event) => onPointerDown(event, i)}\n                onPointerMove={onPointerMove}\n                onPointerUp={endDrag}\n                onPointerCancel={endDrag}\n                onLostPointerCapture={endDrag}\n                onDoubleClick={() => {\n                  const target = collapseTarget(i);\n                  if (target >= 0) setCollapsed(target, !collapsed[target]);\n                }}\n                onKeyDown={(event) => onKeyDown(event, i)}\n                className={`group/handle relative z-10 flex flex-none touch-none items-center justify-center outline-hidden ${\n                  horizontal ? \"w-px cursor-col-resize flex-col\" : \"h-px cursor-row-resize flex-row\"\n                } bg-zinc-900/10 dark:bg-white/10`}\n              >\n                {/* Generous invisible hit area around the 1px line. */}\n                <span aria-hidden=\"true\" className={`absolute ${horizontal ? \"-inset-x-1.5 inset-y-0\" : \"-inset-y-1.5 inset-x-0\"}`} />\n                {/* Accent line on hover, focus and drag. */}\n                <span\n                  aria-hidden=\"true\"\n                  className={`pointer-events-none absolute bg-[var(--rp-accent)] opacity-0 transition-opacity duration-150 group-hover/handle:opacity-100 group-active/handle:opacity-100 ${\n                    horizontal ? \"inset-y-0 -left-px w-[3px]\" : \"inset-x-0 -top-px h-[3px]\"\n                  }`}\n                />\n                {handle !== \"line\" && (\n                  <span\n                    aria-hidden=\"true\"\n                    className={`pointer-events-none relative flex items-center justify-center gap-[3px] rounded-full border border-zinc-900/10 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.06)] transition-colors group-hover/handle:border-[var(--rp-accent)] group-focus-visible/handle:border-[var(--rp-accent)] dark:border-white/15 dark:bg-zinc-900 ${\n                      handle === \"grip\"\n                        ? horizontal\n                          ? \"h-7 w-2.5 flex-col\"\n                          : \"h-2.5 w-7 flex-row\"\n                        : horizontal\n                          ? \"h-12 w-1.5\"\n                          : \"h-1.5 w-12\"\n                    }`}\n                  >\n                    {handle === \"grip\" &&\n                      [0, 1, 2].map((dot) => <span key={dot} className=\"size-[3px] rounded-full bg-zinc-400 dark:bg-zinc-500\" />)}\n                  </span>\n                )}\n                {/* Focus ring, drawn around the hit area. */}\n                <span\n                  aria-hidden=\"true\"\n                  className={`pointer-events-none absolute rounded-sm opacity-0 outline-2 outline-solid outline-[var(--rp-accent)] group-focus-visible/handle:opacity-100 ${\n                    horizontal ? \"-inset-x-1 inset-y-1\" : \"-inset-y-1 inset-x-1\"\n                  }`}\n                />\n              </div>\n            )}\n          </React.Fragment>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/resizable-panels"
}
