defmodule PhiaUi.Components.Dialog do @moduledoc """ Accessible Dialog (Modal) component following the WAI-ARIA Dialog pattern. Uses `Phoenix.LiveView.JS` for open/close transitions and the `PhiaDialog` JavaScript Hook for focus trap, keyboard navigation, and scroll locking. ## Registration in app.js After running `mix phia.add dialog`, register the hook in your LiveSocket: import PhiaDialog from "./phia_hooks/dialog.js" let liveSocket = new LiveSocket("/live", Socket, { hooks: { PhiaDialog, ...yourOtherHooks } }) ## Example <.dialog id="confirm-delete"> <.dialog_trigger for="confirm-delete"> <.button variant={:destructive}>Delete <.dialog_content id="confirm-delete"> <.dialog_header> <.dialog_title id="confirm-delete-title">Delete Item <.dialog_description id="confirm-delete-description"> This action cannot be undone. <.dialog_footer> <.dialog_close for="confirm-delete">Cancel <.button variant={:destructive} phx-click="delete">Delete ## Sub-components | Function | Purpose | |-----------------------|----------------------------------------------| | `dialog/1` | Hook anchor, outer container | | `dialog_trigger/1` | Opens the dialog via JS.remove_class | | `dialog_content/1` | Overlay + panel (hidden by default) | | `dialog_header/1` | Title + description layout container | | `dialog_title/1` | `

` heading (set id for ARIA linkage) | | `dialog_description/1`| `

` supporting text (set id for ARIA) | | `dialog_footer/1` | Action row (close button, confirmations) | | `dialog_close/1` | Closes the dialog via JS.add_class | """ use Phoenix.Component import PhiaUi.ClassMerger, only: [cn: 1] alias Phoenix.LiveView.JS # --------------------------------------------------------------------------- # dialog/1 # --------------------------------------------------------------------------- @doc """ Outer container for the Dialog. Binds the `PhiaDialog` JavaScript Hook. Must wrap both `dialog_trigger/1` and `dialog_content/1`. """ attr(:id, :string, required: true, doc: "Unique ID used as the hook anchor") attr(:class, :string, default: nil, doc: "Additional CSS classes") slot(:inner_block, required: true) def dialog(assigns) do ~H"""

<%= render_slot(@inner_block) %>
""" end # --------------------------------------------------------------------------- # dialog_trigger/1 # --------------------------------------------------------------------------- @doc """ Opens the dialog by calling `JS.remove_class("hidden")` on `#dialog-{for}`. """ attr(:for, :string, required: true, doc: "ID of the dialog to open (matches dialog/1's :id)") attr(:class, :string, default: nil) attr(:rest, :global) slot(:inner_block, required: true) def dialog_trigger(assigns) do ~H"""
JS.add_class("opacity-100 scale-100", to: "#dialog-#{@for} [data-dialog-panel]") } class={cn(["cursor-pointer inline-block", @class])} {@rest} > <%= render_slot(@inner_block) %>
""" end # --------------------------------------------------------------------------- # dialog_content/1 # --------------------------------------------------------------------------- @doc """ The dialog surface: renders the overlay backdrop and the modal panel. Hidden by default. Shown by `dialog_trigger/1` via `JS.remove_class("hidden")`. The outer container id is `"dialog-{id}"` (prefixed) so that `dialog_trigger/1` and `dialog_close/1` can target it. ## Size variants | Value | Max width | |-------------|----------------------------| | `"sm"` | `max-w-sm` | | `"default"` | `max-w-lg` (default) | | `"lg"` | `max-w-2xl` | | `"xl"` | `max-w-4xl` | | `"full"` | `max-w-[calc(100vw-2rem)]` | """ attr(:id, :string, required: true, doc: "ID matching the parent dialog/1's :id") attr(:size, :string, default: "default", values: ~w(sm default lg xl full), doc: "Width of the dialog panel" ) attr(:show_close_button, :boolean, default: true, doc: "Render the default X close button in the top-right corner of the panel" ) attr(:scrollable, :boolean, default: false, doc: "Add overflow-y-auto to the panel for content that may overflow the viewport" ) attr(:full_screen_mobile, :boolean, default: false, doc: """ When `true`, the dialog panel fills the entire screen on mobile viewports (`fixed inset-0 rounded-none`) and reverts to its normal centered behavior on `sm:` and wider screens (`sm:relative sm:inset-auto sm:rounded-lg`). Ideal for complex forms or content that benefits from full viewport space on small devices. """ ) attr(:class, :string, default: nil, doc: "Additional classes for the panel") slot(:inner_block, required: true) def dialog_content(assigns) do ~H""" """ end # --------------------------------------------------------------------------- # dialog_header/1 # --------------------------------------------------------------------------- @doc """ Layout container for the dialog title and description. Renders a `
` with `flex flex-col space-y-1.5 text-center sm:text-left`. On mobile viewports the content is centred; on `sm` and wider it aligns to the left, matching the standard modal convention for each form factor. Wrap `dialog_title/1` and `dialog_description/1` inside this component. Both must have their `:id` set (to `"{dialog-id}-title"` and `"{dialog-id}-description"`) so the panel's `aria-labelledby` and `aria-describedby` attributes resolve correctly. ## Example <.dialog_header> <.dialog_title id="confirm-delete-title"> Delete project? <.dialog_description id="confirm-delete-description"> All data will be permanently removed. This cannot be undone. """ attr(:class, :string, default: nil) attr(:rest, :global) slot(:inner_block, required: true) def dialog_header(assigns) do ~H"""
<%= render_slot(@inner_block) %>
""" end # --------------------------------------------------------------------------- # dialog_title/1 # --------------------------------------------------------------------------- @doc """ Dialog heading (`

