Dialog
Modal dialog with focus trap, Escape dismiss, click-outside dismiss, and portal.
Installation
bin/rails g wabi:add 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
Delete account
This action cannot be undone.
render Components::UI::Dialog.new do
render Components::UI::DialogTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " "bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
) { "Open dialog" }
render Components::UI::DialogContent.new do
render Components::UI::DialogHeader.new do
render Components::UI::DialogTitle.new { "Delete account" }
render Components::UI::DialogDescription.new { "This action cannot be undone." }
end
render Components::UI::DialogFooter.new do
render Components::UI::DialogCancel.new { "Cancel" }
render Components::UI::DialogAction.new(appearance: :destructive, data: { action: "click->wabi--dialog#close" }) { "Delete" }
end
end
end
Source
app/components/ui/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 Dialog < 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--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--dialog-open-value": @open.to_s,
"wabi--dialog-modal-value": @modal.to_s,
"wabi--dialog-portal-value": @portal.to_s,
}
) do
yield if block_given?
end
end
end
end
end
app/components/ui/dialog_trigger.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DialogTrigger < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
button(
type: "button",
data: { "wabi--dialog-target": "trigger" },
class: user_class
) do
yield if block_given?
end
end
end
end
end
app/components/ui/dialog_content.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DialogContent < 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"
# alert: [Boolean] optional, default false.
# Pass `alert: true` for destructive or confirmation dialogs that require
# an immediate user response. Renders `role="alertdialog"` instead of
# `role="dialog"` per ARIA 1.2. The JS controller will re-apply the
# correct role after Zag's spreadProps (which always emits "dialog").
def initialize(alert: false, **attrs)
@alert = alert
@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--dialog-target": "backdrop" },
"data-state": "closed",
class: BACKDROP_CLASS
)
div(
data: { "wabi--dialog-target": "positioner" },
class: POSITIONER_CLASS
) do
div(
role: (@alert ? "alertdialog" : "dialog"),
"aria-modal": "true",
"data-state": "closed",
data: {
"wabi--dialog-target": "content",
# Signal the controller to re-apply role="alertdialog" after
# Zag's spreadProps overwrites it with its hardcoded "dialog".
"wabi--dialog-alert": @alert.to_s
},
inert: true,
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
end
app/components/ui/dialog_header.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DialogHeader < 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/dialog_footer.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DialogFooter < 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/dialog_title.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DialogTitle < 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--dialog-target": "title" },
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/dialog_description.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DialogDescription < 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--dialog-target": "description" },
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/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--dialog#close"` (or their own handler) so they
# can persist before dismissing.
class DialogAction < 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/components/ui/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 DialogCancel < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
render Components::UI::Button.new(
appearance: :outline,
data: { "wabi--dialog-target": "closeTrigger" },
**@attrs
) do
yield if block_given?
end
end
end
end
end
Accessibility
- role="dialog" + aria-modal="true"; title/description wired via aria-labelledby/aria-describedby.
- Initial focus moves into the dialog on open; restored to trigger on close.
- Focus trap keeps Tab inside the dialog while open.
- Backdrop click and Escape close the dialog (configurable via Zag opts).
- Content carries inert when closed — keeps out of tab order + a11y tree (Zag onOpenChange synchronous toggle).
- Scroll lock applied to <body> while modal is open.