Alert Dialog
A modal dialog that interrupts the user with important content and expects a response.
Basic
A simple alert dialog for confirming an action.
Are you absolutely sure?
This action cannot be undone. This will permanently delete your account and remove your data from our servers.
Destructive
An alert dialog for confirming a destructive action like deleting data.
Delete Account
Are you sure you want to delete your account? All of your data will be permanently removed. This action cannot be undone.
FreeAlert Dialog
Accessible confirmation flows.
Install with the Proa CLI
$ proa ui add alert_dialogInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Alert Dialog recipe — one const named-slot table for every public part.
//!
//! Static modal primitives are sourced from Dialog where the two components
//! are visually identical. Alert-specific action and cancel controls remain
//! local to this recipe.
use crate::components::dialog::{DialogSize, DIALOG_RECIPE, DIALOG_SIZE_MD};
use crate::tw_join;
const ALERT_DIALOG_ROOT: &str = "group/alert-dialog";
const ALERT_DIALOG_CONTENT: &str = tw_join!(DIALOG_RECIPE.content, DIALOG_SIZE_MD);
const ALERT_DIALOG_ACTION: &str = tw_join!(
"inline-flex",
"items-center",
"justify-center",
"gap-2",
"whitespace-nowrap",
"rounded-md",
Alert Dialog source
Browse and copy the reviewed source included with this Free component.
alert_dialog.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Alert Dialog component implementations
//!
//! Thin wrappers around the Dialog component with `role="alertdialog"` semantics.
//! AlertDialog is used for destructive action confirmations where the user must
//! explicitly confirm or cancel.
use super::recipe::ALERT_DIALOG_RECIPE;
use super::ALERT_DIALOG_ANATOMY;
use crate::OverlayState;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// AlertDialog root component - manages the alert dialog state
///
/// Wraps the Dialog component with `data-alert-dialog-root` for hydration.
/// The TypeScript code handles the interactive behaviors but treats this
/// as a non-dismissible dialog (no click-outside close, no escape close).
pub struct AlertDialog<Trigger = (), Content = ()> {
/// Whether the dialog starts open (for SSR)
pub default_open: bool,
/// Dialog ID for linking trigger and content
pub id: Option<&'static str>,
/// The trigger element (button that opens dialog)
pub trigger: Option<Trigger>,
/// The dialog content
pub content: Option<Content>,
}
impl AlertDialog<(), ()> {
pub const fn new() -> Self {
Self {
default_open: false,
id: None,
trigger: None,
content: None,
}
}
}
impl<Trigger, Content> Default for AlertDialog<Trigger, Content> {
fn default() -> Self {
Self {
default_open: false,
id: None,
trigger: None,
content: None,
}
}
}
impl<
B: WriteBuf,
Trigger: WebRenderSync<L, B>,
Content: WebRenderSync<L, B>,
L: ::proa_core::DataLoader,
> WebRenderSync<L, B> for AlertDialog<Trigger, Content>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let state = OverlayState::new(self.default_open, true);
let initial_display = if state.hidden() { "none" } else { "block" };
html_sync! {
<div
data-alert-dialog-root
data-dialog-root
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="root"
data-slot="alert-dialog"
data-state={state.data_state()}
data-dialog-id={self.id}
id={self.id}
class={ALERT_DIALOG_RECIPE.root}
style={proa_core::style_ok(proa_macros::text!(
"--proa-overlay-display: {};",
initial_display
))}
>
{self.trigger}
{self.content}
</div>
}
.render(cx)
}
}
/// AlertDialogTrigger - the element that opens the alert dialog
///
/// Wraps DialogTrigger with `aria-haspopup="dialog"`.
pub struct AlertDialogTrigger<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for AlertDialogTrigger<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AlertDialogTrigger<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<button
type="button"
data-dialog-trigger
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="trigger"
data-slot="alert-dialog-trigger"
aria-haspopup="dialog"
class={proa_macros::text!("{} {}", ALERT_DIALOG_RECIPE.trigger, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
/// AlertDialogContent - the modal content container with `role="alertdialog"`
///
/// Unlike DialogContent, this uses `role="alertdialog"` and does not render
/// a close (X) button, enforcing explicit action/cancel interactions.
pub struct AlertDialogContent<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for AlertDialogContent<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AlertDialogContent<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-dialog-portal
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="portal"
data-slot="alert-dialog-portal"
style="display: var(--proa-overlay-display, none);"
>
<div
data-dialog-overlay
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="overlay"
data-slot="alert-dialog-overlay"
class={ALERT_DIALOG_RECIPE.overlay}
aria-hidden="true"
/>
<div
data-dialog-content
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="content"
data-slot="alert-dialog-content"
class={proa_macros::text!("{} {}", ALERT_DIALOG_RECIPE.content, self.class)}
role="alertdialog"
aria-modal="true"
tabindex="-1"
>
{self.children}
</div>
</div>
}
.render(cx)
}
}
/// AlertDialogHeader - container for title and description
pub struct AlertDialogHeader<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for AlertDialogHeader<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AlertDialogHeader<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="header"
data-slot="alert-dialog-header"
class={proa_macros::text!("{} {}", ALERT_DIALOG_RECIPE.header, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// AlertDialogTitle - accessible alert dialog title
pub struct AlertDialogTitle<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for AlertDialogTitle<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AlertDialogTitle<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<h2
data-dialog-title
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="title"
data-slot="alert-dialog-title"
class={proa_macros::text!("{} {}", ALERT_DIALOG_RECIPE.title, self.class)}
>
{self.children}
</h2>
}
.render(cx)
}
}
/// AlertDialogDescription - description text explaining the destructive action
pub struct AlertDialogDescription<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for AlertDialogDescription<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AlertDialogDescription<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<p
data-dialog-description
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="description"
data-slot="alert-dialog-description"
class={proa_macros::text!("{} {}", ALERT_DIALOG_RECIPE.description, self.class)}
>
{self.children}
</p>
}
.render(cx)
}
}
/// AlertDialogFooter - container for action and cancel buttons
pub struct AlertDialogFooter<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for AlertDialogFooter<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AlertDialogFooter<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="footer"
data-slot="alert-dialog-footer"
class={proa_macros::text!("{} {}", ALERT_DIALOG_RECIPE.footer, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// AlertDialogAction - destructive/primary action button
///
/// Styled with destructive colors. Includes `data-dialog-close` so clicking
/// this button closes the alert dialog via the hydration code.
pub struct AlertDialogAction<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for AlertDialogAction<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AlertDialogAction<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<button
type="button"
data-dialog-close
data-alert-dialog-action
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="action"
data-slot="alert-dialog-action"
class={proa_macros::text!("{} {}", ALERT_DIALOG_RECIPE.action, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
/// AlertDialogCancel - cancel/dismiss button
///
/// Styled with outline/secondary colors. Includes `data-dialog-close` so
/// clicking this button closes the alert dialog via the hydration code.
pub struct AlertDialogCancel<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for AlertDialogCancel<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AlertDialogCancel<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<button
type="button"
data-dialog-close
data-alert-dialog-cancel
data-scope={ALERT_DIALOG_ANATOMY.scope()}
data-part="cancel"
data-slot="alert-dialog-cancel"
class={proa_macros::text!("{} {}", ALERT_DIALOG_RECIPE.cancel, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn alert_dialog_renders_with_data_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialog {
default_open: false,
id: Some("confirm-delete"),
trigger: Some(text("Delete")),
content: Some(text("Content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-alert-dialog-root"));
assert!(html.contains("data-dialog-root"));
assert!(html.contains("data-slot=\"alert-dialog\""));
assert!(html.contains("data-scope=\"alert-dialog\""));
assert!(html.contains("data-part=\"root\""));
assert!(html.contains("group/alert-dialog"));
assert!(html.contains("data-state=\"closed\""));
assert!(html.contains("--proa-overlay-display: none"));
assert!(html.contains("data-dialog-id=\"confirm-delete\""));
}
#[test]
fn alert_dialog_renders_open_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialog {
default_open: true,
id: None,
trigger: Some(text("Open")),
content: Some(text("Body")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-state=\"open\""));
}
#[test]
fn alert_dialog_content_has_alertdialog_role() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialogContent {
class: None,
children: Some(text("Are you sure?")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("role=\"alertdialog\""));
assert!(html.contains("aria-modal=\"true\""));
assert!(html.contains("data-slot=\"alert-dialog-portal\""));
assert!(html.contains("data-slot=\"alert-dialog-overlay\""));
assert!(html.contains("data-slot=\"alert-dialog-content\""));
assert!(
!html.contains("data-state="),
"compound content does not invent root-owned disclosure state: {html}"
);
assert!(!html.contains("sr-only"));
}
#[test]
fn alert_dialog_content_does_not_have_close_button() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialogContent {
class: None,
children: Some(text("Confirm")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(!html.contains("<svg"));
assert!(!html.contains("Close"));
}
#[test]
fn alert_dialog_header_renders() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialogHeader {
class: None,
children: Some(AlertDialogTitle {
class: None,
children: Some(text("Are you absolutely sure?")),
}),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-title"));
assert!(html.contains("Are you absolutely sure?"));
}
#[test]
fn alert_dialog_description_renders() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialogDescription {
class: None,
children: Some(text("This action cannot be undone.")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-description"));
assert!(html.contains("This action cannot be undone."));
assert!(html.contains("text-muted-foreground"));
}
#[test]
fn alert_dialog_footer_renders() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialogFooter {
class: None,
children: Some(text("buttons")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("sm:justify-end"));
assert!(html.contains("buttons"));
}
#[test]
fn delegated_runtime_preserves_alert_dialog_dismissal_semantics() {
let js = include_str!("../../../runtime/proa-free.js");
assert!(js.contains("root.hasAttribute('data-alert-dialog-root')"));
assert!(js.contains("'[data-alert-dialog-cancel]'"));
assert!(js.contains("openRoot.hasAttribute('data-alert-dialog-root')"));
assert!(js.contains("e.preventDefault()"));
}
#[test]
fn alert_dialog_action_has_destructive_styling() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialogAction {
class: None,
children: Some(text("Delete")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-close"));
assert!(html.contains("bg-destructive"));
assert!(html.contains("Delete"));
}
#[test]
fn alert_dialog_cancel_has_outline_styling() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialogCancel {
class: None,
children: Some(text("Cancel")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-close"));
assert!(html.contains("border-input"));
assert!(html.contains("bg-background"));
assert!(html.contains("Cancel"));
}
#[test]
fn alert_dialog_action_accepts_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialogAction {
class: Some("mt-2"),
children: Some(text("Confirm")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("mt-2"));
assert!(html.contains("bg-destructive"));
}
#[test]
fn alert_dialog_cancel_accepts_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialogCancel {
class: Some("mt-2"),
children: Some(text("Go Back")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("mt-2"));
assert!(html.contains("border-input"));
}
#[test]
fn alert_dialog_trigger_renders_button() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialogTrigger {
class: None,
children: Some(text("Show Alert")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-trigger"));
assert!(html.contains("data-slot=\"alert-dialog-trigger\""));
assert!(html.contains("aria-haspopup=\"dialog\""));
assert!(html.contains("Show Alert"));
}
#[test]
fn full_alert_dialog_composition() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AlertDialog {
default_open: false,
id: Some("delete-confirm"),
trigger: Some(AlertDialogTrigger {
class: None,
children: Some(text("Delete Account")),
}),
content: Some(AlertDialogContent {
class: None,
children: Some(html_sync! {
{AlertDialogHeader {
class: None,
children: Some(html_sync! {
{AlertDialogTitle {
class: None,
children: Some(text("Are you sure?")),
}}
{AlertDialogDescription {
class: None,
children: Some(text("This cannot be undone.")),
}}
}),
}}
{AlertDialogFooter {
class: None,
children: Some(html_sync! {
{AlertDialogCancel { class: None, children: Some(text("Cancel")), }}
{AlertDialogAction { class: None, children: Some(text("Delete")), }}
}),
}}
}),
}),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-alert-dialog-root"));
assert!(html.contains("role=\"alertdialog\""));
assert!(html.contains("data-slot=\"alert-dialog\""));
assert!(html.contains("data-slot=\"alert-dialog-trigger\""));
assert!(html.contains("data-slot=\"alert-dialog-overlay\""));
assert!(html.contains("data-slot=\"alert-dialog-content\""));
assert!(html.contains("data-slot=\"alert-dialog-header\""));
assert!(html.contains("data-slot=\"alert-dialog-title\""));
assert!(html.contains("data-slot=\"alert-dialog-description\""));
assert!(html.contains("data-slot=\"alert-dialog-footer\""));
assert!(html.contains("data-slot=\"alert-dialog-cancel\""));
assert!(html.contains("data-slot=\"alert-dialog-action\""));
assert!(html.contains("Are you sure?"));
assert!(html.contains("This cannot be undone."));
assert!(html.contains("Cancel"));
assert!(html.contains("Delete"));
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
};
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// AlertDialog renders its content as a plain block — the trigger is interaction chrome and is dropped.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, Trigger, Content: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AlertDialog<Trigger, Content>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.content.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// AlertDialogTrigger is interaction chrome — renders nothing.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C> MdRenderSync<Loader, B>
for AlertDialogTrigger<C>
{
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
/// AlertDialogContent renders its children as a plain block.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AlertDialogContent<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// AlertDialogHeader is a passthrough — its children (title, description) handle formatting.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AlertDialogHeader<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// AlertDialogTitle renders as a bold line: `**title**`.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AlertDialogTitle<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
let prev = cx.md_set_inline(true);
md_sync! { "**" { self.children } "**" }.render_md(cx)?;
cx.md_set_inline(prev);
block_boundary(cx)?;
Ok(())
}
}
/// AlertDialogDescription renders as a plain paragraph.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AlertDialogDescription<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// AlertDialogFooter is a passthrough for its children (action/cancel labels).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AlertDialogFooter<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// AlertDialogAction renders as just its label text (one per line in block context).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AlertDialogAction<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
let prev = cx.md_set_inline(true);
let result = self.children.render_md(cx);
cx.md_set_inline(prev);
result?;
if !cx.md_inline() {
md_sync! { "" "\n" }.render_md(cx)?;
}
Ok(())
}
}
/// AlertDialogCancel renders as just its label text (one per line in block context).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AlertDialogCancel<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
let prev = cx.md_set_inline(true);
let result = self.children.render_md(cx);
cx.md_set_inline(prev);
result?;
if !cx.md_inline() {
md_sync! { "" "\n" }.render_md(cx)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alert_dialog_title_renders_bold() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
AlertDialogTitle {
class: None,
children: Some("Are you absolutely sure?"),
}
.render_md(&mut cx)
.unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"**Are you absolutely sure?**\n\n"
);
}
#[test]
fn alert_dialog_trigger_renders_nothing() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
AlertDialogTrigger {
class: None,
children: Some("Delete Account"),
}
.render_md(&mut cx)
.unwrap();
assert!(buf.is_empty());
}
#[test]
fn full_alert_dialog_composition() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
AlertDialog {
default_open: false,
id: None,
trigger: Some("Delete Account"),
content: Some(AlertDialogContent {
class: None,
children: Some((
AlertDialogHeader {
class: None,
children: Some((
AlertDialogTitle {
class: None,
children: Some("Are you sure?"),
},
AlertDialogDescription {
class: None,
children: Some("This cannot be undone."),
},
)),
},
AlertDialogFooter {
class: None,
children: Some((
AlertDialogCancel {
class: None,
children: Some("Cancel"),
},
AlertDialogAction {
class: None,
children: Some("Delete"),
},
)),
},
)),
}),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("**Are you sure?**"), "output: {output}");
assert!(
output.contains("This cannot be undone\\."),
"output: {output}"
);
assert!(output.contains("Cancel\n"), "output: {output}");
assert!(output.contains("Delete\n"), "output: {output}");
// Trigger label is dropped
assert!(!output.contains("Delete Account"), "output: {output}");
}
/// A block-emitting AlertDialog after mid-line prose must not fuse with it.
#[test]
fn alert_dialog_composes_after_inline_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
AlertDialog {
default_open: false,
id: None,
trigger: Some("Delete Account"),
content: Some(AlertDialogContent {
class: None,
children: Some((
AlertDialogTitle {
class: None,
children: Some("Are you sure?"),
},
AlertDialogDescription {
class: None,
children: Some("This cannot be undone."),
},
)),
}),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.starts_with("intro prose\n\n**Are you sure?**\n\n"),
"block content must start after a blank line, got:\n{output}"
);
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Alert Dialog component - modal dialog for destructive action confirmation
//!
//! Built on top of the Dialog component with `role="alertdialog"` semantics.
//! Unlike a regular Dialog, an AlertDialog:
//! - Uses `role="alertdialog"` instead of `role="dialog"`
//! - Has explicit `AlertDialogAction` and `AlertDialogCancel` buttons
//! - Does not show a close (X) button
//! - Is typically non-dismissible via click-outside
//!
//! # Sub-components
//!
//! - `AlertDialog` - Root component (wraps `Dialog`)
//! - `AlertDialogTrigger` - Button that opens the alert dialog (wraps `DialogTrigger`)
//! - `AlertDialogContent` - The modal content with `role="alertdialog"`
//! - `AlertDialogHeader` - Container for title and description
//! - `AlertDialogTitle` - Accessible alert dialog title
//! - `AlertDialogDescription` - Description text explaining the action
//! - `AlertDialogFooter` - Container for action/cancel buttons
//! - `AlertDialogAction` - Destructive/primary action button
//! - `AlertDialogCancel` - Cancel/dismiss button
//!
//! # Example
//!
//! ```ignore
//! html! {
//! <AlertDialog>
//! <AlertDialogTrigger>"Delete Account"</AlertDialogTrigger>
//! <AlertDialogContent>
//! <AlertDialogHeader>
//! <AlertDialogTitle>"Are you absolutely sure?"</AlertDialogTitle>
//! <AlertDialogDescription>
//! "This action cannot be undone."
//! </AlertDialogDescription>
//! </AlertDialogHeader>
//! <AlertDialogFooter>
//! <AlertDialogCancel>"Cancel"</AlertDialogCancel>
//! <AlertDialogAction>"Continue"</AlertDialogAction>
//! </AlertDialogFooter>
//! </AlertDialogContent>
//! </AlertDialog>
//! }
//! ```
use crate::Anatomy;
mod alert_dialog;
mod md;
mod recipe;
/// Static anatomy contract shared by SSR and the Dialog delegated runtime.
pub const ALERT_DIALOG_ANATOMY: Anatomy = Anatomy::new(
"alert-dialog",
&[
"root",
"trigger",
"portal",
"overlay",
"content",
"header",
"title",
"description",
"footer",
"action",
"cancel",
],
);
pub use alert_dialog::{
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
};
pub use recipe::{
alert_dialog_action_classes, alert_dialog_cancel_classes, alert_dialog_content_base_classes,
alert_dialog_content_size_classes, alert_dialog_description_classes,
alert_dialog_footer_classes, alert_dialog_header_classes, alert_dialog_overlay_classes,
alert_dialog_recipe, alert_dialog_title_classes, AlertDialogRecipe, ALERT_DIALOG_RECIPE,
};
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_part() {
assert_eq!(ALERT_DIALOG_ANATOMY.scope(), "alert-dialog");
assert_eq!(
ALERT_DIALOG_ANATOMY.parts(),
&[
"root",
"trigger",
"portal",
"overlay",
"content",
"header",
"title",
"description",
"footer",
"action",
"cancel",
]
);
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Alert Dialog recipe — one const named-slot table for every public part.
//!
//! Static modal primitives are sourced from Dialog where the two components
//! are visually identical. Alert-specific action and cancel controls remain
//! local to this recipe.
use crate::components::dialog::{DialogSize, DIALOG_RECIPE, DIALOG_SIZE_MD};
use crate::tw_join;
const ALERT_DIALOG_ROOT: &str = "group/alert-dialog";
const ALERT_DIALOG_CONTENT: &str = tw_join!(DIALOG_RECIPE.content, DIALOG_SIZE_MD);
const ALERT_DIALOG_ACTION: &str = tw_join!(
"inline-flex",
"items-center",
"justify-center",
"gap-2",
"whitespace-nowrap",
"rounded-md",
"text-sm",
"font-medium",
"transition-colors",
"h-9",
"px-4",
"py-2",
"bg-destructive",
"text-destructive-foreground",
"shadow-xs",
"hover:bg-destructive/90",
"focus-visible:outline-hidden",
"focus-visible:ring-2",
"focus-visible:ring-ring"
);
const ALERT_DIALOG_CANCEL: &str = tw_join!(
"inline-flex",
"items-center",
"justify-center",
"gap-2",
"whitespace-nowrap",
"rounded-md",
"text-sm",
"font-medium",
"transition-colors",
"h-9",
"px-4",
"py-2",
"border",
"border-input",
"bg-background",
"text-foreground",
"shadow-xs",
"hover:bg-accent",
"hover:text-accent-foreground",
"focus-visible:outline-hidden",
"focus-visible:ring-2",
"focus-visible:ring-ring"
);
/// Static named classes for every Alert Dialog part.
#[derive(Debug, Clone, Copy)]
pub struct AlertDialogRecipe {
pub root: &'static str,
pub trigger: &'static str,
pub overlay: &'static str,
pub content: &'static str,
pub header: &'static str,
pub footer: &'static str,
pub title: &'static str,
pub description: &'static str,
pub action: &'static str,
pub cancel: &'static str,
}
/// Global zero-initialization Alert Dialog recipe.
pub const ALERT_DIALOG_RECIPE: AlertDialogRecipe = AlertDialogRecipe {
root: ALERT_DIALOG_ROOT,
trigger: DIALOG_RECIPE.trigger,
overlay: DIALOG_RECIPE.overlay,
content: ALERT_DIALOG_CONTENT,
header: DIALOG_RECIPE.header,
footer: DIALOG_RECIPE.footer,
title: DIALOG_RECIPE.title,
description: DIALOG_RECIPE.description,
action: ALERT_DIALOG_ACTION,
cancel: ALERT_DIALOG_CANCEL,
};
pub const fn alert_dialog_recipe() -> &'static AlertDialogRecipe {
&ALERT_DIALOG_RECIPE
}
impl crate::variant_spec::ComponentSpec for AlertDialogRecipe {
const NAME: &'static str = "AlertDialog";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
Vec::new()
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::ALERT_DIALOG_ANATOMY)
}
}
/// Compatibility helpers for the former standalone class API.
pub const fn alert_dialog_action_classes() -> &'static str {
ALERT_DIALOG_RECIPE.action
}
pub const fn alert_dialog_cancel_classes() -> &'static str {
ALERT_DIALOG_RECIPE.cancel
}
pub const fn alert_dialog_content_base_classes() -> &'static str {
DIALOG_RECIPE.content
}
pub const fn alert_dialog_content_size_classes() -> &'static str {
DialogSize::Md.classes()
}
pub const fn alert_dialog_overlay_classes() -> &'static str {
ALERT_DIALOG_RECIPE.overlay
}
pub const fn alert_dialog_header_classes() -> &'static str {
ALERT_DIALOG_RECIPE.header
}
pub const fn alert_dialog_footer_classes() -> &'static str {
ALERT_DIALOG_RECIPE.footer
}
pub const fn alert_dialog_title_classes() -> &'static str {
ALERT_DIALOG_RECIPE.title
}
pub const fn alert_dialog_description_classes() -> &'static str {
ALERT_DIALOG_RECIPE.description
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_every_named_slot() {
let recipe = alert_dialog_recipe();
assert!(recipe.root.contains("group/alert-dialog"));
assert!(recipe.trigger.contains("inline-flex"));
assert!(recipe.overlay.contains("bg-black/50"));
assert!(recipe.content.contains("max-w-lg"));
assert!(recipe.header.contains("sm:text-left"));
assert!(recipe.footer.contains("sm:justify-end"));
assert!(recipe.title.contains("text-foreground"));
assert!(recipe.description.contains("text-muted-foreground"));
assert!(recipe.action.contains("bg-destructive"));
assert!(recipe.cancel.contains("border-input"));
}
#[test]
fn identical_dialog_primitives_are_shared() {
assert_eq!(ALERT_DIALOG_RECIPE.trigger, DIALOG_RECIPE.trigger);
assert_eq!(ALERT_DIALOG_RECIPE.overlay, DIALOG_RECIPE.overlay);
assert_eq!(ALERT_DIALOG_RECIPE.header, DIALOG_RECIPE.header);
assert_eq!(ALERT_DIALOG_RECIPE.footer, DIALOG_RECIPE.footer);
assert_eq!(ALERT_DIALOG_RECIPE.title, DIALOG_RECIPE.title);
assert_eq!(ALERT_DIALOG_RECIPE.description, DIALOG_RECIPE.description);
}
#[test]
fn compatibility_helpers_delegate_to_recipe() {
assert_eq!(alert_dialog_action_classes(), ALERT_DIALOG_RECIPE.action);
assert_eq!(alert_dialog_cancel_classes(), ALERT_DIALOG_RECIPE.cancel);
assert_eq!(alert_dialog_content_base_classes(), DIALOG_RECIPE.content);
assert_eq!(
alert_dialog_content_size_classes(),
DialogSize::Md.classes()
);
}
#[test]
fn component_metadata_uses_canonical_anatomy() {
use crate::variant_spec::ComponentSpec;
assert_eq!(
ALERT_DIALOG_RECIPE.anatomy(),
Some(super::super::ALERT_DIALOG_ANATOMY)
);
assert!(ALERT_DIALOG_RECIPE.variant_metadata().is_empty());
}
}