Corex.Dialog (Corex v0.1.1)

View Source

Phoenix implementation of Zag.js Dialog.

Anatomy

Minimal

<.dialog class="dialog">
  <:trigger>Open</:trigger>
  <:content>
    <p>Minimal content.</p>
  </:content>
  <:close_trigger>
    <.heroicon name="hero-x-mark" class="icon" />
  </:close_trigger>
</.dialog>

Title and description

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

Actions in content

<.dialog id="dialog-anatomy-actions" class="dialog">
  <:trigger>Open Dialog</:trigger>
  <:title>Confirm</:title>
  <:description>Choose an action to continue.</:description>
  <:content>
    <p>Are you sure you want to continue?</p>
    <div class="flex flex-wrap justify-end gap-2 mt-4">
      <.action phx-click={Corex.Dialog.set_open("dialog-anatomy-actions", false)} class="button button--sm button--ghost">
        Cancel
      </.action>
      <.action phx-click={Corex.Dialog.set_open("dialog-anatomy-actions", false)} class="button button--sm">
        Continue
      </.action>
    </div>
  </:content>
  <:close_trigger>
    <.heroicon name="hero-x-mark" class="icon" />
  </:close_trigger>
</.dialog>

API

Requires a stable id on <.dialog>.

FunctionActionReturns
set_open/2Set open state (client)%Phoenix.LiveView.JS{}
set_open/3Set open state (server)socket

set_open

<.action phx-click={Corex.Dialog.set_open("dialog-api", true)} class="button button--sm">
  Open Dialog
</.action>
<.dialog id="dialog-api" class="dialog">
  <:trigger>Open Dialog</:trigger>
  <:title>Dialog Title</:title>
  <:description>Dialog description.</:description>
  <:content>
    <p>Dialog content</p>
    <.action phx-click={Corex.Dialog.set_open("dialog-api", false)} class="button button--sm">
      Close
    </.action>
  </:content>
  <:close_trigger>
    <.heroicon name="hero-x-mark" class="icon" />
  </:close_trigger>
</.dialog>
def handle_event("open_dialog", _, socket) do
  {:noreply, Corex.Dialog.set_open(socket, "dialog-api", true)}
end

Events

Server events

EventWhenPayload
on_open_change="dialog_open_changed"Open state changes%{"id" => id, "open" => open, "previousOpen" => previous}

on_open_change

<.dialog class="dialog" on_open_change="dialog_open_changed">
  <:trigger>Open Dialog</:trigger>
  <:title>Dialog Title</:title>
  <:content>
    <p>Dialog content</p>
  </:content>
  <:close_trigger>
    <.heroicon name="hero-x-mark" class="icon" />
  </:close_trigger>
</.dialog>
def handle_event("dialog_open_changed", %{"id" => id, "open" => open}, socket) do
  {:noreply, assign(socket, :dialog_open, open)}
end

Client events

EventWhenevent.detail
on_open_change_client="dialog-open-changed"Open state changesid, open, previousOpen

on_open_change_client

<.dialog id="dialog-events-client" class="dialog" on_open_change_client="dialog-open-changed">
  <:trigger>Open</:trigger>
  <:content><p>Content</p></:content>
  <:close_trigger><.heroicon name="hero-x-mark" class="icon" /></:close_trigger>
</.dialog>
document.getElementById("dialog-events-client")?.addEventListener("dialog-open-changed", (e) => {
  console.log(e.detail);
});

Animation

Set animation on <.dialog> (instant, js, or custom).

Instant

<.dialog class="dialog" modal animation="instant">
  <:trigger>Open</:trigger>
  <:title>Instant</:title>
  <:content>
    <p>Native show and hide without JS transitions.</p>
  </:content>
  <:close_trigger>
    <.heroicon name="hero-x-mark" class="icon" />
  </:close_trigger>
</.dialog>

JS

Web Animations API via animation_options (Corex.Animation.Scale).

<.dialog
  class="dialog"
  modal
  animation="js"
  animation_options={%Corex.Animation.Scale{duration: 0.3, easing: "ease-out"}}
