{
"files": [
{
"content": "defmodule Shadix.Components.Collapsible do\n @moduledoc \"\"\"\n An interactive disclosure that shows or hides a region of content.\n\n Unlike the React/Radix original, this is a portal-free, client-driven\n collapsible. The trigger's `phx-click` runs `Phoenix.LiveView.JS.toggle/1`\n to flip the content's visibility with no server round-trip. A small\n `ShadixCollapsible` hook (assets/ts/collapsible.ts) on the trigger watches the\n content's visibility and mirrors it onto the trigger as `aria-expanded` and\n `data-state` (`open` / `closed`) so assistive tech and `data-state`-based\n styles stay in sync — something `JS` alone can't express here.\n\n The trigger and content derive their ids from the required `:id`, so\n `aria-controls` wires the button to the `\"<id>-content\"` element stably.\n \"\"\"\n use Phoenix.Component\n\n alias Phoenix.LiveView.JS\n\n import Shadix.Cn\n\n @doc \"\"\"\n Renders a collapsible region with a `:trigger` slot and collapsible content.\n\n The `:trigger` slot's contents are placed inside a `<button>` that toggles the\n content. Pass `open` to render initially expanded.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:open, :boolean, default: false)\n attr(:class, :string, default: nil)\n slot(:trigger, required: true)\n slot(:inner_block, required: true)\n\n def collapsible(assigns) do\n ~H\"\"\"\n <div data-slot=\"collapsible\" class={cn([\"flex flex-col gap-2\", @class])}>\n <button\n type=\"button\"\n id={\"#{@id}-trigger\"}\n data-slot=\"collapsible-trigger\"\n data-state={if @open, do: \"open\", else: \"closed\"}\n aria-expanded={if @open, do: \"true\", else: \"false\"}\n aria-controls={\"#{@id}-content\"}\n phx-hook=\"ShadixCollapsible\"\n phx-click={JS.toggle(to: \"##{@id}-content\", display: \"block\")}\n >\n {render_slot(@trigger)}\n </button>\n <div id={\"#{@id}-content\"} data-slot=\"collapsible-content\" hidden={!@open}>\n {render_slot(@inner_block)}\n </div>\n </div>\n \"\"\"\n end\nend\n",
"path": "collapsible.ex"
}
],
"hooks": [
{
"content": "interface CollapsibleHook {\n el: HTMLElement;\n mounted(): void;\n destroyed(): void;\n}\n\nexport const ShadixCollapsible = {\n mounted(this: CollapsibleHook) {\n const trigger = this.el;\n const content = document.getElementById(\n trigger.getAttribute(\"aria-controls\") ?? \"\",\n );\n if (!content) return;\n\n // `JS.toggle` flips the `hidden` attribute (initial state) and/or the inline\n // `display` style, so check both via the computed style to stay correct\n // regardless of which mechanism is currently in effect.\n const visible = () =>\n !content.hidden && getComputedStyle(content).display !== \"none\";\n\n const sync = () => {\n const open = visible();\n trigger.setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n trigger.setAttribute(\"data-state\", open ? \"open\" : \"closed\");\n };\n\n sync();\n\n const observer = new MutationObserver(sync);\n observer.observe(content, {\n attributes: true,\n attributeFilter: [\"style\", \"hidden\"],\n });\n\n (trigger as unknown as { _cleanup?: () => void })._cleanup = () =>\n observer.disconnect();\n },\n\n destroyed(this: CollapsibleHook) {\n (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n },\n};\n",
"name": "ShadixCollapsible",
"path": "collapsible.ts"
}
],
"name": "collapsible",
"npm_deps": [],
"registry_deps": [
"cn"
]
}