defmodule Corex.Accordion do @moduledoc ~S''' Phoenix implementation of the [Zag.js Accordion](https://zagjs.com/components/react/accordion). ## Anatomy ### Minimal ```heex <.accordion class="accordion" items={ Corex.Content.new([ %{label: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit."}, %{label: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula."}, %{label: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a."} ]) } /> ``` ### With slots With `items` and `<:indicator>` slot so every panel shares the same indicator markup. ```heex <.accordion class="accordion" items={ Corex.Content.new([ %{label: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit."}, %{label: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula."}, %{label: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a."} ]) } > <:indicator> <.heroicon name="hero-chevron-right" /> ``` ### Custom slots With `items`, customize each item using slots with `:let={item}` to access the item and its `meta` data ```heex <.accordion class="accordion" value="lorem" items={ Corex.Content.new([ %{ value: "lorem", label: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique.", meta: %{indicator: "hero-arrow-long-right", icon: "hero-chat-bubble-left-right"} }, %{ label: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula, at interdum tellus.", meta: %{indicator: "hero-chevron-right", icon: "hero-device-phone-mobile"} }, %{ value: "donec", label: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a. Sed ac eros luctus.", disabled: true, meta: %{indicator: "hero-chevron-double-right", icon: "hero-phone"} } ]) } > <:trigger :let={item}> <.heroicon name={item.meta.icon} />{item.label} <:content :let={item}>
{item.content}
<:indicator :let={item}> <.heroicon name={item.meta.indicator} /> ``` ### Manual slots With an empty `items` list, use multiple `:trigger`, `:content`, and optional `:indicator` slots. Each slot takes a `value` string that ties the three together. ```heex <.accordion class="accordion" value="lorem"> <:trigger value="lorem"> <.heroicon name="hero-chevron-right" /> Lorem ipsum dolor sit amet <:content value="lorem">Consectetur adipiscing elit. Sed sodales ullamcorper tristique.
<:indicator value="lorem"> <.heroicon name="hero-chevron-down" /> <:trigger value="duis"> <.heroicon name="hero-chevron-right" /> Duis dictum gravida odio ac pharetra? <:content value="duis">Nullam eget vestibulum ligula, at interdum tellus.
<:indicator value="duis"> <.heroicon name="hero-chevron-down" /> ``` ### Compound Take full structural control with the `accordion_root`, `accordion_item`, `accordion_trigger`, `accordion_content`, and `accordion_indicator` sub-components. #### Manual items ```heex <.accordion :let={ctx} compound class="accordion"> <.accordion_root ctx={ctx}> <.accordion_item :let={item} ctx={ctx} value="lorem"> <.accordion_trigger item={item}> Lorem ipsum dolor sit amet <:indicator> <.accordion_indicator item={item}> <.heroicon name="hero-chevron-right" /> <.accordion_content item={item}>Consectetur adipiscing elit. Sed sodales ullamcorper tristique.
<.accordion_item :let={item} ctx={ctx} value="duis"> <.accordion_trigger item={item}> Duis dictum gravida odio ac pharetra? <:indicator> <.accordion_indicator item={item}> <.heroicon name="hero-chevron-right" /> <.accordion_content item={item}>Nullam eget vestibulum ligula, at interdum tellus.
<.accordion_item :let={item} ctx={ctx} value="donec"> <.accordion_trigger item={item}> Donec condimentum ex mi <:indicator> <.accordion_indicator item={item}> <.heroicon name="hero-chevron-right" /> <.accordion_content item={item}>Congue molestie ipsum gravida a. Sed ac eros luctus.
``` #### From a list ```heex <.accordion :let={ctx} compound id="faq" class="accordion"> <.accordion_root ctx={ctx}> <.accordion_item :for={entry <- @items} :let={item} ctx={ctx} value={entry.value}> <.accordion_trigger item={item}> {entry.label} <:indicator> <.accordion_indicator item={item}> <.heroicon name="hero-chevron-right" /> <.accordion_content item={item}>{entry.content}
``` ## API See [API](api.html) for **`id`**, **`respond_to`**, and calling helpers from **`handle_event`**, HEEx, or DOM `CustomEvent`s. | Action | Elixir | DOM command | | ------ | ------ | ----------- | | Set open item(s) | `set_value/2`, `set_value/3` | `corex:accordion:set-value` - `detail.value` is a list of strings | | Read open item(s) | `value/2`, `value/3` | `corex:accordion:value` - optional `detail.respond_to` | | Read focused item | `focused/2`, `focused/3` | `corex:accordion:focused` | | Read one item’s state | `item_state/3`, `item_state/4` | `corex:accordion:item-state` - `detail.value`, `detail.disabled`, optional `detail.respond_to` | ```elixir {:noreply, Corex.Accordion.set_value(socket, "my-accordion", ["lorem"])} ``` ```heex <.action phx-click={Corex.Accordion.set_value("my-accordion", ["lorem"])}>Open first ``` ```javascript document.getElementById("my-accordion")?.dispatchEvent( new CustomEvent("corex:accordion:set-value", { bubbles: false, detail: { value: ["lorem"] }, }), ) ``` ## Events See [Events](events.html) for how **`on_*`** and **`*_client`** relate to **`handle_event/3`** and browser `CustomEvent`s. | Assign | LiveView `handle_event` receives | | ------ | -------------------------------- | | `on_value_change` | `%{"id" => binary, "value" => list}` | | `on_focus_change` | `%{"id" => binary, "value" => binary \\ nil}` | For **`on_value_change_client`** and **`on_focus_change_client`**, the browser emits **`CustomEvent`s** named with the strings you pass. Value changes include **`id`**, **`value`**, **`previousValue`**, **`added`**, **`removed`** when useful. After **`value`**, **`focused`**, or **`item_state`**, responses use the names below unless you route only to the DOM via **`respond_to`** (see [API](api.html)). | Response to LiveView (`pushEvent`) | DOM event on the accordion root | | ---------------------------------- | ------------------------------- | | `accordion_value_response` | `accordion-value` | | `accordion_focused_response` | `accordion-focused` | | `accordion_item_state_response` | `accordion-item-state` | ```heex <.accordion id="acc" class="accordion" on_value_change="accordion_changed" items={Corex.Content.new([%{value: "a", label: "Panel A", content: "Body A"}])} /> ``` ```elixir def handle_event("accordion_changed", %{"id" => _id, "value" => value}, socket) do {:noreply, assign(open: value)} end ``` ## Patterns ### Async ```elixir defmodule MyAppWeb.AccordionAsyncLive do use MyAppWeb, :live_view def mount(_params, _session, socket) do socket = socket |> assign_async(:accordion, fn -> items = Corex.Content.new([ %{ value: "lorem", label: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique.", disabled: true }, %{ value: "duis", label: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula, at interdum tellus." }, %{ value: "donec", label: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a. Sed ac eros luctus." } ]) {:ok, %{accordion: %{items: items, value: ["duis", "donec"]}}} end) {:ok, socket} end def render(assigns) do ~H""" <.async_result :let={accordion} assign={@accordion}> <:loading> <.accordion_skeleton count={3} class="accordion" /> <:failed>Could not load accordion. <.accordion id="async-accordion" class="accordion" items={accordion.items} value={accordion.value} /> """ end end ``` ### Controlled ```elixir defmodule MyAppWeb.AccordionLive do use MyAppWeb, :live_view def mount(_params, _session, socket) do {:ok, assign(socket, :accordion_value, ["lorem"])} end def handle_event("accordion_value_changed", %{"id" => _id, "value" => value}, socket) do {:noreply, assign(socket, :accordion_value, value)} end def render(assigns) do ~H""" <.accordion id="my-accordion" controlled value={@accordion_value} on_value_change="accordion_value_changed" class="accordion" items={ Corex.Content.new([ %{ value: "lorem", label: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit." }, %{ value: "duis", label: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula." } ]) } /> """ end end ``` ## Animation ### `js` Built-in height and opacity via the Web Animations API. Tune timing with `animation_options` using `Corex.Animation.Height`. ```heex <.accordion class="accordion" animation="js" animation_options={%Corex.Animation.Height{duration: 0.3, easing: "ease-out", opacity_start: 0, opacity_end: 1}} items={ Corex.Content.new([ %{ label: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique." }, %{ label: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula, at interdum tellus." }, %{ label: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a. Sed ac eros luctus." } ]) } > <:indicator> <.heroicon name="hero-chevron-right" /> ``` ### `instant` Zag toggles the native `hidden` attribute; no height animation. ```heex <.accordion class="accordion" animation="instant" items={ Corex.Content.new([ %{ label: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique." }, %{ label: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula, at interdum tellus." }, %{ label: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a. Sed ac eros luctus." } ]) } > <:indicator> <.heroicon name="hero-chevron-right" /> ``` ### `custom` The hook removes `hidden` and dispatches a browser `CustomEvent` when the value changes. Use `on_value_change_client` for the event name. The event `detail` is enriched with deltas so user code rarely needs DOM lookups beyond the affected items: // event.detail (AccordionChangedDetail) { id, value, previousValue, added, removed } Animate panels in your own JS. The example below also seeds initial closed-state styling on mount and after LiveView navigations. ```heex <.accordion id="accordion-custom-animate" class="accordion" animation="custom" on_value_change_client="my-accordion-changed" items={ Corex.Content.new([ %{ label: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique." }, %{ label: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula, at interdum tellus." }, %{ label: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a. Sed ac eros luctus." } ]) } > <:indicator> <.heroicon name="hero-chevron-right" /> ``` ```javascript import { animate } from "motion" import { findAccordionContent, animateHeightOpen, animateHeightClose, } from "corex" const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches document.addEventListener("my-accordion-changed", (e) => { const root = document.getElementById(e.detail.id) if (!root) return e.detail.added.forEach((v) => { const el = findAccordionContent(root, v) if (!el) return animateHeightOpen(el, { animator: animate, duration: 0.55, easing: [0.16, 1, 0.3, 1] }) if (!reducedMotion()) { animate( el, { filter: ["blur(12px)", "blur(0px)"], scale: [0.96, 1] }, { duration: 0.6, easing: [0.16, 1, 0.3, 1] }, ) } }) e.detail.removed.forEach((v) => { const el = findAccordionContent(root, v) if (!el) return animateHeightClose(el, { animator: animate, duration: 0.32, easing: [0.7, 0, 0.84, 0] }) if (!reducedMotion()) { animate( el, { filter: ["blur(0px)", "blur(10px)"], scale: [1, 0.97] }, { duration: 0.3, easing: "ease-in" }, ) } }) }) ``` ## Style Zag exposes `data-scope` and `data-part` on each element: ```css [data-scope="accordion"][data-part="root"] {} [data-scope="accordion"][data-part="item"] {} [data-scope="accordion"][data-part="item-trigger"] {} [data-scope="accordion"][data-part="item-content"] {} [data-scope="accordion"][data-part="item-indicator"] {} ``` With Corex Design, import tokens and the accordion stylesheet, then add the `accordion` class and modifiers: ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/accordion.css"; ``` ```heex <.accordion class="accordion accordion--accent accordion--lg" items={ Corex.Content.new([ %{label: "First", content: "First body."}, %{label: "Second", content: "Second body."} ]) } /> ``` ''' @doc type: :component use Phoenix.Component alias Corex.Accordion.Anatomy.{Item, Props, Root} alias Corex.Accordion.Connect alias Phoenix.LiveView alias Phoenix.LiveView.JS import Corex.Helpers, only: [validate_value!: 1, validate_content_items_required!: 2, respond_to_fields: 1] @doc """ Renders an accordion. See the module documentation for list-driven `items`, With slots, Custom slots, Manual and Compound modes, patterns, API, and events. """ attr(:id, :string, required: false, doc: "DOM id on the accordion root. Required for imperative helpers and DOM commands (see API guide)." ) attr(:items, :list, default: [], doc: "List of `%Corex.Content.Item{}` from `Corex.Content.new/1`." ) attr(:value, :any, default: [], doc: "Initial or controlled open state: one string or a list of strings (`value` of each item)." ) attr(:compound, :boolean, default: false, doc: "Enable compound mode. Use with :let={ctx} and sub-components to fully control structure." ) attr(:controlled, :boolean, default: false, doc: "LiveView owns open state. Requires `on_value_change` in LiveView (see Events guide)." ) attr(:collapsible, :boolean, default: true, doc: "Whether the accordion is collapsible") attr(:multiple, :boolean, default: true, doc: "Whether the accordion allows multiple items to be selected" ) attr(:animation, :string, default: "js", values: ["instant", "js", "custom"], doc: """ Animation mode for content open/close. - `instant` - no animation, content opens/closes instantly via native `hidden` attribute - `js` - built-in animation via Web Animations API (opacity + height); tune with `animation_options` (`Corex.Animation.Height`) - `custom` - removes `hidden`, full JS control via `on_value_change_client` """ ) attr(:animation_options, Corex.Animation.Height, default: %Corex.Animation.Height{}, doc: "Wired to the host when `animation` is `js` only. Custom transitions ignore this assign. See `Corex.Animation.Height` (opacity, height, `block_interaction`)." ) attr(:orientation, :string, default: "vertical", values: ["horizontal", "vertical"], doc: "The orientation of the accordion" ) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "The direction of the accordion. When nil, derived from document (html lang + config :rtl_locales)" ) attr(:on_value_change, :string, default: nil, doc: "LiveView event name for `pushEvent` when the open value(s) change. Params: `%{\"id\" => dom_id, \"value\" => list}`. See **Events** in the module doc." ) attr(:on_value_change_client, :string, default: nil, doc: "Browser `CustomEvent` type when the open value(s) change. `event.detail`: `%{id: dom_id, value: list, previousValue: list, added: list, removed: list}` (TS: `AccordionChangedDetail`). See **Events** in the module doc." ) attr(:on_focus_change, :string, default: nil, doc: "LiveView event name for `pushEvent` when the focused item changes. Params: `%{\"id\" => dom_id, \"value\" => focused_item_value}`. See **Events** in the module doc." ) attr(:on_focus_change_client, :string, default: nil, doc: "Browser `CustomEvent` type for focus changes. `event.detail`: `%{id: dom_id, value: focused_value}`. See **Events** in the module doc." ) attr(:rest, :global) slot(:inner_block, required: false, doc: """ Compound mode inner content. Use with the `compound` attribute and `:let={ctx}`. `ctx` is a map with keys: `id`, `values`, `orientation`, `dir`. """ ) slot :indicator, required: false, doc: "Optional slot after each trigger. With `:items`, use `:let={item}`. Without `:items` (manual mode), use one slot per panel and a matching `value` on `:trigger` and `:content`." do attr(:value, :string, required: false) attr(:class, :string, required: false) end slot :trigger, required: false, doc: "With `:items`, optional custom trigger; use `:let={item}`. Without `:items` (manual mode), one slot per panel with `value` (or default `item-0`, …)." do attr(:value, :string, required: false) attr(:class, :string, required: false) attr(:disabled, :boolean, required: false) end slot :content, required: false, doc: "With `:items`, optional custom content; use `:let={item}`. Without `:items` (manual mode), one slot per panel with `value` (or default `item-0`, …)." do attr(:value, :string, required: false) attr(:class, :string, required: false) attr(:disabled, :boolean, required: false) end def accordion(assigns) do assigns = assigns |> assign_new(:id, fn -> "accordion-#{System.unique_integer([:positive])}" end) |> update(:value, &normalize_value/1) |> then(fn assigns -> if not assigns.compound and Enum.empty?(assigns.items) and assigns.trigger == [] and assigns.content == [] do validate_content_items_required!(assigns, "Accordion") else assigns end end) |> then(&accordion_assert_trigger_content_pair!/1) |> then(&accordion_assign_manual_mode!/1) |> then(&accordion_assign_panels/1) ctx = %{ id: assigns.id, values: assigns.value, orientation: assigns.orientation, dir: assigns.dir, animation: assigns.animation } assigns = assign(assigns, :ctx, ctx) ~H"""{panel.item_entry.content}