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