{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "resizable",
  "type": "registry:ui",
  "title": "Resizable",
  "description": "Panels side by side or stacked, with a hairline between each two that follows the pointer one to one. Each panel keeps between its smallest and largest size, the arrow keys move the line, a double click puts it back, and autoSaveId keeps the layout.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/resizable.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/resizable 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Resizable: panels side by side, or one above another, with a line between them that a person drags to give one more\n * room: a file tree beside a log, a list beside its detail, a chat beside a preview (components.md, \"Resizable\").\n * ResizablePanelGroup, ResizablePanel and ResizableHandle, as shadcn names them.\n *\n * - Sizes are percents of the group, from each panel's defaultSize (the rest shared out evenly), kept between its\n *   minSize and maxSize. A handle moves the line between the two panels beside it, and only those two.\n * - The handle is a 1px --litt-line hairline, the look of every divider, with a 12px hit area (44px on a touch screen). It\n *   turns --litt-line-strong under the pointer and --litt-fg-subtle while it is dragged, on the instant tier. No grip and no\n *   dots: the cursor says it moves. Focus draws the 2px focus ring around it.\n * - Dragging follows the pointer one to one, with no easing and no snapping. A double click puts the two panels back\n *   to where they started.\n * - Keys, on the handle (a Tab stop): ← and → (↑ and ↓ when stacked) move the line by 5%, with Shift by 20%; Home and\n *   End give the panel before it its smallest and largest size.\n * - `autoSaveId` keeps the sizes in the browser, so the layout someone chose is there next time. `onLayout` hands them\n *   to you instead, to keep them anywhere.\n * - On a phone two panels side by side are rarely both readable: show one at a time there (a Segmented control or\n *   Tabs), and keep the group for wider screens.\n * - Accessibility: each handle is a separator with aria-valuenow (the size of the panel before it), aria-valuemin,\n *   aria-valuemax and aria-controls pointing at that panel, named by `label` (\"Resize the file tree\").\n * - Flat: no hairline; the handle is a small grey grip pill that darkens on hover and drag.\n */\nimport {\n  Children,\n  createContext,\n  isValidElement,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\n\ntype Direction = \"horizontal\" | \"vertical\";\n\ntype PanelSpec = { defaultSize?: number; minSize: number; maxSize: number };\n\ntype GroupCtx = {\n  direction: Direction;\n  sizes: number[];\n  specs: PanelSpec[];\n  ids: string[];\n  group: RefObject<HTMLDivElement | null>;\n  /** Set the panels either side of handle `before` (the panel before it) so the one before is `size`. */\n  resize: (before: number, size: number, start?: number[]) => void;\n  /** Put the two panels beside handle `before` back to their starting sizes. */\n  reset: (before: number) => void;\n  /** Tell the caller (and the browser's storage) the layout after a drag or a key. */\n  settle: () => void;\n};\n\nconst GroupContext = createContext<GroupCtx | null>(null);\nconst SlotContext = createContext<number>(0);\n\n/** Starting sizes: each panel's defaultSize, the rest shared evenly among the panels without one, scaled to 100. */\nfunction startingSizes(specs: PanelSpec[]): number[] {\n  const given = specs.reduce((sum, s) => sum + (s.defaultSize ?? 0), 0);\n  const open = specs.filter((s) => s.defaultSize === undefined).length;\n  const share = open ? Math.max(0, 100 - given) / open : 0;\n  const raw = specs.map((s) => s.defaultSize ?? share);\n  const total = raw.reduce((a, b) => a + b, 0) || 1;\n  return raw.map((v) => (v / total) * 100);\n}\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\nconst STORE = (id: string) => `litt-resizable:${id}`;\n\nexport type ResizablePanelGroupProps = {\n  /** Side by side (horizontal) or one above another (vertical). */\n  direction?: Direction;\n  /** Keep the sizes in the browser under this name, so the layout someone chose comes back. */\n  autoSaveId?: string;\n  /** Called with every panel's size, in percent, after a drag or a key. */\n  onLayout?: (sizes: number[]) => void;\n  /** ResizablePanels with a ResizableHandle between each two. */\n  children: ReactNode;\n  /** Extra classes for the group. Give it a height when it is horizontal and its panels scroll inside. */\n  className?: string;\n};\n\n/** The row or column of panels. Put ResizablePanels in it with a ResizableHandle between each two. */\nexport function ResizablePanelGroup({ direction = \"horizontal\", autoSaveId, onLayout, children, className = \"\" }: ResizablePanelGroupProps) {\n  const base = useId();\n  const parts = Children.toArray(children).filter(isValidElement) as ReactElement<Record<string, unknown>>[];\n  const panels = parts.filter((p) => p.type === ResizablePanel) as ReactElement<ResizablePanelProps>[];\n  const specs: PanelSpec[] = panels.map((p) => ({ defaultSize: p.props.defaultSize, minSize: p.props.minSize ?? 0, maxSize: p.props.maxSize ?? 100 }));\n  const ids = panels.map((p, i) => p.props.id ?? `${base}-panel-${i}`);\n  const [sizes, setSizes] = useState(() => startingSizes(specs));\n  const latest = useRef(sizes);\n  latest.current = sizes;\n  const group = useRef<HTMLDivElement>(null);\n\n  /* A saved layout comes back after the first render, so the server and the browser agree on the first one. */\n  useLayoutEffect(() => {\n    if (!autoSaveId) return;\n    try {\n      const saved = JSON.parse(localStorage.getItem(STORE(autoSaveId)) ?? \"null\") as unknown;\n      if (Array.isArray(saved) && saved.length === specs.length && saved.every((n) => typeof n === \"number\")) setSizes(saved as number[]);\n    } catch {\n      /* no storage, or something else stored there: keep the defaults */\n    }\n    // once, for this name\n  }, [autoSaveId]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  /* A panel added or removed: start over from the defaults. */\n  if (sizes.length !== specs.length) setSizes(startingSizes(specs));\n\n  const resize = (before: number, size: number, start = latest.current) => {\n    const a = specs[before]!;\n    const b = specs[before + 1]!;\n    const pair = start[before]! + start[before + 1]!;\n    let first = clamp(size, a.minSize, a.maxSize);\n    let second = pair - first;\n    const fixed = clamp(second, b.minSize, b.maxSize);\n    if (fixed !== second) {\n      second = fixed;\n      first = pair - second;\n    }\n    const next = [...start];\n    next[before] = first;\n    next[before + 1] = second;\n    setSizes(next);\n    latest.current = next;\n  };\n\n  const reset = (before: number) => {\n    const fresh = startingSizes(specs);\n    const now = latest.current;\n    const pair = now[before]! + now[before + 1]!;\n    const share = fresh[before]! / (fresh[before]! + fresh[before + 1]!);\n    resize(before, pair * share);\n    settle();\n  };\n\n  const settle = () => {\n    onLayout?.(latest.current);\n    if (!autoSaveId) return;\n    try {\n      localStorage.setItem(STORE(autoSaveId), JSON.stringify(latest.current.map((n) => Math.round(n * 100) / 100)));\n    } catch {\n      /* storage is full or off: the layout still holds for this visit */\n    }\n  };\n\n  let panelIndex = -1;\n  return (\n    <GroupContext value={{ direction, sizes, specs, ids, group, resize, reset, settle }}>\n      <div\n        ref={group}\n        data-direction={direction}\n        className={\"flex min-h-0 min-w-0 \" + (direction === \"horizontal\" ? \"flex-row\" : \"flex-col\") + \" \" + className}\n      >\n        {parts.map((part, i) => {\n          if (part.type === ResizablePanel) panelIndex++;\n          /* A panel knows its own place; a handle knows the panel before it. */\n          return (\n            <SlotContext key={part.key ?? i} value={panelIndex}>\n              {part}\n            </SlotContext>\n          );\n        })}\n      </div>\n    </GroupContext>\n  );\n}\n\nexport type ResizablePanelProps = {\n  /** Its starting size, in percent of the group. Panels without one share what is left. */\n  defaultSize?: number;\n  /** The smallest it may be, in percent. */\n  minSize?: number;\n  /** The largest it may be, in percent. */\n  maxSize?: number;\n  /** An id of your own, for aria-controls on its handle. */\n  id?: string;\n  /** What the panel holds. It clips to its size, so give a long list its own ScrollArea. */\n  children: ReactNode;\n  /** Extra classes for the panel. */\n  className?: string;\n};\n\n/** One panel. Its size comes from the group; it clips what it holds to that size. */\nexport function ResizablePanel({ children, className = \"\" }: ResizablePanelProps) {\n  const ctx = useContext(GroupContext);\n  const index = useContext(SlotContext);\n  if (!ctx) throw new Error(\"ResizablePanel must sit inside a ResizablePanelGroup.\");\n  const size = ctx.sizes[index] ?? 0;\n  return (\n    <div\n      id={ctx.ids[index]}\n      data-panel=\"\"\n      className={\"min-h-0 min-w-0 overflow-hidden \" + className}\n      style={{ flex: `${size} 1 0px` }}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport type ResizableHandleProps = {\n  /** What moving it does, for screen readers: \"Resize the file tree\". */\n  label?: string;\n  /** Extra classes for the handle. */\n  className?: string;\n};\n\n/** The line between two panels. Drag it, or focus it and use the arrow keys. A double click puts the two back. */\nexport function ResizableHandle({ label = \"Resize\", className = \"\" }: ResizableHandleProps) {\n  const ctx = useContext(GroupContext);\n  const before = useContext(SlotContext);\n  if (!ctx) throw new Error(\"ResizableHandle must sit inside a ResizablePanelGroup.\");\n  const { direction, sizes, specs, ids, group, resize, reset, settle } = ctx;\n  const [dragging, setDragging] = useState(false);\n  const horizontal = direction === \"horizontal\";\n  const spec = specs[before];\n  if (!spec || !specs[before + 1]) return null;\n\n  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0 || !group.current) return;\n    e.preventDefault();\n    const handle = e.currentTarget;\n    handle.focus({ preventScroll: true });\n    const panels = Array.from(group.current.children).filter((el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute(\"data-panel\"));\n    const room = panels.reduce((sum, el) => sum + (horizontal ? el.offsetWidth : el.offsetHeight), 0) || 1;\n    const rtl = horizontal && getComputedStyle(group.current).direction === \"rtl\";\n    const start = [...sizes];\n    const from = horizontal ? e.clientX : e.clientY;\n    handle.setPointerCapture(e.pointerId);\n    setDragging(true);\n    const cursor = document.body.style.cursor;\n    document.body.style.cursor = horizontal ? \"col-resize\" : \"row-resize\";\n\n    const move = (ev: PointerEvent) => {\n      const moved = ((horizontal ? ev.clientX : ev.clientY) - from) * (rtl ? -1 : 1);\n      resize(before, start[before]! + (moved / room) * 100, start);\n    };\n    const up = () => {\n      handle.removeEventListener(\"pointermove\", move);\n      handle.removeEventListener(\"pointerup\", up);\n      handle.removeEventListener(\"pointercancel\", up);\n      document.body.style.cursor = cursor;\n      setDragging(false);\n      settle();\n    };\n    handle.addEventListener(\"pointermove\", move);\n    handle.addEventListener(\"pointerup\", up);\n    handle.addEventListener(\"pointercancel\", up);\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const by = e.shiftKey ? 20 : 5;\n    const rtl = horizontal && getComputedStyle(e.currentTarget).direction === \"rtl\";\n    const grow = horizontal ? (rtl ? \"ArrowLeft\" : \"ArrowRight\") : \"ArrowDown\";\n    const shrink = horizontal ? (rtl ? \"ArrowRight\" : \"ArrowLeft\") : \"ArrowUp\";\n    const now = sizes[before]!;\n    const to = e.key === grow ? now + by : e.key === shrink ? now - by : e.key === \"Home\" ? spec.minSize : e.key === \"End\" ? spec.maxSize : null;\n    if (to === null) return;\n    e.preventDefault();\n    resize(before, to);\n    settle();\n  };\n\n  return (\n    <div\n      role=\"separator\"\n      tabIndex={0}\n      aria-label={label}\n      aria-orientation={horizontal ? \"vertical\" : \"horizontal\"}\n      aria-controls={ids[before]}\n      aria-valuenow={Math.round(sizes[before] ?? 0)}\n      aria-valuemin={spec.minSize}\n      aria-valuemax={spec.maxSize}\n      data-dragging={dragging ? \"\" : undefined}\n      onPointerDown={onPointerDown}\n      onKeyDown={onKeyDown}\n      onDoubleClick={() => reset(before)}\n      className={[\n        \"relative z-10 shrink-0 touch-none bg-litt-line [-webkit-tap-highlight-color:transparent]\",\n        /* Flat: no line; a small grey grip pill stands at the middle of the handle. */\n        \"in-[[data-material=flat]]:bg-transparent in-[[data-material=flat]]:hover:bg-transparent in-[[data-material=flat]]:data-[dragging]:bg-transparent in-[[data-material=flat]]:after:absolute in-[[data-material=flat]]:after:top-1/2 in-[[data-material=flat]]:after:left-1/2 in-[[data-material=flat]]:after:-translate-1/2 in-[[data-material=flat]]:after:rounded-litt-pill in-[[data-material=flat]]:after:bg-litt-line-strong in-[[data-material=flat]]:hover:after:bg-litt-fg-subtle in-[[data-material=flat]]:data-[dragging]:after:bg-litt-fg-subtle\",\n        \"transition-[background-color] duration-(--litt-dur-instant) ease-litt hover:bg-litt-line-strong data-[dragging]:bg-litt-fg-subtle\",\n        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\",\n        /* The hit area: 12px, or 44px under a finger, centered on the hairline. */\n        \"before:absolute\",\n        horizontal\n          ? \"w-px in-[[data-material=flat]]:after:h-8 in-[[data-material=flat]]:after:w-1 cursor-col-resize self-stretch before:inset-y-0 before:-inset-x-1.5 pointer-coarse:before:-inset-x-5\"\n          : \"h-px in-[[data-material=flat]]:after:h-1 in-[[data-material=flat]]:after:w-8 cursor-row-resize self-stretch before:inset-x-0 before:-inset-y-1.5 pointer-coarse:before:-inset-y-5\",\n        className,\n      ].join(\" \")}\n    />\n  );\n}\n",
      "target": "components/litt/resizable.tsx"
    }
  ],
  "docs": "Import ResizablePanelGroup, ResizablePanel and ResizableHandle from components/litt/resizable, as shadcn's resizable is written. Sizes are percents. On a phone, show one panel at a time instead.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
