Alert Dialog
A modal dialog that interrupts the user with important content and expects a response.
Usage
Example
AlertDialog do
AlertDialogTrigger do
Button(variant: :outline) { "Show dialog" }
end
AlertDialogContent do
AlertDialogHeader do
AlertDialogTitle { "Are you absolutely sure?" }
AlertDialogDescription { "This action cannot be undone. This will permanently delete your account and remove your data from our servers." }
end
AlertDialogFooter do
AlertDialogCancel { "Cancel" }
AlertDialogAction { "Continue" } # Will probably be a link to a controller action (e.g. delete account)
end
end
end
Small
Pass size: :sm to AlertDialogContent for a narrower dialog with the footer buttons side by side.
AlertDialog do
AlertDialogTrigger do
Button(variant: :outline) { "Show dialog" }
end
AlertDialogContent(size: :sm) do
AlertDialogHeader do
AlertDialogTitle { "Allow accessory to connect?" }
AlertDialogDescription { "Do you want to allow the USB accessory to connect to this device?" }
end
AlertDialogFooter do
AlertDialogCancel { "Don't allow" }
AlertDialogAction { "Allow" }
end
end
end
Media
AlertDialogMedia shows an icon next to the title (above it on small screens or with size: :sm).
AlertDialog do
AlertDialogTrigger do
Button(variant: :destructive) { "Delete chat" }
end
AlertDialogContent do
AlertDialogHeader do
AlertDialogMedia(class: "bg-destructive/10 text-destructive") do
svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s|
s.path(d: "M3 6h18")
s.path(d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6")
s.path(d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2")
end
end
AlertDialogTitle { "Delete chat?" }
AlertDialogDescription { "This will permanently delete this chat conversation." }
end
AlertDialogFooter do
AlertDialogCancel { "Cancel" }
AlertDialogAction(variant: :destructive) { "Delete" }
end
end
end
Inside a form
The dialog renders in place, and HTML does not allow nested forms: a button_to inside AlertDialogAction loses its own form and submits the outer one instead. Render the AlertDialog after the form, or give the action button a form attribute that points to a form elsewhere on the page.
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component AlertDialog
Manual installation
1
Add RubyUI::AlertDialog to app/components/ruby_ui/alert_dialog/alert_dialog.rb
# frozen_string_literal: true
module RubyUI
class AlertDialog < Base
def initialize(open: false, **attrs)
@open = open
super(**attrs)
end
def view_template(&)
div(**attrs, &)
end
private
# `contents` keeps the Stimulus wrapper out of layout, like shadcn's
# element-less Root.
def default_attrs
{
data: {
controller: "ruby-ui--alert-dialog",
ruby_ui__alert_dialog_open_value: @open.to_s
},
class: "contents"
}
end
end
end
2
Add RubyUI::AlertDialogAction to app/components/ruby_ui/alert_dialog/alert_dialog_action.rb
# frozen_string_literal: true
module RubyUI
class AlertDialogAction < Base
def view_template(&)
render RubyUI::Button.new(**attrs, &)
end
private
def default_attrs
{
variant: :primary
}
end
end
end
3
Add RubyUI::AlertDialogCancel to app/components/ruby_ui/alert_dialog/alert_dialog_cancel.rb
# frozen_string_literal: true
module RubyUI
class AlertDialogCancel < Base
def view_template(&)
render RubyUI::Button.new(**attrs, &)
end
private
def default_attrs
{
variant: :outline,
data: {
action: "click->ruby-ui--alert-dialog#dismiss"
}
}
end
end
end
4
Add RubyUI::AlertDialogContent to app/components/ruby_ui/alert_dialog/alert_dialog_content.rb
# frozen_string_literal: true
require "securerandom"
module RubyUI
class AlertDialogContent < Base
SIZES = %i[default sm].freeze
def initialize(size: :default, **attrs)
raise ArgumentError, "size must be one of #{SIZES.join(", ")}" unless SIZES.include?(size.to_sym)
@size = size.to_sym
super(**attrs)
end
# Title and Description take their ids from the context; the children are
# captured first so the <dialog> only references the ones that rendered.
def view_template(&block)
previous = context[:ruby_ui_alert_dialog]
id = attrs[:id] || "ruby-ui-alert-dialog-#{SecureRandom.hex(4)}"
names = context[:ruby_ui_alert_dialog] = {id: id}
body = block ? capture(&block) : ""
dialog(**dialog_attrs(id, names)) { raw(safe(body)) }
ensure
context[:ruby_ui_alert_dialog] = previous
end
private
def dialog_attrs(id, names)
aria = {}
aria[:labelledby] = names[:labelledby] if names[:labelledby] && !author_aria?(:label, :labelledby)
aria[:describedby] = names[:describedby] if names[:describedby] && !author_aria?(:describedby)
(aria.empty? ? attrs : mix({aria: aria}, attrs)).merge(id: id)
end
def author_aria?(*keys)
keys.any? { |key| attrs[:"aria_#{key}"] || attrs.dig(:aria, key) }
end
# No backdrop click action: an alert dialog only closes through its
# buttons or Esc (APG alertdialog).
def default_attrs
{
role: "alertdialog",
data: {
ruby_ui__alert_dialog_target: "dialog",
size: @size
},
class: [
"group/alert-dialog-content fixed open:grid top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full gap-6 rounded-xl bg-popover p-6 text-popover-foreground outline-none ring-1 ring-foreground/10 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
"backdrop:bg-black/10 supports-backdrop-filter:backdrop:backdrop-blur-xs",
"duration-100 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards motion-reduce:animate-none!",
"backdrop:duration-100 data-[state=open]:backdrop:animate-in data-[state=open]:backdrop:fade-in-0 data-[state=closed]:backdrop:animate-out data-[state=closed]:backdrop:fade-out-0 data-[state=closed]:backdrop:fill-mode-forwards motion-reduce:backdrop:animate-none!"
]
}
end
end
end
5
Add RubyUI::AlertDialogDescription to app/components/ruby_ui/alert_dialog/alert_dialog_description.rb
# frozen_string_literal: true
module RubyUI
class AlertDialogDescription < Base
# Inside AlertDialogContent the description describes the dialog (aria-describedby).
def view_template(&)
names = context[:ruby_ui_alert_dialog]
# Only the first one gets the generated id, so ids stay unique.
return p(**attrs, &) if names.nil? || (!attrs[:id] && names[:describedby])
id = attrs[:id] || "#{names[:id]}-description"
names[:describedby] ||= id
p(**attrs.merge(id: id), &)
end
private
def default_attrs
{
class: "text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground"
}
end
end
end
6
Add RubyUI::AlertDialogDocs to app/components/ruby_ui/alert_dialog/alert_dialog_docs.rb
# frozen_string_literal: true
class Views::Docs::AlertDialog < Views::Base
def view_template
component = "AlertDialog"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Alert Dialog", description: "A modal dialog that interrupts the user with important content and expects a response.")
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Example", context: self) do
<<~RUBY
AlertDialog do
AlertDialogTrigger do
Button(variant: :outline) { "Show dialog" }
end
AlertDialogContent do
AlertDialogHeader do
AlertDialogTitle { "Are you absolutely sure?" }
AlertDialogDescription { "This action cannot be undone. This will permanently delete your account and remove your data from our servers." }
end
AlertDialogFooter do
AlertDialogCancel { "Cancel" }
AlertDialogAction { "Continue" } # Will probably be a link to a controller action (e.g. delete account)
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Small", description: "Pass size: :sm to AlertDialogContent for a narrower dialog with the footer buttons side by side.", context: self) do
<<~RUBY
AlertDialog do
AlertDialogTrigger do
Button(variant: :outline) { "Show dialog" }
end
AlertDialogContent(size: :sm) do
AlertDialogHeader do
AlertDialogTitle { "Allow accessory to connect?" }
AlertDialogDescription { "Do you want to allow the USB accessory to connect to this device?" }
end
AlertDialogFooter do
AlertDialogCancel { "Don't allow" }
AlertDialogAction { "Allow" }
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Media", description: "AlertDialogMedia shows an icon next to the title (above it on small screens or with size: :sm).", context: self) do
<<~RUBY
AlertDialog do
AlertDialogTrigger do
Button(variant: :destructive) { "Delete chat" }
end
AlertDialogContent do
AlertDialogHeader do
AlertDialogMedia(class: "bg-destructive/10 text-destructive") do
svg(xmlns: "http://www.w3.org/2000/svg", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s|
s.path(d: "M3 6h18")
s.path(d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6")
s.path(d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2")
end
end
AlertDialogTitle { "Delete chat?" }
AlertDialogDescription { "This will permanently delete this chat conversation." }
end
AlertDialogFooter do
AlertDialogCancel { "Cancel" }
AlertDialogAction(variant: :destructive) { "Delete" }
end
end
end
RUBY
end
Heading(level: 2) { "Inside a form" }
p { "The dialog renders in place, and HTML does not allow nested forms: a button_to inside AlertDialogAction loses its own form and submits the outer one instead. Render the AlertDialog after the form, or give the action button a form attribute that points to a form elsewhere on the page." }
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
end
7
Add RubyUI::AlertDialogFooter to app/components/ruby_ui/alert_dialog/alert_dialog_footer.rb
# frozen_string_literal: true
module RubyUI
class AlertDialogFooter < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end"
}
end
end
end
8
Add RubyUI::AlertDialogHeader to app/components/ruby_ui/alert_dialog/alert_dialog_header.rb
# frozen_string_literal: true
module RubyUI
class AlertDialogHeader < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]"
}
end
end
end
9
Add RubyUI::AlertDialogMedia to app/components/ruby_ui/alert_dialog/alert_dialog_media.rb
# frozen_string_literal: true
module RubyUI
class AlertDialogMedia < Base
def view_template(&)
div(**attrs, &)
end
private
# data-slot drives the header's media layout (has-data-[slot=alert-dialog-media]).
def default_attrs
{
data: {slot: "alert-dialog-media"},
class: "bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8"
}
end
end
end
10
Add RubyUI::AlertDialogTitle to app/components/ruby_ui/alert_dialog/alert_dialog_title.rb
# frozen_string_literal: true
module RubyUI
class AlertDialogTitle < Base
# Inside AlertDialogContent the title names the dialog (aria-labelledby).
def view_template(&)
names = context[:ruby_ui_alert_dialog]
# Only the first one gets the generated id, so ids stay unique.
return h2(**attrs, &) if names.nil? || (!attrs[:id] && names[:labelledby])
id = attrs[:id] || "#{names[:id]}-title"
names[:labelledby] ||= id
h2(**attrs.merge(id: id), &)
end
private
def default_attrs
{
class: "font-heading text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2"
}
end
end
end
11
Add RubyUI::AlertDialogTrigger to app/components/ruby_ui/alert_dialog/alert_dialog_trigger.rb
# frozen_string_literal: true
module RubyUI
class AlertDialogTrigger < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {
ruby_ui__alert_dialog_target: "trigger",
action: "click->ruby-ui--alert-dialog#open"
},
class: "inline-block"
}
end
end
end
12
Add alert_dialog_controller.js to app/javascript/controllers/ruby_ui/alert_dialog_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="ruby-ui--alert-dialog"
//
// <dialog role="alertdialog"> opened with showModal(): the browser makes the
// page inert, moves focus in, traps Tab and returns focus on close. Title and
// Description are wired in Ruby. This controller adds what the browser lacks:
// the exit animation, the page scroll lock and the trigger's ARIA.
// Unlike Dialog, a backdrop click doesn't close it; only Cancel or Esc do.
export default class extends Controller {
static targets = ["dialog", "trigger"]
static values = { open: Boolean }
// Target callbacks also run when Turbo replaces only part of the component.
dialogTargetConnected(dialog) {
// A Turbo cache snapshot keeps `open` but not modality; don't restore it half-open.
if (dialog.open && !dialog.matches(":modal")) dialog.close()
dialog.addEventListener("cancel", this.handleCancel)
dialog.addEventListener("close", this.handleClose)
this.triggerTargets.forEach((trigger) => this.wireTrigger(trigger))
}
dialogTargetDisconnected(dialog) {
dialog.removeEventListener("cancel", this.handleCancel)
dialog.removeEventListener("close", this.handleClose)
this.closeToken = null
this.releaseScroll()
}
triggerTargetConnected(trigger) {
this.wireTrigger(trigger)
}
connect() {
document.addEventListener("turbo:before-cache", this.handleBeforeCache)
if (this.openValue) this.open()
}
disconnect() {
document.removeEventListener("turbo:before-cache", this.handleBeforeCache)
this.closeToken = null
this.releaseScroll()
}
open(e) {
e?.preventDefault()
if (!this.hasDialogTarget) return
this.closeToken = null // reopening cancels a close still animating
this.dialogTarget.dataset.state = "open"
if (!this.dialogTarget.open) this.dialogTarget.showModal()
this.scrollLocked = true
document.body.classList.add("overflow-hidden")
this.setExpanded(true)
}
// Plays the exit animation (panel + ::backdrop), then closes. The token lets
// a reopen or a later close invalidate this one.
async dismiss() {
if (!this.hasDialogTarget || !this.dialogTarget.open || this.closeToken) return
const dialog = this.dialogTarget
const token = (this.closeToken = Symbol("close"))
dialog.dataset.state = "closed"
const animations = document.getAnimations().filter((animation) => animation.effect?.target === dialog)
await Promise.allSettled(animations.map((animation) => animation.finished))
if (this.closeToken === token) dialog.close()
}
// Esc: hold the native close so the exit animation plays. A dismissed
// <input type="file"> picker also fires a bubbling `cancel`; ignore it.
handleCancel = (e) => {
if (e.target !== e.currentTarget) return
e.preventDefault()
this.dismiss()
}
handleClose = (e) => {
this.closeToken = null
delete e.currentTarget.dataset.state
this.releaseScroll()
this.setExpanded(false)
}
// Turbo snapshots the page right after this event and `close` fires later,
// so clean up synchronously: the cache never keeps an open dialog or a locked body.
handleBeforeCache = () => {
if (!this.hasDialogTarget || !this.dialogTarget.open) return
this.closeToken = null
this.dialogTarget.close()
delete this.dialogTarget.dataset.state
this.releaseScroll()
this.setExpanded(false)
}
// Only release a lock this dialog took, and keep it while another modal is open.
releaseScroll() {
if (!this.scrollLocked) return
this.scrollLocked = false
if (!document.querySelector("dialog:modal")) document.body.classList.remove("overflow-hidden")
}
// The trigger wraps the author's button: the ARIA goes on that button.
wireTrigger(trigger) {
const button = this.triggerButton(trigger)
if (!button || !this.hasDialogTarget) return
// Keep the author's ARIA; repoint aria-controls only when it's missing or stale.
if (!button.hasAttribute("aria-haspopup")) button.setAttribute("aria-haspopup", "dialog")
const controls = button.getAttribute("aria-controls")
if (!controls || !document.getElementById(controls)) button.setAttribute("aria-controls", this.dialogTarget.id)
button.setAttribute("aria-expanded", String(this.dialogTarget.open))
}
setExpanded(expanded) {
this.triggerTargets.forEach((trigger) => this.triggerButton(trigger)?.setAttribute("aria-expanded", String(expanded)))
}
// A button/link inside the trigger, or the menuitem/button that wraps it.
triggerButton(trigger) {
return trigger.querySelector("button, a[href], [role=button]") || trigger.closest("[role=menuitem], [role=button]")
}
}
13
Update the Stimulus controllers manifest file
rake stimulus:manifest:update14
Install required components
Component AlertDialog relies on the following RubyUI components. Refer to their individual installation guides for setup instructions:
Components
| Component | Built using | Source |
|---|---|---|
AlertDialog | Phlex | |
AlertDialogAction | Phlex | |
AlertDialogCancel | Phlex | |
AlertDialogContent | Phlex | |
AlertDialogDescription | Phlex | |
AlertDialogDocs | Phlex | |
AlertDialogFooter | Phlex | |
AlertDialogHeader | Phlex | |
AlertDialogMedia | Phlex | |
AlertDialogTitle | Phlex | |
AlertDialogTrigger | Phlex | |
AlertDialogController | Stimulus JS |