defmodule Corex.Collapsible do @moduledoc ~S''' Phoenix implementation of [Zag.js Collapsible](https://zagjs.com/components/react/collapsible). ## Anatomy ### Minimal ```heex <.collapsible class="collapsible"> <:trigger>Toggle <:content> Lorem ipsum dolor sit amet, consectetur adipiscing elit. ``` ### With indicator Optional **`:closed`** and **`:opened`** slots render after the trigger label. Use one chevron in **`:closed`** and default CSS rotates it when open. ```heex <.collapsible class="collapsible"> <:trigger>Toggle <:closed> <.heroicon name="hero-chevron-right" /> <:content> Lorem ipsum dolor sit amet, consectetur adipiscing elit. ``` ### Custom slots Use `:let={collapsible}` on slots to read `collapsible.open` and `collapsible.disabled`. ```heex <.collapsible class="collapsible"> <:trigger :let={c}> {if c.open, do: "Collapse", else: "Expand"} <:closed> <.heroicon name="hero-chevron-down" /> <:opened> <.heroicon name="hero-chevron-up" /> <:content :let={_c}> Panel body with custom opened/closed adornments. ``` ## API Requires a stable `id` on `<.collapsible>`. | 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` | ### set_open ```heex <.action phx-click={Corex.Collapsible.set_open("collapsible-api", true)} class="button button--sm"> Open <.collapsible id="collapsible-api" class="collapsible"> <:trigger>Toggle <:closed> <.heroicon name="hero-chevron-right" /> <:content>Lorem ipsum dolor sit amet. ``` ```elixir def handle_event("open_collapsible", _, socket) do {:noreply, Corex.Collapsible.set_open(socket, "collapsible-api", true)} end ``` ## Events ### Server events | Event | When | Payload | | ----- | ---- | ------- | | `on_open_change="collapsible_open_changed"` | Open state changes | `%{"id" => id, "open" => open}` | ### on_open_change ```heex <.collapsible class="collapsible" on_open_change="collapsible_open_changed" > <:trigger>Toggle <:closed> <.heroicon name="hero-chevron-right" /> <:content>Lorem ipsum dolor sit amet. ``` ```elixir def handle_event("collapsible_open_changed", %{"id" => id, "open" => open}, socket) do {:noreply, assign(socket, :open, open)} end ``` ### Client events | Event | When | `event.detail` | | ----- | ---- | -------------- | | `on_open_change_client="collapsible-open-changed"` | Open state changes | `id`, `open`, `previousOpen` | ### on_open_change_client ```heex <.collapsible id="collapsible-events-client" class="collapsible" on_open_change_client="collapsible-open-changed" > <:trigger>Toggle <:closed> <.heroicon name="hero-chevron-right" /> <:content>Lorem ipsum dolor sit amet. ``` ```javascript document.getElementById("collapsible-events-client")?.addEventListener("collapsible-open-changed", (e) => { console.log(e.detail); }); ``` ## Patterns ### Async ```heex <.async_result :let={panel} assign={@collapsible}> <:loading> <.collapsible_skeleton class="collapsible" /> <.collapsible class="collapsible" open={panel.open}> <:trigger>Details <:closed> <.heroicon name="hero-chevron-right" /> <:content>{panel.body} ``` ```elixir socket = assign_async(socket, :collapsible, fn -> {:ok, %{collapsible: %{open: false, body: "Loaded after async."}}} end) ``` ### Controlled ```heex <.collapsible class="collapsible" controlled open={@open} on_open_change="patterns_collapsible_changed" > <:trigger>Controlled <:closed> <.heroicon name="hero-chevron-right" /> <:content>LiveView owns open. ``` ```elixir def handle_event("patterns_collapsible_changed", %{"open" => open}, socket) do {:noreply, assign(socket, :open, open)} end ``` ## Style Target parts with `data-scope` and `data-part`. Content exposes `--height`, `--collapsed-height`, and related CSS variables for height animations. ```css [data-scope="collapsible"][data-part="root"] {} [data-scope="collapsible"][data-part="trigger"] {} [data-scope="collapsible"][data-part="content"] {} [data-scope="collapsible"][data-part="closed"] {} [data-scope="collapsible"][data-part="opened"] {} ``` ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/collapsible.css"; ``` ### Color | Modifier | Classes | | -------- | ------- | | Default | `collapsible collapsible--md` | | Accent | `collapsible collapsible--md collapsible--accent` | | Brand | `collapsible collapsible--md collapsible--brand` | ### Size | Modifier | Classes | | -------- | ------- | | SM | `collapsible collapsible--sm` | | MD | `collapsible collapsible--md` | | LG | `collapsible collapsible--lg` | ''' @doc type: :component use Phoenix.Component import Corex.Api.Doc alias Corex.Api.RespondTo alias Corex.Collapsible.Anatomy.{Closed, Content, Opened, Props, Root, Trigger} alias Corex.Collapsible.Connect @doc """ Renders a collapsible component. Requires `:trigger` and `:content` slots. Optional **`:closed`** and **`:opened`** slots add visual surfaces after the trigger label (Connect `data-part` only, no Zag props). Use `:let={collapsible}` on slots to access `collapsible.open` and `collapsible.disabled`. """ attr(:id, :string, required: false, doc: "The id of the collapsible, useful for API to identify the collapsible" ) attr(:open, :boolean, default: false, doc: "The initial open state or the controlled open state" ) attr(:controlled, :boolean, default: false, doc: "Whether the collapsible is controlled. Only in LiveView, the on_open_change event is required" ) attr(:disabled, :boolean, default: false, doc: "Whether the collapsible is disabled" ) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "The direction of the collapsible. When nil, derived from document (html lang + config :rtl_locales)" ) attr(:orientation, :string, default: "vertical", values: ["horizontal", "vertical"], doc: "Layout orientation for CSS." ) attr(:on_open_change, :string, default: nil, doc: "The server event name when the open state changes" ) attr(:on_open_change_client, :string, default: nil, doc: "The client event name when the open state changes" ) attr(:rest, :global) slot :trigger, required: true, doc: "Trigger button content. Use :let={collapsible} to access open and disabled state." do attr(:class, :string, required: false) end slot :content, required: true, doc: "Expandable content. Use :let={collapsible} to access open and disabled state." do attr(:class, :string, required: false) end slot :closed, required: false, doc: "Optional surface after the trigger, visible when closed (or use with `:opened` to swap content by state)." do attr(:class, :string, required: false) end slot :opened, required: false, doc: "Optional surface after the trigger, visible when open (use with `:closed` to swap content by state)." do attr(:class, :string, required: false) end def collapsible(assigns) do assigns = assigns |> assign_new(:id, fn -> "collapsible-#{System.unique_integer([:positive])}" end) |> assign(:slot_assigns, %{open: assigns.open, disabled: assigns.disabled}) closed_part = %Closed{ id: assigns.id, dir: assigns.dir, disabled: assigns.disabled, orientation: assigns.orientation } opened_part = %Opened{ id: assigns.id, dir: assigns.dir, disabled: assigns.disabled, orientation: assigns.orientation } assigns = assigns |> assign(:closed_part, closed_part) |> assign(:opened_part, opened_part) ~H"""