Alert Dialog source
Inspect and copy the reviewed source included with this Free component.
Free source · 4 files
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",
"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());
}
}
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());
}
}