Packages

shadcn/ui-inspired component library for Phoenix LiveView with eject-based distribution. CSS-first theme system, 829 components across 20+ categories — Calendar, Chart, Animation, DnD, Editor, Collaboration, Typography, Navigation, Background, Surface, and more.

Current section

Files

Jump to
phia_ui priv templates components dialog.ex.eex
Raw

priv/templates/components/dialog.ex.eex

defmodule <%= @module_name %>.Components.UI.Dialog do
@moduledoc """
Accessible Dialog (Modal) component following the WAI-ARIA Dialog pattern.
Uses `Phoenix.LiveView.JS` for open/close and the `PhiaDialog` JavaScript Hook
for focus trap, keyboard navigation, and scroll locking.
## Registration in app.js
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</.button>
</.dialog_trigger>
<.dialog_content id="confirm-delete">
<.dialog_header>
<.dialog_title id="confirm-delete-title">Delete Item</.dialog_title>
<.dialog_description id="confirm-delete-description">
This action cannot be undone.
</.dialog_description>
</.dialog_header>
<.dialog_footer>
<.dialog_close for="confirm-delete">Cancel</.dialog_close>
</.dialog_footer>
</.dialog_content>
</.dialog>
"""
use Phoenix.Component
import <%= @module_name %>.ClassMerger, only: [cn: 1]
alias Phoenix.LiveView.JS
attr :id, :string, required: true, doc: "Unique ID used as the hook anchor"
attr :class, :string, default: nil
slot :inner_block, required: true
def dialog(assigns) do
~H"""
<div id={@id} phx-hook="PhiaDialog" class={cn(["relative", @class])}>
<%%= render_slot(@inner_block) %>
</div>
"""
end
attr :for, :string, required: true, doc: "ID of the dialog to open"
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def dialog_trigger(assigns) do
~H"""
<div
phx-click={JS.show(to: "#dialog-#{@for}")}
class={cn(["cursor-pointer inline-block", @class])}
{@rest}
>
<%%= render_slot(@inner_block) %>
</div>
"""
end
attr :id, :string, required: true, doc: "ID matching the parent dialog/1's :id"
attr :class, :string, default: nil
slot :inner_block, required: true
def dialog_content(assigns) do
~H"""
<div id={"dialog-#{@id}"} class="fixed inset-0 z-50 hidden" data-dialog-content>
<div
class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm"
data-dialog-overlay
aria-hidden="true"
></div>
<div
role="dialog"
aria-modal="true"
aria-labelledby={"#{@id}-title"}
aria-describedby={"#{@id}-description"}
class={cn([
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]",
"gap-4 border bg-background p-6 shadow-lg sm:rounded-lg",
"transition-all ease-out duration-300 opacity-0 scale-95",
@class
])}
data-dialog-panel
>
<%%= render_slot(@inner_block) %>
</div>
</div>
"""
end
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def dialog_header(assigns) do
~H"""
<div class={cn(["flex flex-col space-y-1.5 text-center sm:text-left", @class])} {@rest}>
<%%= render_slot(@inner_block) %>
</div>
"""
end
attr :id, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def dialog_title(assigns) do
~H"""
<h2
id={@id}
class={cn(["text-lg font-semibold leading-none tracking-tight", @class])}
{@rest}
>
<%%= render_slot(@inner_block) %>
</h2>
"""
end
attr :id, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def dialog_description(assigns) do
~H"""
<p id={@id} class={cn(["text-sm text-muted-foreground", @class])} {@rest}>
<%%= render_slot(@inner_block) %>
</p>
"""
end
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def dialog_footer(assigns) do
~H"""
<div
class={cn(["flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", @class])}
{@rest}
>
<%%= render_slot(@inner_block) %>
</div>
"""
end
attr :for, :string, required: true, doc: "ID of the dialog to close"
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def dialog_close(assigns) do
~H"""
<button
type="button"
phx-click={JS.hide(to: "#dialog-#{@for}")}
class={cn([
"inline-flex items-center justify-center rounded-md text-sm font-medium",
"ring-offset-background transition-colors focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
@class
])}
{@rest}
>
<%%= render_slot(@inner_block) %>
</button>
"""
end
end