{
"files": [
{
"content": "defmodule Shadix.Components.Accordion do\n @moduledoc ~S\"\"\"\n A vertically stacked set of collapsible sections, built purely on client-side\n JS commands and CSS — no LiveView hook and no server round-trip.\n\n Each trigger's `phx-click` flips a `data-state` of `\"open\"`/`\"closed\"` (and the\n matching `aria-expanded`) on both itself and its paired content panel. The\n panel animates open/closed in pure CSS using the `grid-template-rows`\n `0fr → 1fr` technique: the content lives inside a grid whose single row\n transitions between `0fr` (collapsed) and `1fr` (expanded), with the inner\n wrapper clipping overflow. This animates the panel's height smoothly without\n anyone having to measure it, and degrades to an instant toggle under\n `prefers-reduced-motion`.\n\n Because the collapsed panel stays in the DOM (so it can animate) rather than\n being `display:none`, it carries `inert` while closed to keep it out of the\n tab order and the accessibility tree; the same `phx-click` toggles `inert`.\n The trigger's `data-state` also drives the chevron rotation via the\n `[&[data-state=open]>svg]:rotate-180` utility lifted from shadcn.\n\n Ids wire the pieces together: `accordion_item/1` takes a unique `:id`,\n `accordion_trigger/1` and `accordion_content/1` take that same id and derive\n `\"#{id}-trigger\"` / `\"#{id}-content\"` so `aria-controls` and the toggle target\n line up stably.\n\n NOTE: `type=\"single\"` auto-closing of sibling items is simplified — items\n behave as independent toggles in this v1, so `type` is currently advisory and\n rendered as a `data-type` hint on the root.\n \"\"\"\n use Phoenix.Component\n\n alias Phoenix.LiveView.JS\n\n import Shadix.Cn\n\n @doc \"\"\"\n Renders the accordion root, a vertical stack of `accordion_item/1`s.\n\n `:type` is advisory in this v1 (see moduledoc): both `\"single\"` and\n `\"multiple\"` render independent toggles and the value is exposed as\n `data-type` for styling/JS hooks.\n \"\"\"\n attr(:type, :string, default: \"single\", values: ~w(single multiple))\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def accordion(assigns) do\n ~H\"\"\"\n <div data-slot=\"accordion\" data-type={@type} class={cn([@class])} {@rest}>\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"\"\"\n A single accordion item; wraps a trigger and its content.\n\n `:id` must be unique per item and is reused by `accordion_trigger/1` and\n `accordion_content/1` to wire `aria-controls` and the toggle target.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def accordion_item(assigns) do\n ~H\"\"\"\n <div id={@id} data-slot=\"accordion-item\" class={cn([\"border-b last:border-b-0\", @class])} {@rest}>\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc ~S\"\"\"\n The clickable header of an item. `:id` is the *item* id.\n\n Renders a `<button id=\"#{id}-trigger\">` with `aria-controls=\"#{id}-content\"`,\n `aria-expanded` and a `data-state` that toggle on click, plus a chevron SVG\n that rotates when `data-state=\"open\"`. The same `phx-click` flips the paired\n content panel's `data-state` (which drives the CSS open/close animation) and\n its `inert` attribute.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def accordion_trigger(assigns) do\n ~H\"\"\"\n <h3 class=\"flex\" data-slot=\"accordion-header\">\n <button\n type=\"button\"\n id={\"#{@id}-trigger\"}\n data-slot=\"accordion-trigger\"\n data-state=\"closed\"\n aria-expanded=\"false\"\n aria-controls={\"#{@id}-content\"}\n phx-click={\n JS.toggle_attribute({\"aria-expanded\", \"true\", \"false\"}, to: \"##{@id}-trigger\")\n |> JS.toggle_attribute({\"data-state\", \"open\", \"closed\"}, to: \"##{@id}-trigger\")\n |> JS.toggle_attribute({\"data-state\", \"open\", \"closed\"}, to: \"##{@id}-content\")\n |> JS.toggle_attribute({\"inert\", \"true\"}, to: \"##{@id}-content\")\n }\n class={\n cn([\n \"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n class=\"pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n </h3>\n \"\"\"\n end\n\n @doc ~S\"\"\"\n The collapsible body of an item. `:id` is the *item* id.\n\n Renders `<div id=\"#{id}-content\" role=\"region\">`, collapsed (`data-state=\"closed\"`\n and `inert`) by default. The panel animates open/closed in pure CSS via the\n `grid-template-rows` `0fr → 1fr` trick keyed off `data-state`; the inner\n `overflow-hidden` wrapper clips the content as the row collapses. Under\n `prefers-reduced-motion` the transition is dropped and it toggles instantly.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def accordion_content(assigns) do\n ~H\"\"\"\n <div\n id={\"#{@id}-content\"}\n data-slot=\"accordion-content\"\n data-state=\"closed\"\n role=\"region\"\n aria-labelledby={\"#{@id}-trigger\"}\n inert\n class=\"grid grid-rows-[0fr] text-sm transition-[grid-template-rows] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] data-[state=open]:grid-rows-[1fr] motion-reduce:transition-none\"\n >\n <div class=\"overflow-hidden\">\n <div class={cn([\"pt-0 pb-4\", @class])}>\n {render_slot(@inner_block)}\n </div>\n </div>\n </div>\n \"\"\"\n end\nend\n",
"path": "accordion.ex"
}
],
"hooks": [],
"name": "accordion",
"npm_deps": [],
"registry_deps": [
"cn"
]
}