Skip to main content

priv/registry/resizable.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.Resizable do\n  @moduledoc \"\"\"\n  Draggable split panes for a two-panel horizontal or vertical layout.\n\n  Unlike the React `react-resizable-panels` original, this v1 is deliberately\n  simplified: it supports exactly one group with two panels separated by a single\n  handle, with no nested groups and no persistence. The group is a flexbox; each\n  panel sizes itself via `flex-basis` (a percentage) and the handle is a thin,\n  grabbable divider carrying the `ShadixResizable` hook.\n\n  Dragging is pure client behavior that LiveView's `JS` commands can't express, so\n  it lives in the hook (assets/ts/resizable.ts): on pointer drag it reads the\n  direction from the parent's `data-direction`, computes the pointer delta as a\n  fraction of the parent's size, and shifts that fraction from the next panel's\n  `flex-basis` to the previous panel's (clamped to 0-100%).\n\n  Compose them like:\n\n      <.resizable id=\"demo\">\n        <.resizable_panel>One</.resizable_panel>\n        <.resizable_handle />\n        <.resizable_panel>Two</.resizable_panel>\n      </.resizable>\n  \"\"\"\n  use Phoenix.Component\n\n  import Shadix.Cn\n\n  @doc \"\"\"\n  Renders a resizable group (the flex container holding two panels and a handle).\n\n  `:direction` is mirrored onto `data-direction` so the hook can read it, and it\n  switches the flex axis (`flex-col` for vertical).\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:direction, :string, default: \"horizontal\", values: ~w(horizontal vertical))\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def resizable(assigns) do\n    ~H\"\"\"\n    <div\n      id={@id}\n      data-slot=\"resizable\"\n      data-direction={@direction}\n      class={\n        cn([\n          \"flex h-full w-full\",\n          @direction == \"vertical\" && \"flex-col\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders a single resizable panel.\n\n  The panel's size is driven by `flex-basis`; the hook rewrites it on drag. By\n  default panels start at an even 50% split via the inline style, but callers may\n  override `style` to set a different initial basis.\n  \"\"\"\n  attr(:class, :string, default: nil)\n  attr(:style, :string, default: \"flex-basis: 50%;\")\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def resizable_panel(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"resizable-panel\"\n      class={cn([\"min-h-0 min-w-0 overflow-auto\", @class])}\n      style={@style}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders the draggable divider between two panels.\n\n  Carries `phx-hook=\"ShadixResizable\"`, `role=\"separator\"`, and an\n  `aria-orientation` derived from `:direction`. It paints a thin border-colored\n  bar with a centered grip and an enlarged hit area (`after:` pseudo-element).\n  \"\"\"\n  attr(:id, :string, default: nil)\n  attr(:direction, :string, default: \"horizontal\", values: ~w(horizontal vertical))\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n\n  def resizable_handle(assigns) do\n    ~H\"\"\"\n    <div\n      id={@id}\n      data-slot=\"resizable-handle\"\n      phx-hook=\"ShadixResizable\"\n      role=\"separator\"\n      tabindex=\"0\"\n      aria-label=\"Resize panels\"\n      aria-valuemin=\"0\"\n      aria-valuemax=\"100\"\n      aria-valuenow=\"50\"\n      aria-orientation={if @direction == \"vertical\", do: \"horizontal\", else: \"vertical\"}\n      class={\n        cn([\n          \"bg-border relative flex items-center justify-center\",\n          \"focus-visible:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-offset-1\",\n          if(@direction == \"vertical\",\n            do:\n              \"h-px w-full cursor-row-resize after:absolute after:left-0 after:h-1 after:w-full after:-translate-y-1/2\",\n            else:\n              \"w-px cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2\"\n          ),\n          @class\n        ])\n      }\n      {@rest}\n    >\n      <div\n        data-slot=\"resizable-handle-grip\"\n        class={\n          cn([\n            \"bg-border z-10 flex items-center justify-center rounded-xs border\",\n            if(@direction == \"vertical\", do: \"h-3 w-4\", else: \"h-4 w-3\")\n          ])\n        }\n      >\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"24\"\n          height=\"24\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          aria-hidden=\"true\"\n          class={cn([\"size-2.5\", @direction == \"vertical\" && \"rotate-90\"])}\n        >\n          <circle cx=\"9\" cy=\"12\" r=\"1\" />\n          <circle cx=\"9\" cy=\"5\" r=\"1\" />\n          <circle cx=\"9\" cy=\"19\" r=\"1\" />\n          <circle cx=\"15\" cy=\"12\" r=\"1\" />\n          <circle cx=\"15\" cy=\"5\" r=\"1\" />\n          <circle cx=\"15\" cy=\"19\" r=\"1\" />\n        </svg>\n      </div>\n    </div>\n    \"\"\"\n  end\nend\n",
      "path": "resizable.ex"
    }
  ],
  "hooks": [
    {
      "content": "interface ResizableHook {\n  el: HTMLElement;\n  mounted(): void;\n  destroyed(): void;\n}\n\nexport const ShadixResizable = {\n  mounted(this: ResizableHook) {\n    const handle = this.el;\n    const group = handle.parentElement;\n    if (!group) return;\n\n    const prev = handle.previousElementSibling as HTMLElement | null;\n    const next = handle.nextElementSibling as HTMLElement | null;\n    if (!prev || !next) return;\n\n    const isVertical = () =>\n      (group.getAttribute(\"data-direction\") ?? \"horizontal\") === \"vertical\";\n\n    const clamp = (n: number) => Math.min(100, Math.max(0, n));\n\n    let dragging = false;\n    let startPos = 0;\n    let startPrevPct = 0;\n    let startNextPct = 0;\n    let total = 0;\n    // Remembers the pre-collapse split so Enter can restore it after collapsing.\n    let restorePct: number | null = null;\n\n    // Resolve a panel's current size as a percentage of the two combined panels.\n    const pctOf = (el: HTMLElement) => {\n      const rect = el.getBoundingClientRect();\n      const size = isVertical() ? rect.height : rect.width;\n      return total > 0 ? (size / total) * 100 : 0;\n    };\n\n    // Read the current size of the two-panel pair as combined percentages.\n    const currentSplit = () => {\n      const prevRect = prev.getBoundingClientRect();\n      const nextRect = next.getBoundingClientRect();\n      const prevSize = isVertical() ? prevRect.height : prevRect.width;\n      const nextSize = isVertical() ? nextRect.height : nextRect.width;\n      const sum = prevSize + nextSize;\n      return {\n        sum,\n        prevPct: sum > 0 ? (prevSize / sum) * 100 : 0,\n        nextPct: sum > 0 ? (nextSize / sum) * 100 : 0,\n      };\n    };\n\n    // Apply a new split where `prevPct` is the previous panel's share of the\n    // pair, and mirror it onto aria-valuenow for assistive tech.\n    const applySplit = (prevPct: number) => {\n      const p = clamp(prevPct);\n      prev.style.flexBasis = `${p}%`;\n      next.style.flexBasis = `${100 - p}%`;\n      handle.setAttribute(\"aria-valuenow\", String(Math.round(p)));\n    };\n\n    // Keyboard resizing per the WAI-ARIA \"Window Splitter\" pattern: arrow keys\n    // nudge the divider, Home/End jump to the extremes, Enter collapses the\n    // primary pane and restores it on the next press.\n    const STEP = 5;\n    const onKeyDown = (e: KeyboardEvent) => {\n      const { prevPct } = currentSplit();\n      const decKey = isVertical() ? \"ArrowUp\" : \"ArrowLeft\";\n      const incKey = isVertical() ? \"ArrowDown\" : \"ArrowRight\";\n\n      switch (e.key) {\n        case decKey:\n          e.preventDefault();\n          applySplit(prevPct - STEP);\n          break;\n        case incKey:\n          e.preventDefault();\n          applySplit(prevPct + STEP);\n          break;\n        case \"Home\":\n          e.preventDefault();\n          applySplit(0);\n          break;\n        case \"End\":\n          e.preventDefault();\n          applySplit(100);\n          break;\n        case \"Enter\":\n          e.preventDefault();\n          if (prevPct <= 0 && restorePct != null) {\n            applySplit(restorePct);\n            restorePct = null;\n          } else {\n            restorePct = prevPct;\n            applySplit(0);\n          }\n          break;\n      }\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (!dragging) return;\n      const pos = isVertical() ? e.clientY : e.clientX;\n      const deltaPx = pos - startPos;\n      const groupRect = group.getBoundingClientRect();\n      const groupSize = isVertical() ? groupRect.height : groupRect.width;\n      if (groupSize <= 0) return;\n\n      const sum = startPrevPct + startNextPct;\n      const deltaPct = (deltaPx / groupSize) * 100;\n      let prevPct = clamp(startPrevPct + deltaPct);\n      // Keep the two panels' combined basis constant so only this pair resizes.\n      prevPct = Math.min(prevPct, sum);\n      const nextPct = sum - prevPct;\n\n      prev.style.flexBasis = `${prevPct}%`;\n      next.style.flexBasis = `${nextPct}%`;\n      handle.setAttribute(\n        \"aria-valuenow\",\n        String(Math.round(sum > 0 ? (prevPct / sum) * 100 : 0))\n      );\n    };\n\n    const onPointerUp = (e: PointerEvent) => {\n      if (!dragging) return;\n      dragging = false;\n      handle.removeAttribute(\"data-dragging\");\n      try {\n        handle.releasePointerCapture(e.pointerId);\n      } catch {\n        // pointer may already be released\n      }\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      window.removeEventListener(\"pointerup\", onPointerUp);\n      document.body.style.userSelect = \"\";\n    };\n\n    const onPointerDown = (e: PointerEvent) => {\n      e.preventDefault();\n      dragging = true;\n      startPos = isVertical() ? e.clientY : e.clientX;\n\n      const prevRect = prev.getBoundingClientRect();\n      const nextRect = next.getBoundingClientRect();\n      total =\n        (isVertical() ? prevRect.height : prevRect.width) +\n        (isVertical() ? nextRect.height : nextRect.width);\n      startPrevPct = pctOf(prev);\n      startNextPct = pctOf(next);\n\n      handle.setAttribute(\"data-dragging\", \"true\");\n      document.body.style.userSelect = \"none\";\n      try {\n        handle.setPointerCapture(e.pointerId);\n      } catch {\n        // setPointerCapture may be unavailable in some environments\n      }\n      window.addEventListener(\"pointermove\", onPointerMove);\n      window.addEventListener(\"pointerup\", onPointerUp);\n    };\n\n    handle.addEventListener(\"pointerdown\", onPointerDown);\n    handle.addEventListener(\"keydown\", onKeyDown);\n\n    // Seed aria-valuenow from the rendered split so screen readers start in sync.\n    applySplit(currentSplit().prevPct);\n\n    (handle as unknown as { _cleanup?: () => void })._cleanup = () => {\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      window.removeEventListener(\"pointerup\", onPointerUp);\n      handle.removeEventListener(\"pointerdown\", onPointerDown);\n      handle.removeEventListener(\"keydown\", onKeyDown);\n    };\n  },\n\n  destroyed(this: ResizableHook) {\n    (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n  },\n};\n",
      "name": "ShadixResizable",
      "path": "resizable.ts"
    }
  ],
  "name": "resizable",
  "npm_deps": [],
  "registry_deps": [
    "cn"
  ]
}