defmodule PhiaUi.Components.Resizable do @moduledoc """ Drag-to-resize split panel component. Provides `resizable/1`, `resizable_panel/1`, and `resizable_handle/1` sub-components that together create a resizable split-pane layout. The `PhiaResizable` JavaScript hook handles mouse drag, touch drag, and keyboard arrow-key resizing with configurable min/max size clamping. ## Sub-components | Component | Element | Purpose | |---------------------|---------|-------------------------------------------------------| | `resizable/1` | `
` | Flex container with the `PhiaResizable` hook attached | | `resizable_panel/1` | `
` | Individual panel sized via CSS `flex` | | `resizable_handle/1`| `
` | Drag handle between panels (keyboard focusable) | ## Hook setup Register the `PhiaResizable` hook in your LiveSocket before using this component: // assets/js/app.js import PhiaResizable from "./hooks/resizable" let liveSocket = new LiveSocket("/live", Socket, { hooks: { PhiaResizable } }) ## Horizontal split (default) Side-by-side panels, the most common layout for code editors, detail/list splits, and side-by-side comparisons: <.resizable class="h-[400px]"> <.resizable_panel default_size={50} min_size={20}>
Left panel
<.resizable_handle /> <.resizable_panel default_size={50} min_size={20}>
Right panel
## Vertical split Top-and-bottom panels, useful for query editors, terminal splits, or primary + detail layouts: <.resizable direction="vertical" class="h-[600px]"> <.resizable_panel default_size={60} min_size={30}>
Top panel (query editor)
<.resizable_handle /> <.resizable_panel default_size={40} min_size={20}>
Bottom panel (results)
## Three-column layout (master-detail-info) <.resizable class="h-screen"> <.resizable_panel default_size={20} min_size={15} max_size={30}> <%!-- Sidebar navigation --%> <.resizable_handle /> <.resizable_panel default_size={50} min_size={30}> <%!-- Main content --%>
...
<.resizable_handle /> <.resizable_panel default_size={30} min_size={20} max_size={40}> <%!-- Detail/properties panel --%> ## IDE-style layout (file tree + editor + terminal) <.resizable direction="vertical" class="h-screen"> <.resizable_panel default_size={70}> <.resizable class="h-full"> <.resizable_panel default_size={20} min_size={15}>
File tree
<.resizable_handle /> <.resizable_panel default_size={80}>
Editor
<.resizable_handle /> <.resizable_panel default_size={30} min_size={10}>
Terminal
## Panel size model Panel sizes are percentages (0–100). The CSS `flex` property is set to `flex: N 1 0%` where `N` is the `default_size`. The `PhiaResizable` hook reads `data-min-size` and `data-max-size` from each panel to clamp dragging. ## Accessibility - The `resizable_handle/1` carries `role="separator"` and `tabindex="0"` so keyboard users can focus it and adjust the split with arrow keys. - `aria-valuenow`, `aria-valuemin`, and `aria-valuemax` communicate the current split position to assistive technologies. """ use Phoenix.Component import PhiaUi.ClassMerger, only: [cn: 1] # --------------------------------------------------------------------------- # resizable/1 — container with hook # --------------------------------------------------------------------------- attr(:direction, :string, default: "horizontal", values: ~w(horizontal vertical), doc: "Split direction: `\"horizontal\"` (panels side-by-side) or `\"vertical\"` (panels top-and-bottom)." ) attr(:class, :string, default: nil, doc: "Additional CSS classes applied to the container `
`. Always set an explicit height (e.g. `class=\"h-[400px]\"`) so panels have space to fill." ) attr(:rest, :global, doc: "HTML attributes forwarded to the container `
` element.") slot(:inner_block, required: true, doc: "Alternating `resizable_panel/1` and `resizable_handle/1` children." ) @doc """ Renders the resizable container. Attaches the `PhiaResizable` JavaScript hook via `phx-hook`. The hook reads the `data-direction` attribute to determine drag axis. Requires the `PhiaResizable` hook to be registered in your `LiveSocket` hooks. Always set an explicit height on the container so the panels have space to fill — the container uses `h-full w-full` internally: <.resizable class="h-[500px]"> ... """ def resizable(assigns) do ~H"""
{render_slot(@inner_block)}
""" end # --------------------------------------------------------------------------- # resizable_panel/1 — individual panel # --------------------------------------------------------------------------- attr(:default_size, :integer, default: 50, doc: "Initial panel size as a percentage of the container (0–100). The sum of all panel `default_size` values should equal 100." ) attr(:min_size, :integer, default: 10, doc: "Minimum panel size as a percentage. The hook clamps dragging so this panel cannot shrink below this value." ) attr(:max_size, :integer, default: 90, doc: "Maximum panel size as a percentage. The hook clamps dragging so this panel cannot grow beyond this value." ) attr(:class, :string, default: nil, doc: "Additional CSS classes applied to the panel `
`. Typically `overflow-auto` or `overflow-hidden`." ) attr(:rest, :global, doc: "HTML attributes forwarded to the panel `
` element.") slot(:inner_block, required: true, doc: "Panel content.") @doc """ Renders a resizable panel inside a `resizable/1` container. The panel's initial size is set via CSS `flex: N 1 0%` where `N` is the `default_size`. The hook stores `data-default-size`, `data-min-size`, and `data-max-size` for runtime clamping during drag operations. Panels use `overflow-hidden` by default to prevent content from breaking the layout. Override with `class="overflow-auto"` when the panel content should scroll independently. ## Example <.resizable_panel default_size={40} min_size={20} max_size={70}>
Panel content
""" def resizable_panel(assigns) do ~H"""
{render_slot(@inner_block)}
""" end # --------------------------------------------------------------------------- # resizable_handle/1 — drag handle # --------------------------------------------------------------------------- attr(:class, :string, default: nil, doc: "Additional CSS classes applied to the handle `
`." ) attr(:rest, :global, doc: "HTML attributes forwarded to the handle `
` element.") @doc """ Renders the drag handle between two resizable panels. The handle is a thin `
` with a 1px `bg-border` line and an expanded interactive hit area via an `::after` pseudo-element. It is keyboard focusable (`tabindex="0"`) and carries ARIA separator attributes. Place one handle between each pair of adjacent panels: <.resizable_panel ...>... <.resizable_handle /> <%!-- between panels --%> <.resizable_panel ...>... Keyboard interaction (managed by the `PhiaResizable` hook): - `ArrowLeft` / `ArrowRight` — adjust horizontal split. - `ArrowUp` / `ArrowDown` — adjust vertical split. """ def resizable_handle(assigns) do ~H"""