`). Set `:id` to `"{dialog-id}-title"` for ARIA linkage. """ attr(:id, :string, default: nil) attr(:class, :string, default: nil) attr(:rest, :global) slot(:inner_block, required: true) def dialog_title(assigns) do ~H"""

<%= render_slot(@inner_block) %>

""" end # --------------------------------------------------------------------------- # dialog_description/1 # --------------------------------------------------------------------------- @doc """ Supporting text below the title. Set `:id` to `"{dialog-id}-description"` for ARIA linkage. """ attr(:id, :string, default: nil) attr(:class, :string, default: nil) attr(:rest, :global) slot(:inner_block, required: true) def dialog_description(assigns) do ~H"""

<%= render_slot(@inner_block) %>

""" end # --------------------------------------------------------------------------- # dialog_footer/1 # --------------------------------------------------------------------------- @doc """ Action row at the bottom of the dialog for confirmation and close buttons. Renders a `
` with `flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2`. **Responsive stacking behaviour:** - On mobile (`< sm`): buttons stack vertically in reverse DOM order, so the primary action (last in the template) appears visually at the top — thumb-friendly and consistent with mobile modal conventions. - On `sm`+ screens: buttons arrange horizontally, aligned to the right, with `space-x-2` (8px) gaps. Place the cancel/close action first in the template and the primary (submit/confirm) action last. The CSS reversal puts the primary action at the top on mobile automatically: ## Example <.dialog_footer> <%!-- Cancel appears second on desktop, first (top) on mobile --%> <.dialog_close for="confirm-delete">Cancel <%!-- Primary action appears first on desktop (rightmost), top on mobile --%> <.button variant={:destructive} phx-click="delete_item"> Delete """ attr(:class, :string, default: nil) attr(:rest, :global) slot(:inner_block, required: true) def dialog_footer(assigns) do ~H"""
<%= render_slot(@inner_block) %>
""" end # --------------------------------------------------------------------------- # dialog_close/1 # --------------------------------------------------------------------------- @doc """ Closes the dialog via `JS.add_class("hidden")` on `#dialog-{for}`. The Escape key is also handled by the `PhiaDialog` JS Hook. """ attr(:for, :string, required: true, doc: "ID of the dialog to close (matches dialog/1's :id)") attr(:class, :string, default: nil) attr(:rest, :global) slot(:inner_block, required: true) def dialog_close(assigns) do ~H""" """ end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- defp dialog_content_size("sm"), do: "max-w-sm" defp dialog_content_size("default"), do: "max-w-lg" defp dialog_content_size("lg"), do: "max-w-2xl" defp dialog_content_size("xl"), do: "max-w-4xl" defp dialog_content_size("full"), do: "max-w-[calc(100vw-2rem)]" end