{
"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"
]
}