>
  <:trigger>Open</:trigger>
  <:title>JS</:title>
  <:content><p>Scaled open and close.</p></:content>
  <:close_trigger><.heroicon name="hero-x-mark" class="icon" /></:close_trigger>
</.dialog>

Custom

Set animation="custom" and on_open_change_client. The hook does not toggle hidden; listen for:

// event.detail  DialogOpenChangedDetail
{ id, open, previousOpen }
<.dialog
  class="dialog"
  animation="custom"
  on_open_change_client="my-dialog-open-changed"
>
  <:trigger>Open</:trigger>
  <:title>Custom</:title>
  <:content>
    <p>Motion animates open and close.</p>
  </:content>
  <:close_trigger>
    <.heroicon name="hero-x-mark" class="icon" />
  </:close_trigger>
</.dialog>
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.

<.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</:trigger>
  <:title>Delete this item?</:title>
  <:description>This action cannot be undone.</:description>
  <:content>
    <div class="flex flex-wrap justify-end gap-2 mt-4">
      <.action id="delete-item-alert-cancel" phx-click={Corex.Dialog.set_open("delete-item-alert", false)} class="button button--sm button--ghost">
        Cancel
      </.action>
      <.action phx-click={Corex.Dialog.set_open("delete-item-alert", false)} class="button button--sm button--alert">
        Delete
      </.action>
    </div>
  </:content>
</.dialog>

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

<.dialog class="dialog">
  <:trigger>Open</:trigger>
  <:title>Default</:title>
  <:content><p>Default size.</p></:content>
  <:close_trigger><.heroicon name="hero-x-mark" class="icon" /></:close_trigger>
</.dialog>

Small

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

Large

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

Text

<.dialog class="dialog dialog--text-xl">
  <:trigger>Open</:trigger>
  <:title>Larger type</:title>
  <:content><p>Title, description, and body scale with the modifier.</p></:content>
  <:close_trigger><.heroicon name="hero-x-mark" class="icon" /></:close_trigger>
</.dialog>

Radius

<.dialog class="dialog dialog--rounded-xl">
  <:trigger>Open</:trigger>
  <:title>Rounded panel</:title>
  <:content><p>Corner radius on the content panel and close trigger.</p></:content>
  <:close_trigger><.heroicon name="hero-x-mark" class="icon" /></:close_trigger>
</.dialog>

Side

<.dialog class="dialog dialog--side" modal>
  <:trigger>Open</:trigger>
  <:title>Side panel</:title>
  <:content><p>Slides in from the edge.</p></:content>
  <:close_trigger><.heroicon name="hero-x-mark" class="icon" /></:close_trigger>
</.dialog>

Summary

Components

Renders a dialog component.

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.

Renders the dialog description. Use inside <:content> when not using the top-level <:description> slot. Pass the same id as the parent dialog.

Renders the dialog title. Use inside <:content> when not using the top-level <:title> slot. Pass the same id as the parent dialog.

API

Set open state from a control (phx-click).

Set open state from handle_event.

Components

dialog(assigns)

Renders a dialog component.

