defmodule Corex.Dialog do @moduledoc ~S''' Phoenix implementation of [Zag.js Dialog](https://zagjs.com/components/react/dialog). ## Anatomy ### Minimal ```heex <.dialog class="dialog"> <:trigger>Open <:content>

Minimal content.

<:close_trigger> <.heroicon name="hero-x-mark" class="icon" /> ``` ### Title and description ```heex <.dialog class="dialog"> <:trigger>Open Dialog <:title>Dialog Title <:description> Short description of what this dialog is for. <:content>

Body content.

<:close_trigger> <.heroicon name="hero-x-mark" class="icon" /> ``` ### Actions in content ```heex <.dialog id="dialog-anatomy-actions" class="dialog"> <:trigger>Open Dialog <:title>Confirm <:description>Choose an action to continue. <:content>

Are you sure you want to continue?

<.action phx-click={Corex.Dialog.set_open("dialog-anatomy-actions", false)} class="button button--sm button--ghost"> Cancel <.action phx-click={Corex.Dialog.set_open("dialog-anatomy-actions", false)} class="button button--sm"> Continue
<:close_trigger> <.heroicon name="hero-x-mark" class="icon" /> ``` ## API Requires a stable `id` on `<.dialog>`. | Function | Action | Returns | | -------- | ------ | ------- | | [`set_open/2`](#set_open/2) | Set open state (client) | `%Phoenix.LiveView.JS{}` | | [`set_open/3`](#set_open/3) | Set open state (server) | `socket` | ### set_open ```heex <.action phx-click={Corex.Dialog.set_open("dialog-api", true)} class="button button--sm"> Open Dialog <.dialog id="dialog-api" class="dialog"> <:trigger>Open Dialog <:title>Dialog Title <:description>Dialog description. <:content>

Dialog content

<.action phx-click={Corex.Dialog.set_open("dialog-api", false)} class="button button--sm"> Close <:close_trigger> <.heroicon name="hero-x-mark" class="icon" /> ``` ```elixir def handle_event("open_dialog", _, socket) do {:noreply, Corex.Dialog.set_open(socket, "dialog-api", true)} end ``` ## Events ### Server events | Event | When | Payload | | ----- | ---- | ------- | | `on_open_change="dialog_open_changed"` | Open state changes | `%{"id" => id, "open" => open, "previousOpen" => previous}` | ### on_open_change ```heex <.dialog class="dialog" on_open_change="dialog_open_changed"> <:trigger>Open Dialog <:title>Dialog Title <:content>

Dialog content

<:close_trigger> <.heroicon name="hero-x-mark" class="icon" /> ``` ```elixir def handle_event("dialog_open_changed", %{"id" => id, "open" => open}, socket) do {:noreply, assign(socket, :dialog_open, open)} end ``` ### Client events | Event | When | `event.detail` | | ----- | ---- | -------------- | | `on_open_change_client="dialog-open-changed"` | Open state changes | `id`, `open`, `previousOpen` | ### on_open_change_client ```heex <.dialog id="dialog-events-client" class="dialog" on_open_change_client="dialog-open-changed"> <:trigger>Open <:content>

Content

<:close_trigger><.heroicon name="hero-x-mark" class="icon" /> ``` ```javascript document.getElementById("dialog-events-client")?.addEventListener("dialog-open-changed", (e) => { console.log(e.detail); }); ``` ## Animation Set `animation` on `<.dialog>` (`instant`, `js`, or `custom`). ### Instant ```heex <.dialog class="dialog" modal animation="instant"> <:trigger>Open <:title>Instant <:content>

Native show and hide without JS transitions.

<:close_trigger> <.heroicon name="hero-x-mark" class="icon" /> ``` ### JS Web Animations API via `animation_options` (`Corex.Animation.Scale`). ```heex <.dialog class="dialog" modal animation="js" animation_options={%Corex.Animation.Scale{duration: 0.3, easing: "ease-out"}} > <:trigger>Open <:title>JS <:content>

Scaled open and close.

<:close_trigger><.heroicon name="hero-x-mark" class="icon" /> ``` ### Custom Set `animation="custom"` and `on_open_change_client`. The hook does not toggle `hidden`; listen for: // event.detail — DialogOpenChangedDetail { id, open, previousOpen } ```heex <.dialog class="dialog" animation="custom" on_open_change_client="my-dialog-open-changed" > <:trigger>Open <:title>Custom <:content>

Motion animates open and close.

<:close_trigger> <.heroicon name="hero-x-mark" class="icon" /> ``` ```javascript import { animate } from "motion" import { findDialogBackdrop, findDialogContent, animateScaleOpen, animateScaleClose, } from "corex" document.addEventListener("my-dialog-open-changed", (e) => { const { id, open } = e.detail const root = document.getElementById(id) if (!root) return const backdrop = findDialogBackdrop(root) const content = findDialogContent(root) if (open) { if (backdrop) animateScaleOpen(backdrop, { animator: animate, duration: 0.5, easing: "ease-out" }) if (content) animateScaleOpen(content, { animator: animate, duration: 0.7, easing: [0.16, 1, 0.3, 1] }) } else { if (backdrop) animateScaleClose(backdrop, { animator: animate, duration: 0.4, easing: "ease-in" }) if (content) animateScaleClose(content, { animator: animate, duration: 0.35, easing: "ease-in" }) } }) ``` ## Alert dialog Use `role="alertdialog"` with explicit modal and dismiss behavior. Set `initial_focus` to the id of the least destructive action. ```heex <.dialog id="delete-item-alert" class="dialog" role="alertdialog" modal close_on_interact_outside={false} initial_focus="delete-item-alert-cancel" final_focus="dialog:delete-item-alert:trigger" > <:trigger>Delete item <:title>Delete this item? <:description>This action cannot be undone. <:content>
<.action id="delete-item-alert-cancel" phx-click={Corex.Dialog.set_open("delete-item-alert", false)} class="button button--sm button--ghost"> Cancel <.action phx-click={Corex.Dialog.set_open("delete-item-alert", false)} class="button button--sm button--alert"> Delete
``` ## Focus `initial_focus` is the `id` of a focusable element inside the dialog (for example a cancel `.action`). When omitted, Zag focuses the first focusable element in the content. `final_focus` is the `id` of the element to receive focus when the dialog closes (for example the trigger: `dialog:my-dialog:trigger`). When omitted, Zag restores focus to the element that had focus before the dialog opened. ## Style Stack modifiers on the host (`class` on `<.dialog>`). When `prevent_scroll` is enabled, Zag sets `--scrollbar-width` on the document root. Fixed or sticky app chrome can compensate with `calc(... + var(--scrollbar-width, 0px))` at the app level; Corex does not apply this globally. ### Default ```heex <.dialog class="dialog"> <:trigger>Open <:title>Default <:content>

Default size.

<:close_trigger><.heroicon name="hero-x-mark" class="icon" /> ``` ### Small ```heex <.dialog class="dialog dialog--sm"> <:trigger>Open <:title>Small <:content>

Compact dialog.

<:close_trigger><.heroicon name="hero-x-mark" class="icon" /> ``` ### Large ```heex <.dialog class="dialog dialog--lg"> <:trigger>Open <:title>Large <:content>

Spacious dialog.

<:close_trigger><.heroicon name="hero-x-mark" class="icon" /> ``` ### Text ```heex <.dialog class="dialog dialog--text-xl"> <:trigger>Open <:title>Larger type <:content>

Title, description, and body scale with the modifier.

<:close_trigger><.heroicon name="hero-x-mark" class="icon" /> ``` ### Radius ```heex <.dialog class="dialog dialog--rounded-xl"> <:trigger>Open <:title>Rounded panel <:content>

Corner radius on the content panel and close trigger.

<:close_trigger><.heroicon name="hero-x-mark" class="icon" /> ``` ### Side ```heex <.dialog class="dialog dialog--side" modal> <:trigger>Open <:title>Side panel <:content>

Slides in from the edge.

<:close_trigger><.heroicon name="hero-x-mark" class="icon" /> ``` ''' @doc type: :component use Phoenix.Component import Corex.Api.Doc alias Corex.Dialog.Anatomy.{ Backdrop, CloseTrigger, Content, Description, Positioner, Props, Title, Trigger } alias Corex.Api.RespondTo alias Corex.Dialog.Connect alias Corex.Dialog.Translation @doc """ Renders a dialog component. """ attr(:id, :string, required: false, doc: "The id of the dialog, useful for API to identify the dialog" ) attr(:open, :boolean, default: false, doc: "The initial open state or the controlled open state" ) attr(:controlled, :boolean, default: false, doc: "Whether the dialog is controlled. In LiveView, pair with on_open_change when true" ) attr(:modal, :boolean, default: false, doc: "Whether the dialog is modal" ) attr(:close_on_interact_outside, :boolean, default: true, doc: "Whether to close the dialog when clicking outside" ) attr(:close_on_escape, :boolean, default: true, doc: "Whether to close the dialog when pressing Escape" ) attr(:prevent_scroll, :boolean, default: false, doc: "Whether to prevent body scroll when dialog is open" ) attr(:restore_focus, :boolean, default: true, doc: "Whether to restore focus when dialog closes" ) attr(:role, :string, default: "dialog", values: [nil, "dialog", "alertdialog"], doc: "ARIA role for the dialog content (`dialog` or `alertdialog`)" ) attr(:initial_focus, :string, default: nil, doc: "Id of a focusable element inside the dialog to receive focus when opened (for alerts, use the least destructive action)" ) attr(:final_focus, :string, default: nil, doc: "Id of the element to receive focus when the dialog closes (for example the trigger id `dialog:my-dialog:trigger`)" ) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "The direction of the dialog. When nil, derived from document (html lang + config :rtl_locales)" ) attr(:on_open_change, :string, default: nil, doc: "Server event name when the open state changes. Payload: `%{id, open, previousOpen}` (TS: `DialogOpenChangedDetail`)." ) attr(:on_open_change_client, :string, default: nil, doc: "DOM event name dispatched when the open state changes. `event.detail` matches `DialogOpenChangedDetail`. Required for `animation=\"custom\"`." ) attr(:animation, :string, default: "js", values: ["instant", "js", "custom"], doc: "Open and close: native hidden (instant), Web Animations via `Corex.Animation.Scale` (js), or events only (custom)" ) attr(:animation_options, Corex.Animation.Scale, default: %Corex.Animation.Scale{scale_start: 0.96, scale_end: 1.0}, doc: "Wired to the host when `animation` is `js` only. Custom transitions ignore this assign. See `Corex.Animation.Scale` (opacity, scale, timing, `block_interaction`)." ) attr(:translation, Corex.Dialog.Translation, default: nil, doc: "Override translatable strings") attr(:rest, :global) slot :trigger, required: true do attr(:class, :string, required: false) attr(:aria_label, :string, required: false) attr(:title, :string, required: false) end slot :content, required: true do attr(:class, :string, required: false) end slot :title, required: false do attr(:class, :string, required: false) end slot :description, required: false do attr(:class, :string, required: false) end slot :close_trigger, required: false do attr(:class, :string, required: false) end def dialog(assigns) do translation = Translation.resolve(assigns.translation) assigns = assigns |> assign_new(:id, fn -> "dialog-#{System.unique_integer([:positive])}" end) |> assign(:translation, translation) id = assigns.id dir = assigns.dir open = assigns.open assigns = assigns |> assign(:trigger_struct, %Trigger{id: id, dir: dir, open: open}) |> assign(:backdrop_struct, %Backdrop{id: id, dir: dir, open: open}) |> assign(:positioner_struct, %Positioner{id: id, dir: dir, open: open}) |> assign(:content_struct, %Content{ id: id, dir: dir, open: open, role: assigns.role, has_title: assigns.title != [], has_description: assigns.description != [], label: translation.label }) |> assign(:title_struct, %Title{id: id, dir: dir, open: open}) |> assign(:description_struct, %Description{id: id, dir: dir, open: open}) |> assign(:close_trigger_struct, %CloseTrigger{ id: id, dir: dir, open: open, aria_label: assigns.translation.close }) ~H"""

{render_slot(@title)}

{render_slot(@description)}

{render_slot(@content)}
""" end @doc type: :component @doc "Renders the dialog title. Use inside `<:content>` when not using the top-level `<:title>` slot. Pass the same id as the parent dialog." attr(:id, :string, required: true) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"]) attr(:rest, :global) slot(:inner_block, required: true) def dialog_title(assigns) do assigns = assign(assigns, :title_struct, %Title{id: assigns.id, dir: assigns.dir, open: false}) ~H"""

