Alert Dialog
Modal confirmation dialog — role=alertdialog, no click-outside dismiss, initial focus on Cancel.
Installation
bin/rails g wabi:add alert_dialog
bin/importmap pin @zag-js/dialog
bin/importmap pin @zag-js/vanillaPin @zag-js/dialog and @zag-js/vanilla at version 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only downloads the main entry and leaves submodule imports unresolved.
Example
Are you absolutely sure?
This permanently deletes your account and cannot be undone.
render Components::UI::AlertDialog.new do
render Components::UI::AlertDialogTrigger.new(class: "inline-flex h-10 px-4 items-center rounded-md border border-input") { "Delete account" }
render Components::UI::AlertDialogContent.new do
render Components::UI::AlertDialogHeader.new do
render Components::UI::AlertDialogTitle.new { "Are you absolutely sure?" }
render Components::UI::AlertDialogDescription.new { "This permanently deletes your account and cannot be undone." }
end
render Components::UI::AlertDialogFooter.new do
render Components::UI::AlertDialogCancel.new { "Cancel" }
render Components::UI::AlertDialogAction.new(appearance: :destructive) { "Delete" }
end
end
end
Source
app/components/ui/alert_dialog.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class AlertDialog < Wabi::Base
def initialize(id: nil, open: false, modal: true, portal: true, **attrs)
@id = id
@open = open
@modal = modal
@portal = portal
@attrs = attrs
end
def view_template(&block)
div(
id: @id,
data: {
controller: "wabi--alert-dialog",
# `.to_s` matters for Stimulus Boolean values -- a bare boolean true
# serializes to a value-less attribute `data-...-value`, which
# Stimulus then parses as the string "" and treats as `false`.
# Emitting "true"/"false" strings makes the value type roundtrip.
"wabi--alert-dialog-open-value": @open.to_s,
"wabi--alert-dialog-modal-value": @modal.to_s,
"wabi--alert-dialog-portal-value": @portal.to_s,
}
) do
yield if block_given?
end
end
end
end
end
app/components/ui/alert_dialog_trigger.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class AlertDialogTrigger < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
button(
type: "button",
data: { "wabi--alert-dialog-target": "trigger" },
class: merge_class(user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/alert_dialog_content.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class AlertDialogContent < Wabi::Base
variants do
# `data-[state=closed]:pointer-events-none` is critical: when closed,
# the content is `opacity-0` (invisible) but STILL `fixed` with a
# centered footprint. Without disabling pointer events when closed,
# the invisible box intercepts clicks in the center of the viewport.
# Same trap as the original positioner-covers-everything bug.
base "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 border border-input bg-background p-6 shadow-lg sm:rounded-lg " \
"transition-opacity duration-200 ease-out motion-reduce:transition-none " \
"data-[state=open]:opacity-100 data-[state=open]:pointer-events-auto " \
"data-[state=closed]:opacity-0 data-[state=closed]:pointer-events-none"
end
# Backdrop also needs the pointer-events flip -- it's `fixed inset-0`
# which covers the entire viewport even at opacity 0.
BACKDROP_CLASS = "fixed inset-0 z-40 bg-black/80 " \
"transition-opacity duration-200 ease-out motion-reduce:transition-none " \
"data-[state=open]:opacity-100 data-[state=open]:pointer-events-auto " \
"data-[state=closed]:opacity-0 data-[state=closed]:pointer-events-none"
POSITIONER_CLASS = "fixed inset-0 z-50 flex items-center justify-center pointer-events-none"
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
# Visibility now lives on `data-state` rather than the `hidden`
# attribute. The controller force-clears `hidden` after spreadProps
# (Zag still sets it from getContentProps/getBackdropProps) and
# applies `inert` on the content when closed so tab order skips it.
# Without that switch, `hidden` cascades display:none and CSS
# transitions never run (the element snaps off-screen mid-fade).
div(
data: { "wabi--alert-dialog-target": "backdrop" },
"data-state": "closed",
class: BACKDROP_CLASS
)
div(
data: { "wabi--alert-dialog-target": "positioner" },
class: POSITIONER_CLASS
) do
div(
role: "alertdialog",
"aria-modal": "true",
"data-state": "closed",
data: { "wabi--alert-dialog-target": "content" },
inert: true,
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
end
app/components/ui/alert_dialog_header.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class AlertDialogHeader < Wabi::Base
variants { base "flex flex-col space-y-1.5 text-center sm:text-left" }
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(class: merge_class(tokens, user_class)) do
yield if block_given?
end
end
end
end
end
app/components/ui/alert_dialog_title.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class AlertDialogTitle < Wabi::Base
variants { base "text-lg font-semibold leading-none tracking-tight" }
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
h2(
data: { "wabi--alert-dialog-target": "title" },
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/alert_dialog_description.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class AlertDialogDescription < Wabi::Base
variants { base "text-sm text-muted-foreground" }
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
p(
data: { "wabi--alert-dialog-target": "description" },
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/alert_dialog_footer.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class AlertDialogFooter < Wabi::Base
variants { base "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2" }
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(class: merge_class(tokens, user_class)) do
yield if block_given?
end
end
end
end
end
app/components/ui/alert_dialog_cancel.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Outlined "Cancel" button. Tagged as a Zag closeTrigger so the dialog
# closes on click without the caller needing to wire data-action manually.
class AlertDialogCancel < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
render Components::UI::Button.new(
appearance: :outline,
data: { "wabi--alert-dialog-target": "closeTrigger cancel" },
**@attrs
) do
yield if block_given?
end
end
end
end
end
app/components/ui/alert_dialog_action.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Primary "Confirm" button. Does NOT auto-close -- the caller wires
# `data-action="click->wabi--alert-dialog#close"` (or their own handler) so they
# can persist before dismissing.
class AlertDialogAction < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
render Components::UI::Button.new(**@attrs) do
yield if block_given?
end
end
end
end
end
app/javascript/controllers/wabi/alert_dialog_controller.js
import { Controller } from "@hotwired/stimulus"
import * as dialog from "@zag-js/dialog"
import { VanillaMachine, normalizeProps, spreadProps } from "@zag-js/vanilla"
import { WabiPortalRegistry } from "controllers/wabi/_shared/portal_registry"
import { capturePortalRefs, attachToBody, restoreFromBody } from "controllers/wabi/_shared/overlay_portal"
export default class extends Controller {
static targets = ["trigger", "backdrop", "positioner", "content", "title", "description", "closeTrigger"]
static values = {
open: { type: Boolean, default: false },
modal: { type: Boolean, default: true },
portal: { type: Boolean, default: true },
}
connect() {
capturePortalRefs(this)
this.triggerEl = this.hasTriggerTarget ? this.triggerTarget : null
// Capture in-content targets BEFORE move — Stimulus targets only resolve to
// descendants of the controller element, but after the move these live under
// <body>. Items/buttons inside content need their captured refs in render().
this.closeTriggerEls = this.contentEl
? Array.from(this.contentEl.querySelectorAll('[data-wabi--alert-dialog-target~="closeTrigger"]'))
: []
this.titleEl = this.contentEl?.querySelector('[data-wabi--alert-dialog-target="title"]') || null
this.descriptionEl = this.contentEl?.querySelector('[data-wabi--alert-dialog-target="description"]') || null
this.cancelEl = this.contentEl?.querySelector('[data-wabi--alert-dialog-target~="cancel"]') || null
this.portaled = this.portalValue
this.isModalOverlay = this.modalValue
if (this.portaled) attachToBody(this)
this.machine = new VanillaMachine(dialog.machine, {
id: this.element.id || crypto.randomUUID(),
role: "alertdialog",
defaultOpen: this.openValue,
modal: this.modalValue,
closeOnInteractOutside: false,
initialFocusEl: () => this.cancelEl,
onOpenChange: ({ open }) => {
this.openValue = open
if (this.isModalOverlay) WabiPortalRegistry.onOpenChange()
if (this.contentEl) {
if (open) this.contentEl.removeAttribute("inert")
else this.contentEl.setAttribute("inert", "")
}
this.dispatch("change", { detail: { open } })
},
})
this.unsubscribe = this.machine.subscribe(() => this.render())
this.machine.start()
if (this.portaled && this.isModalOverlay) WabiPortalRegistry.register(this)
this.render()
}
disconnect() {
this.unsubscribe?.()
this.machine?.stop()
if (this.portaled) {
restoreFromBody(this)
if (this.isModalOverlay) WabiPortalRegistry.unregister(this)
}
}
isOpen() { return this.openValue }
open() { this.api()?.setOpen(true) }
close() { this.api()?.setOpen(false) }
api() {
return this.machine && dialog.connect(this.machine.service, normalizeProps)
}
render() {
const api = this.api()
if (!api) return
if (this.triggerEl) {
spreadProps(this.triggerEl, api.getTriggerProps())
// Zag hardcodes aria-haspopup="dialog" in getTriggerProps regardless of the
// machine role, so correct it to match the alertdialog popup.
this.triggerEl.setAttribute("aria-haspopup", "alertdialog")
}
if (this.positionerEl) spreadProps(this.positionerEl, api.getPositionerProps())
if (this.titleEl) spreadProps(this.titleEl, api.getTitleProps())
if (this.descriptionEl) spreadProps(this.descriptionEl, api.getDescriptionProps())
this.closeTriggerEls.forEach((el) => spreadProps(el, api.getCloseTriggerProps()))
if (this.backdropEl) {
spreadProps(this.backdropEl, api.getBackdropProps())
this.backdropEl.hidden = false
}
if (this.contentEl) {
spreadProps(this.contentEl, api.getContentProps())
this.contentEl.hidden = false
}
}
}
Accessibility
- role="alertdialog" + aria-modal="true"; title/description wired via aria-labelledby/aria-describedby.
- Does not dismiss on outside click — requires an explicit action (Cancel or Confirm).
- Escape closes the dialog.
- Initial focus moves to the Cancel button on open; restored to trigger on close.
- Focus trap keeps Tab inside the dialog while open.
- Content carries inert when closed — keeps out of tab order + a11y tree.
- Scroll lock applied to <body> while modal is open.
- The trigger's aria-controls points to the dialog content, which is portaled and only present in the DOM while open; automated tools may flag the reference as invalid when the dialog is closed.