` | 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}>
## 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"""
"""
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
# Horizontal: panels flow left-to-right in a flex row
defp container_class("horizontal"),
do: "flex h-full w-full"
# Vertical: panels stack top-to-bottom in a flex column
defp container_class("vertical"),
do: "flex flex-col h-full w-full"
# Handle styling: thin visible line + wide invisible click/drag target via ::after.
# `after:w-1` extends the interactive area 4px either side of the 1px line,
# making it easier to grab on touch devices.
defp handle_class do
"relative flex w-px items-center justify-center bg-border " <>
"after:absolute after:inset-y-0 after:left-1/2 after:w-1 " <>
"after:-translate-x-1/2 focus-visible:outline-none " <>
"focus-visible:ring-1 focus-visible:ring-ring " <>
"cursor-col-resize select-none hover:bg-accent transition-colors"
end
end