defmodule Corex.FloatingPanel do @moduledoc ~S''' Phoenix implementation of [Zag.js Floating Panel](https://zagjs.com/components/react/floating-panel). ## Anatomy ### Basic ```heex <.floating_panel class="floating-panel"> <:trigger class="button button--ghost button--sm"> Open panel Close panel <:title>Panel <:minimize_trigger> <.heroicon name="hero-arrow-down-left" class="icon" /> <:maximize_trigger> <.heroicon name="hero-arrows-pointing-out" class="icon" /> <:default_trigger> <.heroicon name="hero-rectangle-stack" class="icon" /> <:close_trigger> <.heroicon name="hero-x-mark" class="icon" /> <:content>
Congue molestie ipsum gravida a. Sed ac eros luctus, cursus turpis non, pellentesque elit. Pellentesque sagittis fermentum.
``` Required slots: `:trigger`, `:title`, `:close_trigger`, `:content`. Set **`class`** on **`:trigger`** to style the outer trigger button (e.g. `button button--ghost button--sm`). Use **`data-open`** and **`data-closed`** on elements inside `:trigger` to swap label when the panel is open vs closed (see default rules in `floating-panel.css`). You can also target **`[data-part="trigger"][data-state="open"]`** / **`closed`** with your own selectors. Optional slots: `:minimize_trigger`, `:maximize_trigger`, `:default_trigger`. Omit them to hide the minimize, maximize, and restore controls. Set **`position={%Corex.Point{}}` or `position={%{x: n, y: n}}`** for a fixed initial Zag `Point` (**`data-default-position`** / **`defaultPosition`**). If **`position`** is set, it overrides anchor placement. Set **`size={%{width:, height:}}`** for initial dimensions (**`data-default-size`** / Zag **`defaultSize`**). Use **`value_size`** for a controlled current size (**`data-size`** / Zag **`size`**). Optional **`positioning={%Corex.Positioning{}}`** sets **`data-position-*`** for the client hook. When **`position`** is omitted, the hook passes Zag **`getAnchorPosition`** from placement and boundary (e.g. **`placement: "bottom-start"`** and **`gutter: 16`** for a bottom corner). Do not rely on both **`position`** and **`positioning`** for the same panel; prefer **`position`** for explicit pixels, **`positioning`** for placement rules. ## API Requires a stable `id` on `<.floating_panel>`. | Function | Action | Returns | | -------- | ------ | ------- | | [`set_open/2`](#set_open/2) | Set open state (client) | `%Phoenix.LiveView.JS{}` | | [`set_open/3`](#set_open/3) | Set open state (server) | `socket` | ## Events Pick an event name and pass it to `on_*` on `<.floating_panel>`. ### Server events | Event | When | Payload | | ----- | ---- | ------- | | `on_open_change="panel_open_changed"` | Open state changes | `%{"id" => id, "open" => boolean}` | | `on_position_change="panel_position_changed"` | Position changes | `%{"id" => id, ...}` | | `on_size_change="panel_size_changed"` | Size changes | `%{"id" => id, ...}` | | `on_stage_change="panel_stage_changed"` | Stage changes | `%{"id" => id, ...}` | ### Client events | Event | When | `event.detail` | | ----- | ---- | -------------- | | `on_open_change_client="panel-open-changed"` | Open state changes | `id`, `open` | | `on_position_change_client="panel-position-changed"` | Position changes | `id`, ... | | `on_size_change_client="panel-size-changed"` | Size changes | `id`, ... | | `on_stage_change_client="panel-stage-changed"` | Stage changes | `id`, ... | ## Style Use data attributes to target elements: ```css [data-scope="floating-panel"][data-part="root"] {} [data-scope="floating-panel"][data-part="trigger"] {} [data-scope="floating-panel"][data-part="positioner"] {} [data-scope="floating-panel"][data-part="content"] {} [data-scope="floating-panel"][data-part="title"] {} [data-scope="floating-panel"][data-part="header"] {} [data-scope="floating-panel"][data-part="body"] {} [data-scope="floating-panel"][data-part="drag-trigger"] {} [data-scope="floating-panel"][data-part="resize-trigger"] {} [data-scope="floating-panel"][data-part="close-trigger"] {} [data-scope="floating-panel"][data-part="control"] {} [data-scope="floating-panel"][data-part="stage-trigger"] {} ``` If you wish to use the default Corex styling, you can use the class `floating-panel` on the component. This requires to install `Mix.Tasks.Corex.Design` first and import the component css file. ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/floating-panel.css"; ``` You can then use modifiers ```heex <.floating_panel class="floating-panel floating-panel--accent floating-panel--lg"> <:trigger> Closed Open <:title>Title <:close_trigger>Close <:content>Body ``` ''' @doc type: :component use Phoenix.Component import Corex.Api.Doc alias Corex.FloatingPanel.Anatomy.{ Body, CloseTrigger, Content, Control, DragTrigger, Header, Positioner, Props, ResizeTrigger, Root, StageTrigger, Title, Trigger } alias Corex.FloatingPanel.Connect alias Corex.FloatingPanel.Translation alias Corex.Point alias Corex.Positioning alias Phoenix.LiveView alias Phoenix.LiveView.JS @resize_axes ~W(n e w s ne se sw nw) @stages ~W(minimized maximized default) attr(:id, :string, required: false, doc: "The id of the floating panel") attr(:draggable, :boolean, default: true, doc: "Whether the panel can be dragged") attr(:resizable, :boolean, default: true, doc: "Whether the panel can be resized") attr(:allow_overflow, :boolean, default: true, doc: "Whether content can overflow") attr(:close_on_escape, :boolean, default: true, doc: "Whether Escape closes the panel") attr(:disabled, :boolean, default: false, doc: "Whether the panel is disabled") attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "Text direction") attr(:orientation, :string, default: "vertical", values: ["horizontal", "vertical"], doc: "Layout orientation for CSS and ignored attribute lists." ) attr(:size, :map, default: nil, doc: "Zag defaultSize; sets data-default-size on the hook root") attr(:value_size, :map, default: nil, doc: "Zag controlled size; sets data-size when present") attr(:position, :any, default: nil, doc: "Initial Zag `Point` (`defaultPosition` / `data-default-position`). `%Corex.Point{}` or `%{x:, y:}`" ) attr(:positioning, Corex.Positioning, default: nil, doc: "Optional placement for `getAnchorPosition` on the client (`data-position-*`). Ignored for anchor math when `position` is set." ) attr(:min_size, :map, default: nil, doc: "Minimum size constraints") attr(:max_size, :map, default: nil, doc: "Maximum size constraints") attr(:persist_rect, :boolean, default: false, doc: "Whether to persist position and size") attr(:grid_size, :integer, default: 1, doc: "Grid snapping size for drag and resize") attr(:on_open_change, :string, default: nil, doc: "Server event when open state changes") attr(:on_open_change_client, :string, default: nil, doc: "Client event when open state changes") attr(:on_position_change, :string, default: nil, doc: "Server event when position changes") attr(:on_size_change, :string, default: nil, doc: "Server event when size changes") attr(:on_stage_change, :string, default: nil, doc: "Server event when stage (minimized/maximized) changes" ) attr(:on_position_change_client, :string, default: nil, doc: "Client event when position changes" ) attr(:on_size_change_client, :string, default: nil, doc: "Client event when size changes") attr(:on_stage_change_client, :string, default: nil, doc: "Client event when stage (minimized/maximized) changes" ) attr(:translation, Corex.FloatingPanel.Translation, default: nil, doc: "Override translatable strings" ) attr(:rest, :global) slot :trigger, required: true do attr(:class, :string, required: false) end slot :title, required: true do attr(:class, :string, required: false) end slot :minimize_trigger, required: false do attr(:class, :string, required: false) end slot :maximize_trigger, required: false do attr(:class, :string, required: false) end slot :default_trigger, required: false do attr(:class, :string, required: false) end slot :close_trigger, required: true do attr(:class, :string, required: false) end slot :content, required: true do attr(:class, :string, required: false) end def floating_panel(assigns) do translation = Translation.resolve(assigns.translation) assigns = assigns |> assign_new(:id, fn -> "floating-panel-#{System.unique_integer([:positive])}" end) |> assign_new(:dir, fn -> "ltr" end) |> assign(:translation, translation) |> assign(:resize_axes, @resize_axes) |> assign(:stages, @stages) |> assign(:default_position, Point.to_map(assigns.position)) |> assign(:trigger_entry, List.first(assigns.trigger)) ~H"""Content.
``` ```javascript document.getElementById("my-floating-panel")?.dispatchEvent( new CustomEvent("corex:floating-panel:set-open", { bubbles: false, detail: { open: true }, }) ); ``` """) def set_open(floating_panel_id, open) when is_binary(floating_panel_id) and is_boolean(open) do JS.dispatch("corex:floating-panel:set-open", to: "##{floating_panel_id}", detail: %{open: open}, bubbles: false ) end api_doc(~S""" Set open state from `handle_event`. ```heex <.action phx-click="open_panel">Open <.floating_panel id="my-floating-panel" class="floating-panel"> <:trigger class="button button--ghost button--sm">Open <:title>Panel <:close_trigger><.heroicon name="hero-x-mark" class="icon" /> <:content>Content.
``` ```elixir def handle_event("open_panel", _, socket) do {:noreply, Corex.FloatingPanel.set_open(socket, "my-floating-panel", true)} end ``` """) def set_open(socket, floating_panel_id, open) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(floating_panel_id) and is_boolean(open) do LiveView.push_event(socket, "floating_panel_set_open", %{ floating_panel_id: floating_panel_id, open: open }) end end