{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"resizable-panels","type":"registry:ui","title":"ResizablePanels","description":"Two panels with an accessible, draggable separator: a percentage split, keyboard steps, and a commit event to persist the size.","package":"react-fancy","dependencies":["tailwind-merge"],"registryDependencies":[],"files":[{"path":"components\/fancy\/resizable-panels\/ResizablePanels.tsx","content":"import { Children, forwardRef, useCallback, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, PointerEvent } from \"react\";\nimport { cn } from \"..\/lib\/utils\/cn\";\nimport type { ResizablePanelsProps } from \".\/ResizablePanels.types\";\n\nfunction clamp(n: number, lo: number, hi: number): number {\n  return Math.min(hi, Math.max(lo, n));\n}\n\n\/** Two decimals: precise enough for a pixel on a 4K row, tidy in JSON. *\/\nfunction tidy(n: number): number {\n  return Math.round(n * 100) \/ 100;\n}\n\nconst SEPARATOR_BASE =\n  \"relative shrink-0 touch-none select-none outline-none transition-colors \" +\n  \"bg-[color:var(--fancy-border,var(--color-zinc-200))] dark:bg-[color:var(--fancy-border-dark,var(--color-zinc-700))] \" +\n  \"focus-visible:bg-[color:var(--fancy-focus,var(--color-blue-500))] dark:focus-visible:bg-[color:var(--fancy-focus-dark,var(--color-blue-400))] \" +\n  \"focus-visible:ring-2 focus-visible:ring-[color:var(--fancy-focus,var(--color-blue-500))]\/40 dark:focus-visible:ring-[color:var(--fancy-focus-dark,var(--color-blue-400))]\/40\";\n\nconst SEPARATOR_ACTIVE =\n  \"hover:bg-[color:var(--fancy-focus,var(--color-blue-500))] dark:hover:bg-[color:var(--fancy-focus-dark,var(--color-blue-400))]\";\n\n\/**\n * Two panels and a draggable, keyboard-operable separator between them \u2014 the\n * W3C window-splitter pattern\n * (https:\/\/www.w3.org\/WAI\/ARIA\/apg\/patterns\/windowsplitter\/).\n *\n * `value` is the PRIMARY (first) panel's share of the container, as a\n * percentage. The separator is `role=\"separator\"` with `aria-valuenow\/min\/max`,\n * `aria-controls` pointing at the primary panel, and a tab stop. Arrow keys\n * along the drag axis move it by `step`, Shift + Arrow by `largeStep`, Home \/\n * End jump to `min` \/ `max`. Pointer and touch drags use pointer capture, so a\n * fast drag that leaves the bar keeps resizing.\n *\n * `onValueChange` fires continuously; `onValueCommit` fires once per gesture\n * (pointer release, key release) so a host can persist a per-user size without\n * writing on every frame. The host owns persistence.\n *\/\nexport const ResizablePanels = forwardRef<HTMLDivElement, ResizablePanelsProps>(\n  (\n    {\n      children,\n      value,\n      defaultValue = 50,\n      onValueChange,\n      onValueCommit,\n      min = 10,\n      max = 90,\n      orientation = \"horizontal\",\n      step = 5,\n      largeStep = 20,\n      label = \"Resize panels\",\n      disabled = false,\n      className,\n      ...props\n    },\n    forwardedRef,\n  ) => {\n    \/\/ Never past the container, whatever min\/max say.\n    const lo = clamp(min, 0, 100);\n    const hi = Math.max(lo, clamp(max, 0, 100));\n\n    const [internal, setInternal] = useState(defaultValue);\n    const controlled = value !== undefined;\n    const current = tidy(clamp(controlled ? value : internal, lo, hi));\n\n    \/*\n     * The value most recently ASKED FOR, re-synced to what is rendered on every\n     * render. Several key repeats can land before React re-renders; reading the\n     * render-time value would make each of them compute from the same start.\n     *\/\n    const latest = useRef(current);\n    latest.current = current;\n\n    const rootRef = useRef<HTMLDivElement | null>(null);\n    const setRefs = useCallback(\n      (node: HTMLDivElement | null) => {\n        rootRef.current = node;\n        if (typeof forwardedRef === \"function\") forwardedRef(node);\n        else if (forwardedRef) forwardedRef.current = node;\n      },\n      [forwardedRef],\n    );\n\n    const firstId = useId();\n    const horizontal = orientation === \"horizontal\";\n\n    const request = useCallback(\n      (next: number) => {\n        const v = tidy(clamp(next, lo, hi));\n        if (v === latest.current) return;\n        latest.current = v;\n        if (!controlled) setInternal(v);\n        onValueChange?.(v);\n      },\n      [controlled, hi, lo, onValueChange],\n    );\n\n    \/\/ --- keyboard -----------------------------------------------------------\n\n    const keyChanged = useRef(false);\n\n    const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      const dec = horizontal ? \"ArrowLeft\" : \"ArrowUp\";\n      const inc = horizontal ? \"ArrowRight\" : \"ArrowDown\";\n      const by = e.shiftKey ? largeStep : step;\n      let next: number;\n      if (e.key === dec) next = latest.current - by;\n      else if (e.key === inc) next = latest.current + by;\n      else if (e.key === \"Home\") next = lo;\n      else if (e.key === \"End\") next = hi;\n      else return;\n      e.preventDefault();\n      const before = latest.current;\n      request(next);\n      if (latest.current !== before) keyChanged.current = true;\n    };\n\n    const onKeyUp = () => {\n      if (!keyChanged.current) return;\n      keyChanged.current = false;\n      onValueCommit?.(latest.current);\n    };\n\n    \/\/ --- pointer ------------------------------------------------------------\n\n    const drag = useRef<{ pointerId: number; start: number } | null>(null);\n    const [dragging, setDragging] = useState(false);\n\n    const valueAt = (clientX: number, clientY: number): number | null => {\n      const rect = rootRef.current?.getBoundingClientRect();\n      if (!rect) return null;\n      const size = horizontal ? rect.width : rect.height;\n      if (!size) return null;\n      const offset = horizontal ? clientX - rect.left : clientY - rect.top;\n      return (offset \/ size) * 100;\n    };\n\n    const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {\n      if (disabled || e.button !== 0) return;\n      e.preventDefault();\n      e.currentTarget.setPointerCapture?.(e.pointerId);\n      drag.current = { pointerId: e.pointerId, start: latest.current };\n      setDragging(true);\n    };\n\n    const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n      if (!drag.current || drag.current.pointerId !== e.pointerId) return;\n      const v = valueAt(e.clientX, e.clientY);\n      if (v !== null) request(v);\n    };\n\n    const endDrag = (e: PointerEvent<HTMLDivElement>) => {\n      const d = drag.current;\n      if (!d || d.pointerId !== e.pointerId) return;\n      drag.current = null;\n      setDragging(false);\n      if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {\n        e.currentTarget.releasePointerCapture?.(e.pointerId);\n      }\n      if (latest.current !== d.start) onValueCommit?.(latest.current);\n    };\n\n    const items = Children.toArray(children);\n\n    return (\n      <div\n        ref={setRefs}\n        {...props}\n        data-react-fancy-resizable-panels=\"\"\n        data-orientation={orientation}\n        data-dragging={dragging ? \"\" : undefined}\n        data-disabled={disabled ? \"\" : undefined}\n        className={cn(\n          \"flex h-full w-full min-h-0 min-w-0 overflow-hidden\",\n          horizontal ? \"flex-row\" : \"flex-col\",\n          dragging && (horizontal ? \"cursor-col-resize select-none\" : \"cursor-row-resize select-none\"),\n          className,\n        )}\n      >\n        <div\n          id={firstId}\n          data-react-fancy-resizable-panels-panel=\"first\"\n          className=\"min-h-0 min-w-0 overflow-auto\"\n          style={{ flexBasis: `${current}%`, flexGrow: 0, flexShrink: 0 }}\n        >\n          {items[0]}\n        <\/div>\n        <div\n          role=\"separator\"\n          data-react-fancy-resizable-panels-separator=\"\"\n          aria-orientation={horizontal ? \"vertical\" : \"horizontal\"}\n          aria-valuenow={current}\n          aria-valuemin={lo}\n          aria-valuemax={hi}\n          aria-controls={firstId}\n          aria-label={label}\n          aria-disabled={disabled || undefined}\n          tabIndex={disabled ? -1 : 0}\n          onKeyDown={onKeyDown}\n          onKeyUp={onKeyUp}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={endDrag}\n          onPointerCancel={endDrag}\n          onLostPointerCapture={endDrag}\n          className={cn(\n            SEPARATOR_BASE,\n            \/\/ A 1px line with a 12px hit area, so it is easy to grab by touch.\n            horizontal\n              ? \"w-px before:absolute before:inset-y-0 before:-left-1.5 before:-right-1.5\"\n              : \"h-px before:absolute before:inset-x-0 before:-top-1.5 before:-bottom-1.5\",\n            disabled\n              ? \"cursor-default opacity-50\"\n              : cn(SEPARATOR_ACTIVE, horizontal ? \"cursor-col-resize\" : \"cursor-row-resize\"),\n          )}\n        \/>\n        <div\n          data-react-fancy-resizable-panels-panel=\"second\"\n          className=\"min-h-0 min-w-0 flex-1 overflow-auto\"\n        >\n          {items[1]}\n        <\/div>\n      <\/div>\n    );\n  },\n);\n\nResizablePanels.displayName = \"ResizablePanels\";\n","type":"registry:ui","target":"components\/fancy\/resizable-panels\/ResizablePanels.tsx"},{"path":"components\/fancy\/resizable-panels\/ResizablePanels.types.ts","content":"import type { HTMLAttributes, ReactNode } from \"react\";\n\n\/**\n * How the two PANELS are laid out.\n *\n * - `\"horizontal\"`: side by side. The separator is a vertical bar\n *   (`aria-orientation=\"vertical\"`), moved with ArrowLeft \/ ArrowRight.\n * - `\"vertical\"`: stacked. The separator is a horizontal bar\n *   (`aria-orientation=\"horizontal\"`), moved with ArrowUp \/ ArrowDown.\n *\/\nexport type ResizablePanelsOrientation = \"horizontal\" | \"vertical\";\n\nexport interface ResizablePanelsProps\n  extends Omit<HTMLAttributes<HTMLDivElement>, \"children\" | \"defaultValue\" | \"onChange\"> {\n  \/**\n   * Exactly two children: the PRIMARY panel (first, left \/ top) and the\n   * secondary panel (second, right \/ bottom). Further children are ignored.\n   *\/\n  children: ReactNode;\n  \/**\n   * Controlled size of the primary panel, as a PERCENTAGE (0-100) of the\n   * container. Clamped to `min` \/ `max`.\n   *\/\n  value?: number;\n  \/** Uncontrolled starting size, a percentage. Default `50`. *\/\n  defaultValue?: number;\n  \/** Fires on every change while dragging or pressing keys. *\/\n  onValueChange?: (value: number) => void;\n  \/**\n   * Fires ONCE when a gesture ends: at pointer release (or cancel) and at key\n   * release, and only if the value changed. This is the moment to persist a\n   * per-user size; the host owns persistence.\n   *\/\n  onValueCommit?: (value: number) => void;\n  \/** Smallest primary size, a percentage. Default `10`. *\/\n  min?: number;\n  \/** Largest primary size, a percentage. Default `90`. *\/\n  max?: number;\n  \/** Panel layout. Default `\"horizontal\"` (side by side). *\/\n  orientation?: ResizablePanelsOrientation;\n  \/** Arrow-key step, in percentage points. Default `5`. *\/\n  step?: number;\n  \/** Shift + Arrow step, in percentage points. Default `20`. *\/\n  largeStep?: number;\n  \/** Accessible name of the separator. Default `\"Resize panels\"`. *\/\n  label?: string;\n  \/** Freezes the split: the separator is not focusable and ignores input. *\/\n  disabled?: boolean;\n}\n","type":"registry:ui","target":"components\/fancy\/resizable-panels\/ResizablePanels.types.ts"},{"path":"components\/fancy\/resizable-panels\/index.ts","content":"export { ResizablePanels } from \".\/ResizablePanels\";\nexport type { ResizablePanelsProps, ResizablePanelsOrientation } from \".\/ResizablePanels.types\";\n","type":"registry:ui","target":"components\/fancy\/resizable-panels\/index.ts"},{"path":"components\/fancy\/lib\/utils\/cn.ts","content":"import { twMerge } from \"tailwind-merge\";\n\n\/**\n * A class value: a string, a number, a conditional map, an array of any of\n * those, or nothing. Mirrors what `clsx` accepted, because every component in\n * this package already passes these shapes.\n *\/\nexport type ClassValue =\n  | string\n  | number\n  | bigint\n  | null\n  | undefined\n  | false\n  | ClassDictionary\n  | ClassValue[];\n\ntype ClassDictionary = Record<string, unknown>;\n\n\/**\n * Flatten class values into a space-separated string.\n *\n * This was `clsx` until 2026-08-19. It is roughly twenty lines of logic, it\n * shipped as a RUNTIME dependency to every consumer of this package, and its\n * last commit was 801 days old \u2014 so it fell to the rule that third-party code\n * must be both approved and actively maintained. Owning it costs less than\n * carrying it.\n *\n * `tailwind-merge` deliberately stays: deciding which of two conflicting\n * Tailwind utilities wins requires modelling the entire utility space, which is\n * emphatically not twenty lines, and it is actively maintained.\n *\n * Falsy values are dropped \u2014 including `0` and `\"\"`, which is what `clsx` did\n * and what `tests\/cn.test.ts` pins. A truthy number is kept, because digits are\n * legal class names. `bigint` is admitted because clsx's signature did and\n * the compiler proved it reachable from two call sites here.\n *\/\nfunction flatten(value: ClassValue): string {\n  if (!value) return \"\";\n\n  if (typeof value === \"string\" || typeof value === \"number\" || typeof value === \"bigint\") {\n    return String(value);\n  }\n\n  if (Array.isArray(value)) {\n    let out = \"\";\n    for (const item of value) {\n      const part = flatten(item);\n      if (part) out = out ? `${out} ${part}` : part;\n    }\n    return out;\n  }\n\n  let out = \"\";\n  for (const key in value) {\n    if (value[key]) out = out ? `${out} ${key}` : key;\n  }\n  return out;\n}\n\nexport function cn(...inputs: ClassValue[]): string {\n  return twMerge(flatten(inputs));\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/cn.ts"}]}