{
"files": [
{
"content": "defmodule Shadix.Components.AlertDialog do\n @moduledoc \"\"\"\n A modal alert dialog for destructive or otherwise consequential confirmations.\n\n This is the `Dialog`'s stricter sibling: it renders with `role=\"alertdialog\"`\n and deliberately offers no dismissal affordances other than an explicit\n choice. There is **no X close button** and clicking the overlay does **not**\n close it — the user must pick the action or cancel. `Escape` is still honored\n via `phx-window-keydown` so keyboard users are never trapped.\n\n Like `Dialog`, it is portal-free and fully client-driven: `show_alert_dialog/1`\n and `hide_alert_dialog/1` are `Phoenix.LiveView.JS` command builders that toggle\n the overlay and content panel (with transitions) and dispatch\n `shadix:alert-dialog-open` / `shadix:alert-dialog-close` events. The\n `ShadixAlertDialog` hook (assets/ts/alert_dialog.ts) listens for those events to\n lock body scroll and manage focus. Focus is trapped with Phoenix's built-in\n `<.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 alert dialog with a `:trigger` slot and arbitrary content.\n\n The trigger is wrapped in a `display: contents` span wired to\n `show_alert_dialog/1`, so the caller's own button/element opens the dialog\n without extra markup. The overlay is intentionally not clickable and no close\n button is rendered; only `Escape` and the explicit action/cancel controls\n dismiss it.\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 alert_dialog(assigns) do\n ~H\"\"\"\n <span phx-click={show_alert_dialog(@id)} class=\"contents\">{render_slot(@trigger)}</span>\n <div id={\"#{@id}-root\"} phx-hook=\"ShadixAlertDialog\">\n <div\n id={\"#{@id}-overlay\"}\n class=\"hidden fixed inset-0 z-50 bg-black/50\"\n aria-hidden=\"true\"\n data-slot=\"alert-dialog-overlay\"\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=\"alertdialog\"\n aria-modal=\"true\"\n aria-labelledby={\"#{@id}-title\"}\n aria-describedby={\"#{@id}-description\"}\n data-slot=\"alert-dialog-content\"\n phx-window-keydown={hide_alert_dialog(@id)}\n phx-key=\"escape\"\n >\n {render_slot(@inner_block)}\n </.focus_wrap>\n </div>\n \"\"\"\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that opens the alert dialog with `id`.\n\n Shows the overlay and content panel with enter transitions and dispatches\n `shadix:alert-dialog-open` on the `*-root` element for the `ShadixAlertDialog`\n hook.\n \"\"\"\n def show_alert_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:alert-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 alert dialog with `id`.\n\n Hides the overlay and content panel with leave transitions and dispatches\n `shadix:alert-dialog-close` on the `*-root` element for the `ShadixAlertDialog`\n hook.\n \"\"\"\n def hide_alert_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:alert-dialog-close\", to: \"##{id}-root\")\n end\n\n @doc \"Header region of an alert 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 alert_dialog_header(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"alert-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 an alert dialog; right-aligns the cancel/action controls on larger screens.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def alert_dialog_footer(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"alert-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 Alert dialog title. `:id` is the *dialog's* id; the title renders with\n `\"#{id}-title\"` 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 alert_dialog_title(assigns) do\n ~H\"\"\"\n <h2\n id={\"#{@id}-title\"}\n data-slot=\"alert-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 Alert dialog description. `:id` is the *dialog's* id; renders with\n `\"#{id}-description\"` so it matches the content panel's `aria-describedby`.\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 alert_dialog_description(assigns) do\n ~H\"\"\"\n <p\n id={\"#{@id}-description\"}\n data-slot=\"alert-dialog-description\"\n class={cn([\"text-muted-foreground text-sm\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </p>\n \"\"\"\n end\n\n @doc ~S\"\"\"\n The primary (often destructive) action button.\n\n Renders a plain primary `<button>` carrying `data-slot=\"alert-dialog-action\"`.\n The caller is expected to wire it with `phx-click` (and may pass any other\n attributes via the global `:rest`) to perform the confirmed operation; it does\n not close the dialog on its own.\n \"\"\"\n attr(:class, :string, default: nil)\n attr(:type, :string, default: \"button\")\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def alert_dialog_action(assigns) do\n ~H\"\"\"\n <button\n type={@type}\n data-slot=\"alert-dialog-action\"\n class={\n cn([\n \"inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium whitespace-nowrap text-primary-foreground shadow-xs transition-all outline-none hover:bg-primary/90 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n </button>\n \"\"\"\n end\n\n @doc ~S\"\"\"\n The cancel button: an outline-styled `<button>` that closes the alert dialog.\n\n `:id` is the *dialog's* id so the button can call `hide_alert_dialog/1` on the\n correct panel. Carries `data-slot=\"alert-dialog-cancel\"`.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:type, :string, default: \"button\")\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def alert_dialog_cancel(assigns) do\n ~H\"\"\"\n <button\n type={@type}\n data-slot=\"alert-dialog-cancel\"\n phx-click={hide_alert_dialog(@id)}\n class={\n cn([\n \"inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-md border bg-background px-4 py-2 text-sm font-medium whitespace-nowrap shadow-xs transition-all outline-none hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n </button>\n \"\"\"\n end\nend\n",
"path": "alert_dialog.ex"
}
],
"hooks": [
{
"content": "interface AlertDialogHook {\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 ShadixAlertDialog = {\n mounted(this: AlertDialogHook) {\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=\"alertdialog\"]');\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:alert-dialog-open\", open);\n el.addEventListener(\"shadix:alert-dialog-close\", close);\n },\n\n destroyed(this: AlertDialogHook) {\n document.body.style.overflow = \"\";\n },\n};\n",
"name": "ShadixAlertDialog",
"path": "alert_dialog.ts"
}
],
"name": "alert_dialog",
"npm_deps": [],
"registry_deps": [
"cn"
]
}