Skip to main content

priv/registry/dialog.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.Dialog do\n  @moduledoc \"\"\"\n  A modal dialog built on client-side JS commands and a small LiveView hook.\n\n  Unlike the React/Radix original, this is a portal-free, fully client-driven\n  dialog: `show_dialog/1` and `hide_dialog/1` are `Phoenix.LiveView.JS` command\n  builders that toggle the overlay and content panel (with transitions) and\n  dispatch `shadix:dialog-open` / `shadix:dialog-close` events. The `ShadixDialog`\n  hook (assets/ts/dialog.ts) listens for those events to lock body scroll and\n  manage focus. `Escape` and overlay-click both close via `phx-window-keydown` /\n  `phx-click`. Focus is trapped with Phoenix's built-in `<.focus_wrap>`.\n\n  The trigger and content live side by side under a `*-root` wrapper carrying the\n  hook; ids are derived from the required `:id` so titles/descriptions wire up to\n  `aria-labelledby` / `aria-describedby` stably.\n  \"\"\"\n  use Phoenix.Component\n\n  alias Phoenix.LiveView.JS\n\n  import Shadix.Cn\n\n  @doc \"\"\"\n  Renders a modal dialog with a `:trigger` slot and arbitrary content.\n\n  The trigger is wrapped in a `display: contents` span wired to `show_dialog/1`,\n  so the caller's own button/element shows the dialog without extra markup.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:class, :string, default: nil)\n  slot(:trigger, required: true)\n  slot(:inner_block, required: true)\n\n  def dialog(assigns) do\n    ~H\"\"\"\n    <span phx-click={show_dialog(@id)} class=\"contents\">{render_slot(@trigger)}</span>\n    <div id={\"#{@id}-root\"} phx-hook=\"ShadixDialog\">\n      <div\n        id={\"#{@id}-overlay\"}\n        class=\"hidden fixed inset-0 z-50 bg-black/50\"\n        aria-hidden=\"true\"\n        phx-click={hide_dialog(@id)}\n      />\n      <.focus_wrap\n        id={\"#{@id}-content\"}\n        class={\n          cn([\n            \"hidden fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg\",\n            @class\n          ])\n        }\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-labelledby={\"#{@id}-title\"}\n        aria-describedby={\"#{@id}-description\"}\n        data-slot=\"dialog-content\"\n        phx-window-keydown={hide_dialog(@id)}\n        phx-key=\"escape\"\n      >\n        {render_slot(@inner_block)}\n        <button\n          type=\"button\"\n          phx-click={hide_dialog(@id)}\n          aria-label=\"Close\"\n          class=\"absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n        >\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          >\n            <path d=\"M18 6 6 18\" />\n            <path d=\"m6 6 12 12\" />\n          </svg>\n        </button>\n      </.focus_wrap>\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Returns the `Phoenix.LiveView.JS` command that opens the dialog with `id`.\n\n  Shows the overlay and content panel with enter transitions and dispatches\n  `shadix:dialog-open` on the `*-root` element for the `ShadixDialog` hook.\n  \"\"\"\n  def show_dialog(id) do\n    %JS{}\n    |> JS.show(\n      to: \"##{id}-overlay\",\n      transition: {\"transition-opacity ease-out duration-200\", \"opacity-0\", \"opacity-100\"}\n    )\n    |> JS.show(\n      to: \"##{id}-content\",\n      display: \"grid\",\n      transition:\n        {\"transition ease-out duration-200\", \"opacity-0 scale-95\", \"opacity-100 scale-100\"}\n    )\n    |> JS.dispatch(\"shadix:dialog-open\", to: \"##{id}-root\")\n    |> JS.focus_first(to: \"##{id}-content\")\n  end\n\n  @doc \"\"\"\n  Returns the `Phoenix.LiveView.JS` command that closes the dialog with `id`.\n\n  Hides the overlay and content panel with leave transitions and dispatches\n  `shadix:dialog-close` on the `*-root` element for the `ShadixDialog` hook.\n  \"\"\"\n  def hide_dialog(id) do\n    %JS{}\n    |> JS.hide(\n      to: \"##{id}-overlay\",\n      transition: {\"transition-opacity ease-in duration-150\", \"opacity-100\", \"opacity-0\"}\n    )\n    |> JS.hide(\n      to: \"##{id}-content\",\n      transition:\n        {\"transition ease-in duration-150\", \"opacity-100 scale-100\", \"opacity-0 scale-95\"}\n    )\n    |> JS.dispatch(\"shadix:dialog-close\", to: \"##{id}-root\")\n  end\n\n  @doc \"Header region of a dialog; stacks title/description with sensible spacing.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def dialog_header(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"dialog-header\"\n      class={cn([\"flex flex-col gap-2 text-center sm:text-left\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"Footer region of a dialog; right-aligns actions on larger screens.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def dialog_footer(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"dialog-footer\"\n      class={cn([\"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc ~S\"\"\"\n  Dialog title. `:id` is the *dialog's* id; the title renders with `\"#{id}-title\"`\n  so it matches the content panel's `aria-labelledby`.\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 dialog_title(assigns) do\n    ~H\"\"\"\n    <h2\n      id={\"#{@id}-title\"}\n      data-slot=\"dialog-title\"\n      class={cn([\"text-lg leading-none font-semibold\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </h2>\n    \"\"\"\n  end\n\n  @doc ~S\"\"\"\n  Dialog description. `:id` is the *dialog's* id; renders with `\"#{id}-description\"`.\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 dialog_description(assigns) do\n    ~H\"\"\"\n    <p\n      id={\"#{@id}-description\"}\n      data-slot=\"dialog-description\"\n      class={cn([\"text-muted-foreground text-sm\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </p>\n    \"\"\"\n  end\nend\n",
      "path": "dialog.ex"
    }
  ],
  "hooks": [
    {
      "content": "interface DialogHook {\n  el: HTMLElement;\n  mounted(): void;\n  destroyed(): void;\n}\n\nconst FOCUSABLE =\n  'a[href],area[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\nexport const ShadixDialog = {\n  mounted(this: DialogHook) {\n    const el = this.el;\n    let restoreTo: HTMLElement | null = null;\n\n    const open = () => {\n      restoreTo = document.activeElement as HTMLElement | null;\n      document.body.style.overflow = \"hidden\";\n      window.requestAnimationFrame(() => {\n        const content = el.querySelector<HTMLElement>('[role=\"dialog\"]');\n        const first = content?.querySelector<HTMLElement>(FOCUSABLE);\n        (first ?? content)?.focus();\n      });\n    };\n\n    const close = () => {\n      document.body.style.overflow = \"\";\n      restoreTo?.focus();\n      restoreTo = null;\n    };\n\n    el.addEventListener(\"shadix:dialog-open\", open);\n    el.addEventListener(\"shadix:dialog-close\", close);\n  },\n\n  destroyed(this: DialogHook) {\n    document.body.style.overflow = \"\";\n  },\n};\n",
      "name": "ShadixDialog",
      "path": "dialog.ts"
    }
  ],
  "name": "dialog",
  "npm_deps": [],
  "registry_deps": [
    "cn"
  ]
}