{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"resizable-clean","type":"registry:ui","title":"Resizable (Clean)","description":"Panes side by side or stacked with a line between them you can drag – sizes in percent, a smallest and largest size, panes that fold away, arrow keys, Home, End and Enter on the handle, a double-click to reset","dependencies":[],"files":[{"path":"registry/resizable-clean/resizable-clean.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentPropsWithRef,\n  type CSSProperties,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type Ref,\n} from \"react\";\n\n/*\n * Resizable – a single self-contained file, clean style.\n * Panes side by side or stacked, with a hairline between them you can drag: a sidebar and a work area, a list and its\n * detail, an editor over its output. Sizes are in percent, so the layout fits any width; each pane can have a\n * smallest and a largest size, and a collapsible one snaps shut when dragged past half its minimum. The handle is a\n * real separator: arrow keys move it, Home and End go to the limits, Enter folds a pane away and back, a double-click\n * puts it back where it started.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type ResizablePanelGroupProps = ComponentPropsWithRef<\"div\"> & {\n  /** horizontal: panes side by side · vertical: stacked */\n  direction?: \"horizontal\" | \"vertical\";\n  /** Called with every pane's size in % (in order) after the user resizes – store it to bring the layout back */\n  onLayout?: (sizes: number[]) => void;\n  /** How far an arrow key moves a handle, in % (Shift: 2 ×) */\n  keyboardStep?: number;\n};\n\nexport type ResizablePanelProps = ComponentPropsWithRef<\"div\"> & {\n  /** The size it starts at, in % of the group; panes without one share what is left */\n  defaultSize?: number;\n  /** The smallest it gets, in % */\n  minSize?: number;\n  /** The largest it gets, in % */\n  maxSize?: number;\n  /** Snaps to collapsedSize when dragged past half its minimum – Enter on its handle folds it and brings it back */\n  collapsible?: boolean;\n  /** Its size when folded, in % – 0 hides it; a little more keeps an icon rail */\n  collapsedSize?: number;\n};\n\nexport type ResizableHandleProps = ComponentPropsWithRef<\"div\"> & {\n  /** A small grip on the line, so it reads as draggable */\n  withHandle?: boolean;\n};\n\ntype Config = { min: number; max: number; collapsible: boolean; collapsed: number; initial?: number };\ntype Group = {\n  direction: \"horizontal\" | \"vertical\";\n  sizes: Record<string, number>;\n  configs: Map<string, Config>;\n  register: () => () => void;\n  /** The panes' sizes in order, right now */\n  read: () => number[];\n  /** Changes the sizes around a handle – change gets them in order, the panes' limits and the index of the pane before it */\n  resize: (handle: HTMLElement, change: (sizes: number[], cfg: Config[], at: number) => number[] | null, animate?: boolean) => void;\n  dragging: (on: boolean) => void;\n  step: number;\n};\n\nconst Ctx = createContext<Group | null>(null);\nconst EPS = 0.01;\nconst PANEL = \"data-rszc-panel\";\n\n/* ---------- Group ---------- */\n\nexport function ResizablePanelGroup({ direction = \"horizontal\", onLayout, keyboardStep = 5, className, style, children, ref, ...rest }: ResizablePanelGroupProps) {\n  const el = useRef<HTMLDivElement | null>(null);\n  const configs = useRef(new Map<string, Config>()).current;\n  const [sizes, setSizes] = useState<Record<string, number>>({});\n  const sizesRef = useRef(sizes);\n  sizesRef.current = sizes;\n  const [panes, setPanes] = useState(0);\n  const layout = useRef(onLayout);\n  layout.current = onLayout;\n  const snap = useRef(0);\n\n  /** The panes in the order they are on screen – only the group's own, not those of a group inside */\n  const order = useCallback(() => (el.current ? [...el.current.children].filter((c) => c.hasAttribute(PANEL)).map((c) => c.getAttribute(PANEL)!) : []), []);\n\n  // Every pane gets a size before paint: its defaultSize, or a share of what is left; then all of them add up to 100\n  useLayoutEffect(() => {\n    const ids = order();\n    const cur = sizesRef.current;\n    if (!ids.length) return;\n    // Limits of panes that are gone\n    for (const id of [...configs.keys()]) if (!ids.includes(id)) configs.delete(id);\n    const known = ids.filter((id) => cur[id] != null || configs.get(id)?.initial != null);\n    const given = known.reduce((s, id) => s + (cur[id] ?? configs.get(id)!.initial!), 0);\n    const share = ids.length > known.length ? Math.max(0, 100 - given) / (ids.length - known.length) : 0;\n    let next = ids.map((id) => cur[id] ?? configs.get(id)?.initial ?? share);\n    const total = next.reduce((a, b) => a + b, 0) || 1;\n    next = next.map((s) => (s * 100) / total);\n    const same = ids.length === Object.keys(cur).length && ids.every((id, i) => Math.abs((cur[id] ?? -1) - next[i]) < EPS);\n    if (!same) setSizes(Object.fromEntries(ids.map((id, i) => [id, next[i]])));\n  }, [panes, order, configs]);\n\n  // A pane coming or going: the sizes are worked out again (one that's gone hands its share to the others)\n  const register = useCallback(() => {\n    setPanes((n) => n + 1);\n    return () => setPanes((n) => n + 1);\n  }, []);\n\n  const group = useMemo<Group>(\n    () => ({\n      direction,\n      sizes,\n      configs,\n      step: keyboardStep,\n      register,\n      read: () => order().map((id) => sizesRef.current[id] ?? 0),\n      dragging: (on) => el.current?.toggleAttribute(\"data-resizing\", on),\n      resize: (handle, change, animate) => {\n        const ids = order();\n        const before = prevPanel(handle);\n        const at = before ? ids.indexOf(before) : -1;\n        if (at < 0 || at >= ids.length - 1) return;\n        const cur = ids.map((id) => sizesRef.current[id] ?? 0);\n        const next = change(cur, ids.map((id) => configs.get(id) ?? { min: 0, max: 100, collapsible: false, collapsed: 0 }), at);\n        if (!next || next.every((s, i) => Math.abs(s - cur[i]) < EPS)) return;\n        // Keys, Enter and double-click glide; a drag follows the pointer exactly\n        const g = el.current;\n        if (g && animate) {\n          g.setAttribute(\"data-snap\", \"\");\n          window.clearTimeout(snap.current);\n          snap.current = window.setTimeout(() => g.removeAttribute(\"data-snap\"), 240);\n        }\n        const out = Object.fromEntries(ids.map((id, i) => [id, next[i]]));\n        sizesRef.current = out;\n        setSizes(out);\n        layout.current?.(next.map((s) => Math.round(s * 100) / 100));\n      },\n    }),\n    [direction, sizes, configs, keyboardStep, register, order],\n  );\n\n  const setRef = useCallback(\n    (node: HTMLDivElement | null) => {\n      el.current = node;\n      assign(ref, node);\n    },\n    [ref],\n  );\n\n  return (\n    <Ctx.Provider value={group}>\n      {/* React 19 hoists this into <head> once, no matter how many groups render */}\n      <style href=\"ui-resizable-clean\" precedence=\"default\">\n        {css}\n      </style>\n      <div ref={setRef} className={[\"ui-rszc\", className].filter(Boolean).join(\" \")} data-direction={direction} style={style} {...rest}>\n        {children}\n      </div>\n    </Ctx.Provider>\n  );\n}\n\n/* ---------- Panel ---------- */\n\nexport function ResizablePanel({ defaultSize, minSize = 0, maxSize = 100, collapsible = false, collapsedSize = 0, id, className, style, children, ...rest }: ResizablePanelProps) {\n  const group = useContext(Ctx);\n  const auto = useId();\n  const key = id ?? auto;\n  const min = clamp(minSize, 0, 100);\n  // Written during render, so the group reads this render's limits on the next drag\n  group?.configs.set(key, { min, max: clamp(maxSize, min, 100), collapsible, collapsed: clamp(collapsedSize, 0, min), initial: defaultSize });\n\n  useLayoutEffect(() => {\n    if (!group) return;\n    return group.register();\n  }, [key]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const size = group?.sizes[key] ?? defaultSize;\n  const cfg = group?.configs.get(key);\n  const folded = !!cfg?.collapsible && size != null && size <= cfg.collapsed + EPS;\n  // Folded all the way: out of the Tab order and away from screen readers too\n  const hidden = folded && size! <= EPS;\n\n  return (\n    <div\n      id={key}\n      className={[\"ui-rszc__panel\", className].filter(Boolean).join(\" \")}\n      {...{ [PANEL]: key }}\n      data-collapsed={folded ? \"\" : undefined}\n      inert={hidden || undefined}\n      style={{ flex: `${size ?? 1} 1 0px`, ...style } as CSSProperties}\n      {...rest}\n    >\n      {children}\n    </div>\n  );\n}\n\n/* ---------- Handle ---------- */\n\nexport function ResizableHandle({ withHandle = false, className, \"aria-label\": ariaLabel, ...rest }: ResizableHandleProps) {\n  const group = useContext(Ctx);\n  const self = useRef<HTMLDivElement | null>(null);\n  const start = useRef<{ pos: number; px: number; sizes: number[]; sign: number } | null>(null);\n  // Sizes to come back to after Enter folded a pane, by pane id\n  const restore = useRef<Record<string, number>>({});\n  const [near, setNear] = useState<{ before?: string; after?: string }>({});\n  const [active, setActive] = useState(false);\n  const horizontal = (group?.direction ?? \"horizontal\") === \"horizontal\";\n\n  // Which panes it sits between – read from the page, so conditional panes are fine\n  useLayoutEffect(() => {\n    const el = self.current;\n    if (!el) return;\n    const before = prevPanel(el) ?? undefined;\n    const after = nextPanel(el) ?? undefined;\n    if (before !== near.before || after !== near.after) setNear({ before, after });\n  });\n\n  if (!group) return null;\n  const cfg = near.before ? group.configs.get(near.before) : undefined;\n  const now = near.before ? group.sizes[near.before] : undefined;\n\n  const rtl = () => horizontal && !!self.current && getComputedStyle(self.current).direction === \"rtl\";\n\n  const down = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const el = self.current;\n    const g = el?.parentElement;\n    if (e.button !== 0 || !el || !g) return;\n    e.preventDefault();\n    el.focus({ preventScroll: true });\n    // The panes' own pixels (without the handles), corrected for a zoomed or scaled card\n    const rect = g.getBoundingClientRect();\n    const scale = horizontal ? rect.width / (g.offsetWidth || 1) : rect.height / (g.offsetHeight || 1);\n    const px = [...g.children].filter((c) => c.hasAttribute(PANEL)).reduce((s, c) => s + (horizontal ? (c as HTMLElement).offsetWidth : (c as HTMLElement).offsetHeight), 0) * (scale || 1);\n    start.current = { pos: horizontal ? e.clientX : e.clientY, px: Math.max(1, px), sizes: group.read(), sign: rtl() ? -1 : 1 };\n    el.setPointerCapture(e.pointerId);\n    setActive(true);\n    group.dragging(true);\n  };\n  const move = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const s = start.current;\n    if (!s || !self.current || !s.sizes.length) return;\n    const delta = (((horizontal ? e.clientX : e.clientY) - s.pos) / s.px) * 100 * s.sign;\n    group.resize(self.current, (_, cfg, at) => shift(s.sizes, cfg, at, delta));\n  };\n  const up = () => {\n    if (!start.current) return;\n    start.current = null;\n    setActive(false);\n    group.dragging(false);\n  };\n\n  /** Folds the collapsible pane next to it, or brings it back to where it was */\n  const toggle = () => {\n    const el = self.current;\n    if (!el) return;\n    group.resize(\n      el,\n      (sizes, cfg, at) => {\n        // The pane before the handle first, else the one after it\n        const side = cfg[at].collapsible ? at : cfg[at + 1].collapsible ? at + 1 : -1;\n        if (side < 0) return null;\n        const c = cfg[side];\n        const id = side === at ? near.before : near.after;\n        const sign = side === at ? 1 : -1;\n        if (sizes[side] <= c.collapsed + EPS) {\n          const back = clamp(restore.current[id ?? \"\"] ?? c.initial ?? c.min, c.min, c.max);\n          return shift(sizes, cfg, at, (back - sizes[side]) * sign, true);\n        }\n        if (id) restore.current[id] = sizes[side];\n        return shift(sizes, cfg, at, (c.collapsed - sizes[side]) * sign);\n      },\n      true,\n    );\n  };\n\n  const key = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    const el = self.current;\n    if (!el) return;\n    const step = group.step * (e.shiftKey ? 2 : 1);\n    const back = horizontal ? (rtl() ? \"ArrowRight\" : \"ArrowLeft\") : \"ArrowUp\";\n    const fwd = horizontal ? (rtl() ? \"ArrowLeft\" : \"ArrowRight\") : \"ArrowDown\";\n    if (e.key === back || e.key === fwd) {\n      e.preventDefault();\n      group.resize(el, (sizes, cfg, at) => shift(sizes, cfg, at, e.key === fwd ? step : -step, true), true);\n    } else if (e.key === \"Home\" || e.key === \"End\") {\n      e.preventDefault();\n      group.resize(el, (sizes, cfg, at) => shift(sizes, cfg, at, (e.key === \"Home\" ? cfg[at].min : cfg[at].max) - sizes[at], true), true);\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      toggle();\n    }\n  };\n\n  // Back to where it started: the pane before it at its defaultSize\n  const reset = () => {\n    const el = self.current;\n    if (!el) return;\n    group.resize(el, (sizes, cfg, at) => (cfg[at].initial == null ? null : shift(sizes, cfg, at, cfg[at].initial - sizes[at], true)), true);\n  };\n\n  return (\n    <div\n      ref={self}\n      className={[\"ui-rszc__handle\", className].filter(Boolean).join(\" \")}\n      role=\"separator\"\n      tabIndex={0}\n      // Panes side by side are split by an upright line\n      aria-orientation={horizontal ? \"vertical\" : \"horizontal\"}\n      aria-label={ariaLabel ?? \"Resize\"}\n      aria-controls={near.before}\n      aria-valuenow={now != null ? Math.round(now) : undefined}\n      aria-valuemin={cfg ? Math.round(cfg.collapsible ? cfg.collapsed : cfg.min) : 0}\n      aria-valuemax={cfg ? Math.round(cfg.max) : 100}\n      data-dragging={active || undefined}\n      onPointerDown={down}\n      onPointerMove={move}\n      onPointerUp={up}\n      onPointerCancel={up}\n      onLostPointerCapture={up}\n      onDoubleClick={reset}\n      onKeyDown={key}\n      {...rest}\n    >\n      {withHandle && <span className=\"ui-rszc__grip\" aria-hidden=\"true\" />}\n    </div>\n  );\n}\n\n/* ---------- Helpers ---------- */\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** The pane right before / after a handle, by its id */\nfunction prevPanel(el: Element) {\n  let n = el.previousElementSibling;\n  while (n && !n.hasAttribute(PANEL)) n = n.previousElementSibling;\n  return n?.getAttribute(PANEL) ?? null;\n}\nfunction nextPanel(el: Element) {\n  let n = el.nextElementSibling;\n  while (n && !n.hasAttribute(PANEL)) n = n.nextElementSibling;\n  return n?.getAttribute(PANEL) ?? null;\n}\n\n/**\n * Moves the handle after pane `at` by `delta` % (positive: toward the end). The pane on the side it moves away from\n * grows up to its max; the panes on the other side give way in turn, each down to its min – the one next to the\n * handle snaps shut when it is collapsible and pushed past half its min. A folded pane opens straight to its min once\n * it is pulled past half of it (by the keyboard at once).\n */\nfunction shift(start: number[], cfg: Config[], at: number, delta: number, keyboard = false): number[] {\n  const next = start.slice();\n  if (Math.abs(delta) < EPS) return next;\n  const grow = delta > 0 ? at : at + 1;\n  const order: number[] = [];\n  if (delta > 0) for (let i = at + 1; i < start.length; i++) order.push(i);\n  else for (let i = at; i >= 0; i--) order.push(i);\n  const g = cfg[grow];\n  let want = Math.abs(delta);\n  const opening = g.collapsible && start[grow] <= g.collapsed + EPS && g.min > g.collapsed;\n  if (opening) {\n    if (!keyboard && want < (g.min - g.collapsed) / 2) return next;\n    want = Math.max(want, g.min - g.collapsed);\n  }\n  want = Math.min(want, g.max - start[grow]);\n  if (want <= EPS) return next;\n  let taken = 0;\n  for (const i of order) {\n    const c = cfg[i];\n    const need = want - taken;\n    if (need <= EPS) break;\n    const room = Math.max(0, start[i] - c.min);\n    const open = !(c.collapsible && start[i] <= c.collapsed + EPS);\n    // Next to the handle and pushed well past its min: it folds and hands over all of its room at once\n    if (i === order[0] && c.collapsible && open && need > room + (c.min - c.collapsed) / 2 && start[grow] + (start[i] - c.collapsed) <= g.max + EPS) {\n      taken += start[i] - c.collapsed;\n      next[i] = c.collapsed;\n      break;\n    }\n    if (!open) continue;\n    const take = Math.min(room, need);\n    next[i] = start[i] - take;\n    taken += take;\n  }\n  // A folded pane opens to at least its min, or not at all\n  if (opening && taken < g.min - g.collapsed - EPS) return start.slice();\n  next[grow] = start[grow] + taken;\n  return next;\n}\n\n/** Hands an element to a ref of yours, a function or an object */\nfunction assign<T>(ref: Ref<T> | undefined, el: T | null) {\n  if (typeof ref === \"function\") ref(el);\n  else if (ref) (ref as { current: T | null }).current = el;\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors as variables – override them on .ui-rszc (see Theming in the docs) */\n.ui-rszc {\n  --ui-rszc-line: var(--aui-border, rgba(0, 0, 0, 0.06));\n  --ui-rszc-line-active: rgba(0, 0, 0, 0.22);\n  --ui-rszc-grip: var(--aui-surface, #ffffff);\n  --ui-rszc-grip-edge: rgba(0, 0, 0, 0.1);\n  --ui-rszc-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-rszc-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n\n  /* Fills what it is put in; the panes share it by their sizes */\n  display: flex;\n  box-sizing: border-box;\n  width: 100%;\n  height: 100%;\n  min-width: 0;\n  min-height: 0;\n  overflow: hidden;\n}\n.ui-rszc[data-direction=\"vertical\"] { flex-direction: column; }\n.ui-rszc *, .ui-rszc *::before, .ui-rszc *::after { box-sizing: border-box; }\n.ui-rszc[data-resizing] { user-select: none; -webkit-user-select: none; }\n.ui-rszc[data-direction=\"horizontal\"][data-resizing] { cursor: col-resize; }\n.ui-rszc[data-direction=\"vertical\"][data-resizing] { cursor: row-resize; }\n/* While dragging, iframes and text under the pointer don't catch it */\n.ui-rszc[data-resizing] > .ui-rszc__panel { pointer-events: none; }\n\n/* A pane: its share of the room, never pushed wider by its content; it scrolls when the content doesn't fit */\n.ui-rszc__panel { position: relative; min-width: 0; min-height: 0; overflow: auto; }\n.ui-rszc[data-snap] > .ui-rszc__panel { transition: flex-grow 220ms var(--ui-rszc-ease); }\n\n/* The handle: a 0.5px hairline in the layout, a 12px grab strip over it */\n.ui-rszc__handle {\n  position: relative;\n  z-index: 1;\n  flex: none;\n  outline: none;\n  touch-action: none;\n  user-select: none;\n  border: 0 solid var(--ui-rszc-line);\n  transition: border-color 150ms ease;\n}\n/* A border, so it stays one crisp device pixel on any screen */\n.ui-rszc[data-direction=\"horizontal\"] > .ui-rszc__handle { width: 0; border-inline-start-width: 0.5px; cursor: col-resize; }\n.ui-rszc[data-direction=\"vertical\"] > .ui-rszc__handle { height: 0; border-top-width: 0.5px; cursor: row-resize; }\n.ui-rszc__handle::after { content: \"\"; position: absolute; border-radius: 4px; }\n.ui-rszc[data-direction=\"horizontal\"] > .ui-rszc__handle::after { top: 0; bottom: 0; left: -6px; right: -6px; }\n.ui-rszc[data-direction=\"vertical\"] > .ui-rszc__handle::after { left: 0; right: 0; top: -6px; bottom: -6px; }\n/* The line firms up under the pointer (after a short rest, so passing over it stays calm) and while dragging */\n.ui-rszc__handle:hover { border-color: var(--ui-rszc-line-active); transition-delay: 120ms; }\n.ui-rszc__handle[data-dragging], .ui-rszc__handle:focus-visible { border-color: var(--ui-rszc-line-active); transition-delay: 0ms; }\n/* Keyboard focus: the ring around the grab strip, inside it, so the panes' edges never cut it */\n.ui-rszc__handle:focus-visible::after { outline: 2px solid var(--ui-rszc-focus); outline-offset: -2px; }\n\n/* The grip: a small white pill on the line */\n.ui-rszc__grip {\n  position: absolute;\n  z-index: 1;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  border-radius: 999px;\n  background: var(--ui-rszc-grip);\n  box-shadow: 0 0 0 0.5px var(--ui-rszc-grip-edge), 0 1px 2px rgba(0, 0, 0, 0.06);\n  pointer-events: none;\n}\n.ui-rszc[data-direction=\"horizontal\"] .ui-rszc__grip { width: 6px; height: 24px; }\n.ui-rszc[data-direction=\"vertical\"] .ui-rszc__grip { width: 24px; height: 6px; }\n\n/* Touch screens: a wider strip for the finger and a bigger grip */\n@media (pointer: coarse) {\n  .ui-rszc[data-direction=\"horizontal\"] > .ui-rszc__handle::after { left: -12px; right: -12px; }\n  .ui-rszc[data-direction=\"vertical\"] > .ui-rszc__handle::after { top: -12px; bottom: -12px; }\n  .ui-rszc[data-direction=\"horizontal\"] .ui-rszc__grip { width: 8px; height: 32px; }\n  .ui-rszc[data-direction=\"vertical\"] .ui-rszc__grip { width: 32px; height: 8px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-rszc[data-snap] > .ui-rszc__panel { transition: none; }\n  .ui-rszc__handle { transition: none; }\n}\n\n/* Windows high contrast: the line keeps a system color */\n@media (forced-colors: active) {\n  .ui-rszc__handle { border-color: CanvasText; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-rszc {\n  --ui-rszc-line: var(--aui-border, rgba(255, 255, 255, 0.08));\n  --ui-rszc-line-active: rgba(255, 255, 255, 0.3);\n  --ui-rszc-grip: var(--aui-surface, #1f1f22);\n  --ui-rszc-grip-edge: rgba(255, 255, 255, 0.16);\n  --ui-rszc-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n}\n`;\n"}]}