Skip to main content

priv/registry/collapsible.json

{
  "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"
  ]
}