From dfe9d5e6f153ea0161be7c4a8331ba207358f7c1 Mon Sep 17 00:00:00 2001 From: kantum <42kantum@gmail.com> Date: Sat, 7 Feb 2026 22:27:36 +0100 Subject: [PATCH 1/2] Add imperative resizing feature --- assets/js/live_pane/hooks/pane.ts | 48 +++++++++ demo/lib/demo_web/components/layouts.ex | 8 ++ demo/lib/demo_web/live/resize_live.ex | 19 ++++ demo/lib/demo_web/live/resize_live.html.heex | 100 +++++++++++++++++++ demo/lib/demo_web/router.ex | 1 + lib/live_pane.ex | 24 +++++ 6 files changed, 200 insertions(+) create mode 100644 demo/lib/demo_web/live/resize_live.ex create mode 100644 demo/lib/demo_web/live/resize_live.html.heex diff --git a/assets/js/live_pane/hooks/pane.ts b/assets/js/live_pane/hooks/pane.ts index 697756e..c85020b 100644 --- a/assets/js/live_pane/hooks/pane.ts +++ b/assets/js/live_pane/hooks/pane.ts @@ -121,6 +121,15 @@ export function createPaneHook() { expandPane(paneData, groupData); } }); + + this.handleEvent( + 'resize', + ({ pane_id, size }: { pane_id: string; size: number }) => { + if (paneId === pane_id) { + resizePaneTo(paneData, groupData, size); + } + } + ); }, updated() { @@ -328,6 +337,45 @@ function expandPane(paneData: PaneData, groupData: PaneGroupData) { groupData.layout.set(nextLayout); } +function resizePaneTo( + paneData: PaneData, + groupData: PaneGroupData, + size: number +) { + const { minSize = 0, maxSize = 100 } = paneData.constraints; + const clampedSize = Math.min(Math.max(size, minSize), maxSize); + + const prevLayout = groupData.layout.get(); + const paneDataArray = groupData.paneDataArray.get(); + + const paneConstraintsArray = paneDataArray.map(pd => pd.constraints); + + const { paneSize, pivotIndices } = paneDataHelper( + paneDataArray, + paneData, + prevLayout + ); + + if (paneSize == null) return; + + const isLastPane = + findPaneDataIndex(paneDataArray, paneData.id) === paneDataArray.length - 1; + + const delta = isLastPane ? paneSize - clampedSize : clampedSize - paneSize; + + const nextLayout = adjustLayoutByDelta({ + delta, + layout: prevLayout, + paneConstraintsArray, + pivotIndices, + trigger: 'imperative-api' + }); + + if (areArraysEqual(prevLayout, nextLayout)) return; + + groupData.layout.set(nextLayout); +} + function handleTransition( element: HTMLElement, paneDataArray: Writable, diff --git a/demo/lib/demo_web/components/layouts.ex b/demo/lib/demo_web/components/layouts.ex index 7b209a0..f1b3b41 100644 --- a/demo/lib/demo_web/components/layouts.ex +++ b/demo/lib/demo_web/components/layouts.ex @@ -148,6 +148,14 @@ defmodule DemoWeb.Layouts do Collapsible +
  • + <.link + href={~p"/resize"} + class="-ml-px block border-l pl-4 border-border text-muted-foreground hover:border-primary hover:text-primary" + > + Resize + +
  • <.link href={~p"/conditional"} diff --git a/demo/lib/demo_web/live/resize_live.ex b/demo/lib/demo_web/live/resize_live.ex new file mode 100644 index 0000000..79651e0 --- /dev/null +++ b/demo/lib/demo_web/live/resize_live.ex @@ -0,0 +1,19 @@ +defmodule DemoWeb.ResizeLive do + use DemoWeb, :live_view + + def mount(_params, _session, socket) do + {:ok, assign(socket, size: 50)} + end + + def handle_event("update_size", %{"size" => size}, socket) do + case Integer.parse(size) do + {value, _} -> {:noreply, assign(socket, :size, value)} + :error -> {:noreply, socket} + end + end + + def handle_event("apply_size", _, socket) do + socket = push_event(socket, "resize", %{pane_id: "resize_pane_1", size: socket.assigns.size}) + {:noreply, socket} + end +end diff --git a/demo/lib/demo_web/live/resize_live.html.heex b/demo/lib/demo_web/live/resize_live.html.heex new file mode 100644 index 0000000..ae1d3af --- /dev/null +++ b/demo/lib/demo_web/live/resize_live.html.heex @@ -0,0 +1,100 @@ +
    +
    +

    Imperative Resize

    +

    + Programmatically resize panes from the server using the resize event. +

    +
    + +
    +

    + Push a resize + event with pane_id + and size + to resize a pane. + The size is clamped to respect min_size + and max_size + constraints. +

    + +
    + + {@size}% + +
    + + + +
    + Resizable Pane +
    +
    + + +
    + + + + + +
    +
    + +
    + Other Pane +
    +
    +
    + +

    Usage

    + +
    +
    +          
    +          <%="""
    +          def handle_event(\"resize_pane\", _, socket) do
    +            socket = push_event(socket, \"resize\", %{
    +              pane_id: \"my_pane_id\",
    +              size: 30
    +            })
    +            {:noreply, socket}
    +          end
    +          """%>
    +
    +
    +
    +
    +
    diff --git a/demo/lib/demo_web/router.ex b/demo/lib/demo_web/router.ex index 2f47add..542e064 100644 --- a/demo/lib/demo_web/router.ex +++ b/demo/lib/demo_web/router.ex @@ -23,6 +23,7 @@ defmodule DemoWeb.Router do get "/overflowing", PageController, :overflowing live "/collapsible", CollapsibleLive, :collapsible + live "/resize", ResizeLive, :resize live "/conditional", ConditionalLive, :conditional live "/states", StatesLive, :states live "/updating_content", UpdatingContentLive, :updating_content diff --git a/lib/live_pane.ex b/lib/live_pane.ex index 7772042..7a616d8 100644 --- a/lib/live_pane.ex +++ b/lib/live_pane.ex @@ -14,6 +14,30 @@ defmodule LivePane do
    Pane 2
    + + ## Server-side Events + + Pane hooks listen to the following events that can be pushed from the server: + + ### collapse + + Collapses a collapsible pane to its `collapsed_size`. + + push_event(socket, "collapse", %{pane_id: "my_pane_id"}) + + ### expand + + Expands a collapsed pane to its previous size (or `min_size` if no previous size). + + push_event(socket, "expand", %{pane_id: "my_pane_id"}) + + ### resize + + Resizes a pane to a specific size (as a percentage of the group). + + push_event(socket, "resize", %{pane_id: "my_pane_id", size: 30}) + + The size will be automatically clamped to respect the pane's `min_size` and `max_size` constraints. """ use Phoenix.Component From 9d328ae99ddca377e80124ab9c9f5c0cc710cc44 Mon Sep 17 00:00:00 2001 From: kantum <42kantum@gmail.com> Date: Sun, 8 Feb 2026 00:32:32 +0100 Subject: [PATCH 2/2] Follow copilot suggestion --- assets/js/live_pane/hooks/pane.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/assets/js/live_pane/hooks/pane.ts b/assets/js/live_pane/hooks/pane.ts index c85020b..ff1ee1a 100644 --- a/assets/js/live_pane/hooks/pane.ts +++ b/assets/js/live_pane/hooks/pane.ts @@ -126,7 +126,7 @@ export function createPaneHook() { 'resize', ({ pane_id, size }: { pane_id: string; size: number }) => { if (paneId === pane_id) { - resizePaneTo(paneData, groupData, size); + resizePaneTo(this.el, paneData, groupData, size); } } ); @@ -338,12 +338,16 @@ function expandPane(paneData: PaneData, groupData: PaneGroupData) { } function resizePaneTo( + element: HTMLElement, paneData: PaneData, groupData: PaneGroupData, size: number ) { + const numericSize = Number(size); + if (!Number.isFinite(numericSize)) return; + const { minSize = 0, maxSize = 100 } = paneData.constraints; - const clampedSize = Math.min(Math.max(size, minSize), maxSize); + const clampedSize = Math.min(Math.max(numericSize, minSize), maxSize); const prevLayout = groupData.layout.get(); const paneDataArray = groupData.paneDataArray.get(); @@ -373,6 +377,23 @@ function resizePaneTo( if (areArraysEqual(prevLayout, nextLayout)) return; + // Detect if this resize causes a collapsed<->expanded state change + const wasCollapsed = isPaneCollapsed(paneDataArray, prevLayout, paneData); + const willBeCollapsed = isPaneCollapsed(paneDataArray, nextLayout, paneData); + + if (paneData.constraints.collapsible && wasCollapsed !== willBeCollapsed) { + const transState = willBeCollapsed + ? PaneState.Collapsing + : PaneState.Expanding; + handleTransition( + element, + groupData.paneDataArray, + groupData.layout, + paneData, + transState + ); + } + groupData.layout.set(nextLayout); }