ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

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/vanilla

Pin @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

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
# 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.