{render_slot(@inner_block)}

""" end @doc type: :component @doc "Renders the dialog description. Use inside `<:content>` when not using the top-level `<:description>` slot. Pass the same id as the parent dialog." attr(:id, :string, required: true) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"]) attr(:rest, :global) slot(:inner_block, required: true) def dialog_description(assigns) do assigns = assign(assigns, :description_struct, %Description{ id: assigns.id, dir: assigns.dir, open: false }) ~H"""

{render_slot(@inner_block)}

""" end @doc type: :component @doc "Renders the dialog close button. Use inside `<:content>` when not using the top-level `<:close_trigger>` slot. Pass the same id as the parent dialog." attr(:id, :string, required: true) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"]) attr(:translation, Corex.Dialog.Translation, default: nil, doc: "Override translatable strings") attr(:rest, :global) slot(:inner_block, required: true) def dialog_close_trigger(assigns) do translation = Translation.resolve(Map.get(assigns, :translation)) assigns = assign(assigns, :close_trigger_struct, %CloseTrigger{ id: assigns.id, dir: assigns.dir, open: false, aria_label: translation.close }) ~H""" """ end api_doc(~S""" Set open state from a control (`phx-click`). ```heex <.action phx-click={Corex.Dialog.set_open("my-dialog", true)}>Open <.dialog id="my-dialog" class="dialog"> <:trigger>Open <:content>

Content.

<:close_trigger><.heroicon name="hero-x-mark" class="icon" /> ``` ```javascript document.getElementById("my-dialog")?.dispatchEvent( new CustomEvent("corex:dialog:set-open", { bubbles: false, detail: { open: true }, }) ); ``` """) def set_open(dialog_id, open) when is_binary(dialog_id) and is_boolean(open) do RespondTo.dispatch_set_open(dialog_id, open, "corex:dialog:set-open") end api_doc(~S""" Set open state from `handle_event`. ```heex <.action phx-click="open_dialog">Open <.dialog id="my-dialog" class="dialog"> <:trigger>Open <:content>

Content.

<:close_trigger><.heroicon name="hero-x-mark" class="icon" /> ``` ```elixir def handle_event("open_dialog", _, socket) do {:noreply, Corex.Dialog.set_open(socket, "my-dialog", true)} end ``` """) def set_open(socket, dialog_id, open) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(dialog_id) and is_boolean(open) do RespondTo.push_set_open(socket, "dialog_set_open", dialog_id, open) end end