Attributes

  • id (:string) - The id of the dialog, useful for API to identify the dialog.
  • open (:boolean) - The initial open state or the controlled open state. Defaults to false.
  • controlled (:boolean) - Whether the dialog is controlled. In LiveView, pair with on_open_change when true. Defaults to false.
  • modal (:boolean) - Whether the dialog is modal. Defaults to false.
  • close_on_interact_outside (:boolean) - Whether to close the dialog when clicking outside. Defaults to true.
  • close_on_escape (:boolean) - Whether to close the dialog when pressing Escape. Defaults to true.
  • prevent_scroll (:boolean) - Whether to prevent body scroll when dialog is open. Defaults to false.
  • restore_focus (:boolean) - Whether to restore focus when dialog closes. Defaults to true.
  • role (:string) - ARIA role for the dialog content (dialog or alertdialog). Defaults to "dialog". Must be one of nil, "dialog", or "alertdialog".
  • initial_focus (:string) - Id of a focusable element inside the dialog to receive focus when opened (for alerts, use the least destructive action). Defaults to nil.
  • final_focus (:string) - Id of the element to receive focus when the dialog closes (for example the trigger id dialog:my-dialog:trigger). Defaults to nil.
  • dir (:string) - The direction of the dialog. When nil, derived from document (html lang + config :rtl_locales). Defaults to nil. Must be one of nil, "ltr", or "rtl".
  • on_open_change (:string) - Server event name when the open state changes. Payload: %{id, open, previousOpen} (TS: DialogOpenChangedDetail). Defaults to nil.
  • on_open_change_client (:string) - DOM event name dispatched when the open state changes. event.detail matches DialogOpenChangedDetail. Required for animation="custom". Defaults to nil.
  • animation (:string) - Open and close: native hidden (instant), Web Animations via Corex.Animation.Scale (js), or events only (custom). Defaults to "js". Must be one of "instant", "js", or "custom".
  • animation_options (Corex.Animation.Scale) - Wired to the host when animation is js only. Custom transitions ignore this assign. See Corex.Animation.Scale (opacity, scale, timing, block_interaction). Defaults to %Corex.Animation.Scale{duration: 0.3, easing: "ease", opacity_start: 0.0, opacity_end: 1.0, scale_start: 0.96, scale_end: 1.0, block_interaction: false}.
  • translation (Corex.Dialog.Translation) - Override translatable strings. Defaults to nil.
  • Global attributes are accepted.

Slots

  • trigger (required) - Accepts attributes:
    • class (:string)
    • aria_label (:string)
    • title (:string)
  • content (required) - Accepts attributes:
    • class (:string)
  • title - Accepts attributes:
    • class (:string)
  • description - Accepts attributes:
    • class (:string)
  • close_trigger - Accepts attributes:
    • class (:string)

dialog_close_trigger(assigns)

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.

Attributes

  • id (:string) (required)
  • dir (:string) - Defaults to nil.Must be one of nil, "ltr", or "rtl".
  • translation (Corex.Dialog.Translation) - Override translatable strings. Defaults to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required)

dialog_description(assigns)

Renders the dialog description. Use inside <:content> when not using the top-level <:description> slot. Pass the same id as the parent dialog.

Attributes

  • id (:string) (required)
  • dir (:string) - Defaults to nil.Must be one of nil, "ltr", or "rtl".
  • Global attributes are accepted.

Slots

  • inner_block (required)

dialog_title(assigns)

Renders the dialog title. Use inside <:content> when not using the top-level <:title> slot. Pass the same id as the parent dialog.

Attributes

  • id (:string) (required)
  • dir (:string) - Defaults to nil.Must be one of nil, "ltr", or "rtl".
  • Global attributes are accepted.

Slots

  • inner_block (required)

API

set_open(dialog_id, open)

Set open state from a control (phx-click).

<.action phx-click={Corex.Dialog.set_open("my-dialog", true)}>Open</.action>
<.dialog id="my-dialog" class="dialog">
  <:trigger>Open</:trigger>
  <:content><p>Content.</p></:content>
  <:close_trigger><.heroicon name="hero-x-mark" class="icon" /></:close_trigger>
</.dialog>
document.getElementById("my-dialog")?.dispatchEvent(
  new CustomEvent("corex:dialog:set-open", {
    bubbles: false,
    detail: { open: true },
  })
);

set_open(socket, dialog_id, open)

Set open state from handle_event.

<.action phx-click="open_dialog">Open</.action>
<.dialog id="my-dialog" class="dialog">
  <:trigger>Open</:trigger>
  <:content><p>Content.</p></:content>
  <:close_trigger><.heroicon name="hero-x-mark" class="icon" /></:close_trigger>
</.dialog>
def handle_event("open_dialog", _, socket) do
  {:noreply, Corex.Dialog.set_open(socket, "my-dialog", true)}
end