Dialog
A modal overlay that requires user interaction, commonly used for confirmations, forms, and focused tasks.
Basic Dialog
A dialog with a trigger button, header with title and description, and a footer with cancel and save buttons.
Edit Profile
Make changes to your profile here. Click save when you're done.
Sizes
Dialogs support multiple size variants to fit different content needs.
Small Dialog
This dialog uses the Sm size variant (max-w-sm).
Medium Dialog
This dialog uses the Md size variant (max-w-lg). This is the default size.
Large Dialog
This dialog uses the Lg size variant (max-w-2xl). Use this for dialogs with more content.
Without Close Button
Dialogs can hide the default close (X) button by setting show_close to false. Users must use the provided action buttons to dismiss.
Confirm Action
This dialog has no close button in the top-right corner. Use the buttons below to dismiss it.
Modal content and focus-management markup.
$ proa ui add dialogInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Dialog recipe — typed root/content resolution and named slot classes.
//!
//! Dialog has two styling boundaries: the semantic root and the portalled
//! content. The root resolves independently, while the content owns the size
//! variant. Every remaining public part reads one static named slot.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
pub(crate) const DIALOG_ROOT: &str = "group/dialog";
pub(crate) const DIALOG_TRIGGER: &str = tw_join!(
"inline-flex",
"items-center",
"justify-center",
"gap-2",
"whitespace-nowrap",
"text-sm",
Dialog source
Browse and copy the reviewed source included with this Free component.
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
//! Dialog component implementations
//!
//! These components render the initial SSR markup. The TypeScript hydration
//! code handles the interactive behaviors (portal, focus trap, etc.).
use super::recipe::{DialogContentRecipeProps, DialogRootRecipeProps, DialogSize, DIALOG_RECIPE};
use super::DIALOG_ANATOMY;
use crate::components::button::{
ButtonColor, ButtonRadius, ButtonRecipeProps, ButtonSize, ButtonVariant, BUTTON_RECIPE,
};
use proa_core::{StaticRaw, WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
const CLOSE_ICON: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>"#;
/// Dialog root component - manages the dialog state
///
/// On SSR, this renders a container with data attributes for hydration.
/// The TypeScript code reads these attributes and sets up the interactive behavior.
pub struct Dialog<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 Dialog<(), ()> {
pub const fn new() -> Self {
Self {
default_open: false,
id: None,
trigger: None,
content: None,
}
}
}
impl<Trigger, Content> Default for Dialog<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 Dialog<Trigger, Content>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let state = crate::OverlayState::new(self.default_open, true);
let initial_display = if state.hidden() { "none" } else { "block" };
let style = DIALOG_RECIPE.resolve_root(DialogRootRecipeProps);
html_sync! {
<div
id={self.id}
data-dialog-root
data-scope={DIALOG_ANATOMY.scope()}
data-part="root"
data-slot="dialog"
data-state={state.data_state()}
data-dialog-id={self.id}
class={style}
style={proa_core::style_ok(proa_macros::text!("--proa-overlay-display: {};", initial_display))}
>
{self.trigger}
{self.content}
</div>
}
.render(cx)
}
}
/// DialogTrigger - the element that opens the dialog
pub struct DialogTrigger<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for DialogTrigger<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for DialogTrigger<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={DIALOG_ANATOMY.scope()}
data-part="trigger"
data-slot="dialog-trigger"
aria-haspopup="dialog"
class={proa_macros::text!("{} {}", DIALOG_RECIPE.trigger, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
/// A dialog trigger styled through the typed Button recipe.
///
/// This renders one semantic `<button>` carrying the Dialog trigger contract.
/// It exists separately from [`DialogTrigger`] so existing struct literals
/// remain source-compatible and callers never need to nest a Button inside
/// another button.
pub struct DialogButtonTrigger<C = ()> {
pub size: Option<ButtonSize>,
pub color: Option<ButtonColor>,
pub variant: Option<ButtonVariant>,
pub radius: Option<ButtonRadius>,
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl DialogButtonTrigger<()> {
/// Create a typed dialog button trigger with Button's default appearance.
pub const fn new() -> Self {
Self {
size: None,
color: None,
variant: None,
radius: None,
class: None,
children: None,
}
}
/// Create a typed dialog button trigger with a static text label.
pub const fn text(text: &'static str) -> DialogButtonTrigger<&'static str> {
DialogButtonTrigger {
size: None,
color: None,
variant: None,
radius: None,
class: None,
children: Some(text),
}
}
}
impl<C> Default for DialogButtonTrigger<C> {
fn default() -> Self {
Self {
size: None,
color: None,
variant: None,
radius: None,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for DialogButtonTrigger<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = BUTTON_RECIPE.resolve(ButtonRecipeProps {
size: self.size,
color: self.color,
variant: self.variant,
radius: self.radius,
});
html_sync! {
<button
type="button"
data-dialog-trigger
data-scope={DIALOG_ANATOMY.scope()}
data-part="trigger"
data-slot="dialog-trigger"
data-size={style.size().as_str()}
data-color={style.color().as_str()}
data-variant={style.variant().as_str()}
data-radius={style.radius().as_str()}
aria-haspopup="dialog"
class={proa_macros::text!("{} {}", style, self.class)}
>
{self.children}
</button>
}
.render(cx)
}
}
/// DialogOverlay - the backdrop behind the dialog
#[derive(Default)]
pub struct DialogOverlay {
pub class: Option<&'static str>,
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for DialogOverlay {
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-dialog-overlay
data-scope={DIALOG_ANATOMY.scope()}
data-part="overlay"
data-slot="dialog-overlay"
class={proa_macros::text!("{} {}", DIALOG_RECIPE.overlay, self.class)}
aria-hidden="true"
></div>
}
.render(cx)
}
}
/// DialogContent - the main dialog container
pub struct DialogContent<C = ()> {
pub size: Option<DialogSize>,
pub class: Option<&'static str>,
pub children: Option<C>,
/// Whether to show the close button (X icon)
pub show_close: bool,
}
impl<C> Default for DialogContent<C> {
fn default() -> Self {
Self {
size: None,
class: None,
children: None,
show_close: true,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for DialogContent<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = DIALOG_RECIPE.resolve_content(DialogContentRecipeProps { size: self.size });
let size = style.size();
html_sync! {
<div
data-dialog-portal
data-scope={DIALOG_ANATOMY.scope()}
data-part="portal"
data-slot="dialog-portal"
style="display: var(--proa-overlay-display, none);"
>
<div
data-dialog-overlay
data-scope={DIALOG_ANATOMY.scope()}
data-part="overlay"
data-slot="dialog-overlay"
class={DIALOG_RECIPE.overlay}
aria-hidden="true"
></div>
<div
data-dialog-content
data-scope={DIALOG_ANATOMY.scope()}
data-part="content"
data-slot="dialog-content"
data-size={size.as_str()}
class={proa_macros::text!("{} {}", style, self.class)}
role="dialog"
aria-modal="true"
tabindex="-1"
>
{self.children}
{self.show_close.then(|| html_sync! {
<button type="button" data-dialog-close data-scope={DIALOG_ANATOMY.scope()} data-part="close-button" data-slot="dialog-close-button" class={DIALOG_RECIPE.close_button}>
<span data-scope={DIALOG_ANATOMY.scope()} data-part="close-label" data-slot="dialog-close-label" class={DIALOG_RECIPE.close_label}>
"Close"
</span>
{StaticRaw(CLOSE_ICON)}
</button>
})}
</div>
</div>
}
.render(cx)
}
}
/// DialogHeader - container for title and description
pub struct DialogHeader<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for DialogHeader<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for DialogHeader<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-scope={DIALOG_ANATOMY.scope()}
data-part="header"
data-slot="dialog-header"
class={proa_macros::text!("{} {}", DIALOG_RECIPE.header, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// DialogTitle - accessible dialog title
pub struct DialogTitle<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for DialogTitle<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for DialogTitle<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<h2
data-dialog-title
data-scope={DIALOG_ANATOMY.scope()}
data-part="title"
data-slot="dialog-title"
class={proa_macros::text!("{} {}", DIALOG_RECIPE.title, self.class)}
>
{self.children}
</h2>
}
.render(cx)
}
}
/// DialogDescription - optional description text
pub struct DialogDescription<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for DialogDescription<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for DialogDescription<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<p
data-dialog-description
data-scope={DIALOG_ANATOMY.scope()}
data-part="description"
data-slot="dialog-description"
class={proa_macros::text!("{} {}", DIALOG_RECIPE.description, self.class)}
>
{self.children}
</p>
}
.render(cx)
}
}
/// DialogFooter - container for action buttons
pub struct DialogFooter<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for DialogFooter<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for DialogFooter<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-scope={DIALOG_ANATOMY.scope()}
data-part="footer"
data-slot="dialog-footer"
class={proa_macros::text!("{} {}", DIALOG_RECIPE.footer, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// DialogClose - wraps an author-provided control that closes the dialog
///
/// This renders a `<span>`, not a `<button>`, and it has to: most callers pass
/// a `Button` (or a link) as the child, and `<button>` may not contain
/// interactive content. The parser enforces that — a nested `<button>` start
/// tag closes the open one — so promoting this element would emit the author's
/// button as a *sibling*, stripped of `data-dialog-close`, and their Cancel
/// would stop closing the dialog.
///
/// When the close is the control rather than a wrapper around one, reach for
/// [`DialogCloseButton`]: a span holding a bare label is not focusable, not
/// keyboard-activatable, and has no role.
pub struct DialogClose<C = ()> {
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl<C> Default for DialogClose<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for DialogClose<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<span
data-dialog-close
data-scope={DIALOG_ANATOMY.scope()}
data-part="close"
data-slot="dialog-close"
class={proa_macros::text!("{} {}", DIALOG_RECIPE.close, self.class)}
>
{self.children}
</span>
}
.render(cx)
}
}
/// A dialog close styled through the typed Button recipe.
///
/// The counterpart to [`DialogButtonTrigger`], and it exists for the same
/// reason: one semantic `<button>` carrying the close contract, so a caller
/// with nothing but a label ("Cancel") gets a real control — focusable,
/// keyboard-activatable, announced as a button — instead of the bare `<span>`
/// [`DialogClose`] must stay in order to keep wrapping author-provided buttons.
pub struct DialogCloseButton<C = ()> {
pub size: Option<ButtonSize>,
pub color: Option<ButtonColor>,
pub variant: Option<ButtonVariant>,
pub radius: Option<ButtonRadius>,
pub class: Option<&'static str>,
pub children: Option<C>,
}
impl DialogCloseButton<()> {
/// Create a typed dialog close button with Button's default appearance.
pub const fn new() -> Self {
Self {
size: None,
color: None,
variant: None,
radius: None,
class: None,
children: None,
}
}
/// Create a typed dialog close button with a static text label.
pub const fn text(text: &'static str) -> DialogCloseButton<&'static str> {
DialogCloseButton {
size: None,
color: None,
variant: None,
radius: None,
class: None,
children: Some(text),
}
}
}
impl<C> Default for DialogCloseButton<C> {
fn default() -> Self {
Self {
size: None,
color: None,
variant: None,
radius: None,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for DialogCloseButton<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = BUTTON_RECIPE.resolve(ButtonRecipeProps {
size: self.size,
color: self.color,
variant: self.variant,
radius: self.radius,
});
html_sync! {
<button
type="button"
data-dialog-close
data-scope={DIALOG_ANATOMY.scope()}
data-part="close"
data-slot="dialog-close"
data-size={style.size().as_str()}
data-color={style.color().as_str()}
data-variant={style.variant().as_str()}
data-radius={style.radius().as_str()}
class={proa_macros::text!("{} {}", style, 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 dialog_renders_with_data_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Dialog {
default_open: false,
id: Some("test-dialog"),
trigger: Some(text("Open")),
content: Some(text("Content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-root"));
assert!(html.contains("data-slot=\"dialog\""));
assert!(html.contains("group/dialog"));
assert!(html.contains("data-state=\"closed\""));
assert!(html.contains("data-dialog-id=\"test-dialog\""));
assert!(html.contains("id=\"test-dialog\""));
}
#[test]
fn dialog_content_renders_with_role() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
DialogContent {
size: Some(DialogSize::Lg),
class: None,
children: Some(text("Hello")),
show_close: true,
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("role=\"dialog\""));
assert!(html.contains("aria-modal=\"true\""));
assert!(html.contains("aria-hidden=\"true\""));
assert!(html.contains("data-slot=\"dialog-portal\""));
assert!(html.contains("data-slot=\"dialog-overlay\""));
assert!(html.contains("data-slot=\"dialog-content\""));
assert!(html.contains("data-slot=\"dialog-close-button\""));
assert!(!html.contains("data-state=\"closed\""));
assert!(!html.contains("display: none"));
assert!(html.contains("data-size=\"lg\""));
assert!(html.contains("max-w-2xl"));
assert!(
html.contains("aria-hidden=\"true\"></div><div data-dialog-content"),
"the overlay must close before the dialog content starts"
);
}
#[test]
fn default_open_is_owned_by_the_root_without_closed_descendants() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Dialog {
default_open: true,
id: Some("open-dialog"),
trigger: Some(DialogTrigger {
class: None,
children: Some(text("Open")),
}),
content: Some(DialogContent {
size: None,
class: None,
children: Some(text("Visible content")),
show_close: false,
}),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("id=\"open-dialog\""));
assert!(html.contains("data-state=\"open\""));
assert!(html.contains("--proa-overlay-display: block"));
assert!(html.contains("display: var(--proa-overlay-display, none)"));
assert!(!html.contains("data-state=\"closed\""));
assert!(!html.contains("display: none"));
assert!(!html.contains("aria-expanded=\"false\""));
}
#[test]
fn dialog_header_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
DialogHeader {
class: None,
children: Some(DialogTitle {
class: None,
children: Some(text("My Title")),
}),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-slot=\"dialog-header\""));
assert!(html.contains("data-slot=\"dialog-title\""));
assert!(html.contains("data-dialog-title"));
assert!(html.contains("My Title"));
}
#[test]
fn dialog_parts_append_author_classes_to_their_own_slots() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
DialogTrigger {
class: Some("custom-trigger"),
children: Some(text("Open")),
}
.render(&mut cx)
.unwrap();
DialogOverlay {
class: Some("custom-overlay"),
}
.render(&mut cx)
.unwrap();
DialogDescription {
class: Some("custom-description"),
children: Some(text("Details")),
}
.render(&mut cx)
.unwrap();
DialogFooter {
class: Some("custom-footer"),
children: Some(text("Actions")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-slot=\"dialog-trigger\""));
assert!(html.contains("inline-flex"));
assert!(html.contains("custom-trigger"));
assert!(html.contains("data-slot=\"dialog-overlay\""));
assert!(html.contains("bg-black/50"));
assert!(html.contains("custom-overlay"));
assert!(
html.contains("aria-hidden=\"true\"></div>"),
"a standalone overlay must render a closing tag"
);
assert!(html.contains("data-slot=\"dialog-description\""));
assert!(html.contains("custom-description"));
assert!(html.contains("data-slot=\"dialog-footer\""));
assert!(html.contains("custom-footer"));
}
#[test]
fn dialog_button_trigger_is_one_typed_button_without_legacy_trigger_classes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
DialogButtonTrigger {
size: Some(ButtonSize::Lg),
color: Some(ButtonColor::Primary),
variant: Some(ButtonVariant::Solid),
radius: Some(ButtonRadius::Lg),
class: Some("w-full hover:-translate-y-px"),
children: Some(text("Get Proa UI Pro")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert_eq!(html.matches("<button").count(), 1, "{html}");
assert_eq!(html.matches("</button>").count(), 1, "{html}");
assert_eq!(html.matches("data-dialog-trigger").count(), 1, "{html}");
assert!(html.contains("type=\"button\""));
assert!(html.contains("data-scope=\"dialog\""));
assert!(html.contains("data-part=\"trigger\""));
assert!(html.contains("data-slot=\"dialog-trigger\""));
assert!(html.contains("aria-haspopup=\"dialog\""));
assert!(html.contains("data-size=\"lg\""));
assert!(html.contains("data-color=\"primary\""));
assert!(html.contains("data-variant=\"solid\""));
assert!(html.contains("data-radius=\"lg\""));
assert!(html.contains("Get Proa UI Pro"));
let classes = html
.split_once("class=\"")
.and_then(|(_, rest)| rest.split_once('\"'))
.map(|(classes, _)| classes)
.expect("dialog button trigger class attribute");
let has_class = |expected: &str| {
classes
.split_ascii_whitespace()
.any(|class| class == expected)
};
for expected in [
"inline-flex",
"h-10",
"px-6",
"rounded-lg",
"bg-primary",
"text-primary-foreground",
"hover:bg-primary/90",
"w-full",
"hover:-translate-y-px",
] {
assert!(has_class(expected), "missing {expected}: {classes}");
}
for legacy in [
"h-9",
"rounded-md",
"border-input",
"bg-background",
"text-foreground",
"shadow-xs",
"hover:bg-accent",
"hover:text-accent-foreground",
] {
assert!(!has_class(legacy), "legacy class {legacy}: {classes}");
}
}
#[test]
fn dialog_button_trigger_resolves_secondary_outline_appearance() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
DialogButtonTrigger {
size: Some(ButtonSize::Lg),
color: Some(ButtonColor::Secondary),
variant: Some(ButtonVariant::Outline),
radius: Some(ButtonRadius::Lg),
class: Some("w-full"),
children: Some(text("Talk to us")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert_eq!(html.matches("<button").count(), 1, "{html}");
assert_eq!(html.matches("</button>").count(), 1, "{html}");
assert!(html.contains("data-size=\"lg\""));
assert!(html.contains("data-color=\"secondary\""));
assert!(html.contains("data-variant=\"outline\""));
assert!(html.contains("data-radius=\"lg\""));
assert!(html.contains("Talk to us"));
let classes = html
.split_once("class=\"")
.and_then(|(_, rest)| rest.split_once('\"'))
.map(|(classes, _)| classes)
.expect("dialog button trigger class attribute");
let has_class = |expected: &str| {
classes
.split_ascii_whitespace()
.any(|class| class == expected)
};
for expected in [
"h-10",
"rounded-lg",
"border-input",
"bg-background",
"text-foreground",
"shadow-xs",
"hover:bg-accent",
"hover:text-accent-foreground",
"dark:bg-input/30",
"dark:hover:bg-input/50",
"w-full",
] {
assert!(has_class(expected), "missing {expected}: {classes}");
}
for conflicting in [
"bg-primary",
"text-primary-foreground",
"hover:bg-primary/90",
] {
assert!(
!has_class(conflicting),
"conflicting class {conflicting}: {classes}"
);
}
}
#[test]
fn public_close_remains_a_neutral_runtime_wrapper() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
DialogClose {
class: Some("custom-close"),
children: Some(text("Cancel")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-dialog-close"));
assert!(html.contains("data-slot=\"dialog-close\""));
assert!(html.contains("custom-close"));
assert!(!html.contains("absolute"));
}
/// The close aligns itself against the buttons it stands beside, and it
/// stopped shipping the `class=" "` that an empty slot used to leave.
#[test]
fn close_wrapper_aligns_itself_and_writes_no_empty_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
DialogClose {
class: None,
children: Some(text("Cancel")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("items-center"), "{html}");
assert!(!html.contains("class=\" \""), "{html}");
}
/// The reason `DialogClose` is still a `<span>`. An author's own button has
/// to stay *inside* it: the HTML parser closes an open `<button>` when it
/// meets another, which would leave the caller's control a sibling with no
/// `data-dialog-close` on it, and clicking Cancel would do nothing.
#[test]
fn close_wrapper_keeps_an_author_control_nested() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
DialogClose {
class: None,
children: Some(html_sync! {
<button type="button">"Cancel"</button>
}),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
let wrapper = html.find("<span").expect("close wrapper");
let control = html.find("<button").expect("author control");
let wrapper_end = html.find("</span>").expect("close wrapper end");
assert!(wrapper < control && control < wrapper_end, "{html}");
assert_eq!(html.matches("<button").count(), 1, "{html}");
}
#[test]
fn typed_close_button_is_one_focusable_control_carrying_the_close_contract() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
DialogCloseButton {
size: Some(ButtonSize::Lg),
color: Some(ButtonColor::Secondary),
variant: Some(ButtonVariant::Outline),
radius: Some(ButtonRadius::Lg),
class: Some("min-w-24"),
children: Some(text("Cancel")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert_eq!(html.matches("<button").count(), 1, "{html}");
assert_eq!(html.matches("data-dialog-close").count(), 1, "{html}");
assert!(html.contains("type=\"button\""), "{html}");
assert!(html.contains("data-scope=\"dialog\""), "{html}");
assert!(html.contains("data-part=\"close\""), "{html}");
assert!(html.contains("data-slot=\"dialog-close\""), "{html}");
assert!(html.contains("data-size=\"lg\""), "{html}");
assert!(html.contains("data-variant=\"outline\""), "{html}");
assert!(html.contains("min-w-24"), "{html}");
// Typed through the Button recipe, so it centres its own label and
// matches the height of whatever action sits beside it in the footer.
assert!(html.contains("inline-flex"), "{html}");
assert!(html.contains("items-center"), "{html}");
}
#[test]
fn every_declared_anatomy_part_is_emitted_by_a_public_renderer() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Dialog::<(), ()>::default().render(&mut cx).unwrap();
DialogTrigger {
class: None,
children: Some(text("Open")),
}
.render(&mut cx)
.unwrap();
DialogContent {
size: None,
class: None,
children: Some(text("Content")),
show_close: true,
}
.render(&mut cx)
.unwrap();
DialogHeader {
class: None,
children: Some(text("Header")),
}
.render(&mut cx)
.unwrap();
DialogTitle {
class: None,
children: Some(text("Title")),
}
.render(&mut cx)
.unwrap();
DialogDescription {
class: None,
children: Some(text("Description")),
}
.render(&mut cx)
.unwrap();
DialogFooter {
class: None,
children: Some(text("Footer")),
}
.render(&mut cx)
.unwrap();
DialogClose {
class: None,
children: Some(text("Close")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
for part in DIALOG_ANATOMY.parts() {
let contract = format!("data-scope=\"dialog\" data-part=\"{part}\"");
assert!(
html.contains(&contract),
"missing anatomy part {part}: {html}"
);
}
}
#[test]
fn runtime_and_css_follow_the_root_owned_dialog_contract() {
let js = include_str!("../../../runtime/proa-free.js");
let css = include_str!("../../../runtime/proa-free.css");
assert!(js.contains("syncModalContract(root, DIALOG_CONTRACT"));
assert!(js.contains("trigger.id = `${baseId}--trigger`"));
assert!(js.contains("content.id = `${baseId}--content`"));
assert!(js.contains("title.id = `${baseId}--title`"));
assert!(js.contains("description.id = `${baseId}--description`"));
assert!(js.contains("trigger.setAttribute('aria-controls', content.id)"));
assert!(js.contains("content.setAttribute('aria-labelledby', title.id)"));
assert!(js.contains("content.setAttribute('aria-describedby', description.id)"));
assert!(js.contains("parts.trigger.setAttribute('aria-expanded', open ? 'true' : 'false')"));
assert!(js.contains("parts.content.setAttribute('aria-hidden', open ? 'false' : 'true')"));
assert!(css.contains("[data-dialog-root][data-state='open'] > [data-dialog-portal]"));
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{
Dialog, DialogButtonTrigger, DialogClose, DialogCloseButton, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogOverlay, DialogTitle, DialogTrigger,
};
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Dialog 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 Dialog<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(())
}
}
/// DialogTrigger is interaction chrome — renders nothing.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C> MdRenderSync<Loader, B> for DialogTrigger<C> {
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
/// DialogButtonTrigger is interaction chrome — renders nothing.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C> MdRenderSync<Loader, B>
for DialogButtonTrigger<C>
{
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
/// DialogOverlay is visual chrome — renders nothing.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for DialogOverlay {
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
/// DialogContent renders its children as a plain block (close button and size are dropped).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for DialogContent<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// DialogHeader is a passthrough — its children (title, description) handle formatting.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for DialogHeader<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// DialogTitle renders as a bold line: `**title**`.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for DialogTitle<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(())
}
}
/// DialogDescription renders as a plain paragraph.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for DialogDescription<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// DialogFooter is a passthrough for its children (action buttons render as labels).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for DialogFooter<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// DialogClose is interaction chrome — renders nothing.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C> MdRenderSync<Loader, B> for DialogClose<C> {
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
/// DialogCloseButton is interaction chrome — renders nothing.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C> MdRenderSync<Loader, B>
for DialogCloseButton<C>
{
fn render_md(self, _cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dialog_title_renders_bold() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
DialogTitle {
class: None,
children: Some("Edit Profile"),
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "**Edit Profile**\n\n");
}
#[test]
fn dialog_trigger_renders_nothing() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
DialogTrigger {
class: None,
children: Some("Open"),
}
.render_md(&mut cx)
.unwrap();
assert!(buf.is_empty());
}
#[test]
fn dialog_button_trigger_renders_nothing() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
DialogButtonTrigger::text("Open")
.render_md(&mut cx)
.unwrap();
assert!(buf.is_empty());
}
#[test]
fn dialog_close_button_renders_nothing() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
DialogCloseButton::text("Cancel")
.render_md(&mut cx)
.unwrap();
assert!(buf.is_empty());
}
#[test]
fn dialog_renders_content_and_drops_trigger() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Dialog {
default_open: false,
id: None,
trigger: Some("Open Trigger"),
content: Some(DialogContent {
children: Some((
DialogHeader {
class: None,
children: Some((
DialogTitle {
class: None,
children: Some("Edit Profile"),
},
DialogDescription {
class: None,
children: Some("Make changes to your profile."),
},
)),
},
DialogFooter {
class: None,
children: Some(DialogClose {
class: None,
children: Some("Cancel"),
}),
},
)),
..Default::default()
}),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("**Edit Profile**"), "output: {output}");
assert!(
output.contains("Make changes to your profile\\."),
"output: {output}"
);
assert!(!output.contains("Open Trigger"), "output: {output}");
assert!(!output.contains("Cancel"), "output: {output}");
}
/// A block-emitting Dialog after mid-line prose must not fuse with it.
#[test]
fn 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();
Dialog {
default_open: false,
id: None,
trigger: Some("Open Trigger"),
content: Some(DialogContent {
children: Some(DialogHeader {
class: None,
children: Some((
DialogTitle {
class: None,
children: Some("Edit Profile"),
},
DialogDescription {
class: None,
children: Some("Make changes to your profile."),
},
)),
}),
..Default::default()
}),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.starts_with("intro prose\n\n**Edit Profile**\n\n"),
"block content must start after a blank line, got:\n{output}"
);
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Dialog component - modal overlay for focused interactions
//!
//! A Dialog is a window overlaid on the primary content that requires
//! user interaction. It traps focus, handles escape key, and prevents
//! background scrolling.
//!
//! # Sub-components
//!
//! - `Dialog` - Root component managing open/close state
//! - `DialogTrigger` - Button that opens the dialog
//! - `DialogContent` - The modal content container
//! - `DialogOverlay` - Semi-transparent backdrop
//! - `DialogHeader` - Container for title and description
//! - `DialogTitle` - Accessible dialog title
//! - `DialogDescription` - Optional description text
//! - `DialogFooter` - Container for action buttons
//! - `DialogClose` - Wrapper marking an author's control as the dismiss action
//! - `DialogCloseButton` - The dismiss action itself, as a typed button
//!
//! # SSR Behavior
//!
//! On the server, Dialog renders its content in a hidden state by default.
//! The TypeScript hydration code handles:
//! - Portal rendering (moving content to document body)
//! - Focus trapping and restoration
//! - Keyboard/click dismissal
//! - Body scroll locking
//!
//! # Example
//!
//! ```ignore
//! html! {
//! <Dialog>
//! <DialogTrigger>
//! <Button>"Open"</Button>
//! </DialogTrigger>
//! <DialogContent>
//! <DialogHeader>
//! <DialogTitle>"Edit Profile"</DialogTitle>
//! <DialogDescription>"Make changes to your profile."</DialogDescription>
//! </DialogHeader>
//! // ... form content
//! <DialogFooter>
//! // Wrapping your own button: DialogClose marks it as the
//! // dismiss action and stays out of the way.
//! <DialogClose>
//! <Button variant={ButtonVariant::Ghost}>"Cancel"</Button>
//! </DialogClose>
//! // With only a label to render, use DialogCloseButton — it
//! // is the button, so the control is focusable and typed.
//! <Button>"Save"</Button>
//! </DialogFooter>
//! </DialogContent>
//! </Dialog>
//! }
//! ```
use crate::Anatomy;
#[allow(clippy::module_inception)]
mod dialog;
mod md;
mod recipe;
/// Static anatomy contract for Dialog's public compound parts.
pub const DIALOG_ANATOMY: Anatomy = Anatomy::new(
"dialog",
&[
"root",
"trigger",
"portal",
"overlay",
"content",
"close-button",
"close-label",
"header",
"title",
"description",
"footer",
"close",
],
);
pub use dialog::{
Dialog, DialogButtonTrigger, DialogClose, DialogCloseButton, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogOverlay, DialogTitle, DialogTrigger,
};
pub(crate) use recipe::DIALOG_SIZE_MD;
pub use recipe::{
dialog_close_classes, dialog_content_classes, dialog_description_classes,
dialog_footer_classes, dialog_header_classes, dialog_overlay_classes, dialog_recipe,
dialog_title_classes, dialog_trigger_classes, DialogContentRecipeProps, DialogContentStyle,
DialogRecipe, DialogRecipeProps, DialogRootRecipeProps, DialogRootStyle, DialogSize,
DIALOG_RECIPE,
};
#[cfg(feature = "metadata-export")]
pub use recipe::export_metadata;
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_part() {
assert_eq!(DIALOG_ANATOMY.scope(), "dialog");
assert_eq!(
DIALOG_ANATOMY.parts(),
&[
"root",
"trigger",
"portal",
"overlay",
"content",
"close-button",
"close-label",
"header",
"title",
"description",
"footer",
"close",
]
);
}
}
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Dialog recipe — typed root/content resolution and named slot classes.
//!
//! Dialog has two styling boundaries: the semantic root and the portalled
//! content. The root resolves independently, while the content owns the size
//! variant. Every remaining public part reads one static named slot.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
pub(crate) const DIALOG_ROOT: &str = "group/dialog";
pub(crate) const DIALOG_TRIGGER: &str = tw_join!(
"inline-flex",
"items-center",
"justify-center",
"gap-2",
"whitespace-nowrap",
"text-sm",
"font-medium",
"transition-all",
"cursor-pointer",
"rounded-md",
"h-9",
"px-4",
"py-2",
"border",
"border-input",
"bg-background",
"text-foreground",
"shadow-xs",
"hover:bg-accent",
"hover:text-accent-foreground"
);
pub(crate) const DIALOG_OVERLAY: &str = tw_join!(
"fixed",
"inset-0",
"z-50",
"bg-black/50",
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=open]:fade-in-0"
);
pub(crate) const DIALOG_CONTENT: &str = tw_join!(
"fixed",
"left-1/2",
"top-1/2",
"z-50",
"-translate-x-1/2",
"-translate-y-1/2",
"grid",
"w-full",
"max-w-[calc(100%-2rem)]",
"gap-4",
"border",
"border-border",
"bg-background",
"p-6",
"shadow-lg",
"duration-200",
"data-[state=open]:animate-in",
"data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0",
"data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95",
"data-[state=open]:zoom-in-95",
"rounded-lg"
);
pub(crate) const DIALOG_HEADER: &str = tw_join!(
"flex",
"flex-col",
"space-y-1.5",
"text-center",
"sm:text-left"
);
/// The footer is the one row that mixes a caller's own button with whatever
/// `DialogClose` was handed, so it cannot lean on the flex defaults.
///
/// `align-items` defaults to `stretch`, which gives every child the height of
/// the tallest one. A button that centres its own label (`min-h-… items-center`)
/// still reads as centred; a plain label paints on the first line box of a box
/// it never asked to be that tall, and the two sit visibly off each other.
/// `sm:items-center` sizes each child to its content and centres it instead.
///
/// `gap-2` rather than `sm:space-x-2`, because `space-x-*` is horizontal
/// margin only: in the stacked mobile layout the actions had no separation at
/// all.
pub(crate) const DIALOG_FOOTER: &str = tw_join!(
"flex",
"flex-col-reverse",
"gap-2",
"sm:flex-row",
"sm:items-center",
"sm:justify-end"
);
pub(crate) const DIALOG_TITLE: &str = tw_join!(
"text-lg",
"font-semibold",
"leading-none",
"tracking-tight",
"text-foreground"
);
pub(crate) const DIALOG_DESCRIPTION: &str = tw_join!("text-sm", "text-muted-foreground");
/// The public `DialogClose` is a semantic wrapper around author-provided
/// controls, so it carries no visual treatment — a border or padding here would
/// paint a second box around a caller's own button.
///
/// Alignment it does need. The wrapper is a footer flex item sitting beside
/// buttons that centre their own label, and it was previously the empty string,
/// which also left `class=" "` on every close in the rendered markup.
pub(crate) const DIALOG_CLOSE: &str = tw_join!("inline-flex", "items-center", "cursor-pointer");
/// Styling for the optional built-in X button rendered by `DialogContent`.
pub(crate) const DIALOG_CLOSE_BUTTON: &str = tw_join!(
"absolute",
"right-4",
"top-4",
"rounded-sm",
"opacity-70",
"ring-offset-background",
"transition-opacity",
"hover:opacity-100",
"focus:outline-hidden",
"focus:ring-2",
"focus:ring-ring",
"focus:ring-offset-2",
"disabled:pointer-events-none",
"data-[state=open]:bg-accent",
"data-[state=open]:text-muted-foreground"
);
const DIALOG_SIZE_SM: &str = "max-w-sm";
pub(crate) const DIALOG_SIZE_MD: &str = "max-w-lg";
const DIALOG_SIZE_LG: &str = "max-w-2xl";
const DIALOG_SIZE_XL: &str = "max-w-4xl";
const DIALOG_SIZE_FULL: &str = "max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)]";
/// Dialog content size.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum DialogSize {
Sm,
#[default]
Md,
Lg,
Xl,
Full,
}
impl DialogSize {
pub const fn classes(self) -> &'static str {
match self {
Self::Sm => DIALOG_SIZE_SM,
Self::Md => DIALOG_SIZE_MD,
Self::Lg => DIALOG_SIZE_LG,
Self::Xl => DIALOG_SIZE_XL,
Self::Full => DIALOG_SIZE_FULL,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Sm => "sm",
Self::Md => "md",
Self::Lg => "lg",
Self::Xl => "xl",
Self::Full => "full",
}
}
}
/// Typed inputs for the Dialog root styling boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DialogRootRecipeProps;
/// Typed inputs for the portalled Dialog content.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DialogContentRecipeProps {
pub size: Option<DialogSize>,
}
/// Conventional recipe-props name retained for consumers that resolve content.
pub type DialogRecipeProps = DialogContentRecipeProps;
/// Static named classes for every Dialog part.
#[derive(Debug, Clone, Copy)]
pub struct DialogRecipe {
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 close: &'static str,
pub close_button: &'static str,
pub close_label: &'static str,
size_default: DialogSize,
}
impl DialogRecipe {
pub const CLASS_CAPACITY: usize = 2;
pub const fn resolve_root(&'static self, _props: DialogRootRecipeProps) -> DialogRootStyle {
DialogRootStyle { recipe: self }
}
pub const fn resolve_content(
&'static self,
props: DialogContentRecipeProps,
) -> DialogContentStyle {
let size = match props.size {
Some(size) => size,
None => self.size_default,
};
DialogContentStyle { recipe: self, size }
}
/// Compatibility alias for the former content-oriented recipe resolver.
pub const fn resolve(&'static self, props: DialogRecipeProps) -> DialogContentStyle {
self.resolve_content(props)
}
/// Compatibility representation for callers of the former derived recipe.
pub const fn classes(
&'static self,
size: Option<DialogSize>,
) -> ([&'static str; Self::CLASS_CAPACITY], usize) {
let style = self.resolve_content(DialogContentRecipeProps { size });
([self.content, style.size.classes()], Self::CLASS_CAPACITY)
}
}
/// Resolved Dialog root classes.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct DialogRootStyle {
recipe: &'static DialogRecipe,
}
impl AttrValue for DialogRootStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())
}
}
/// Resolved Dialog content classes and effective size.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct DialogContentStyle {
recipe: &'static DialogRecipe,
size: DialogSize,
}
impl DialogContentStyle {
pub const fn size(self) -> DialogSize {
self.size
}
}
impl AttrValue for DialogContentStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.content.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.classes().as_bytes())
}
}
/// Global zero-initialization Dialog recipe.
pub const DIALOG_RECIPE: DialogRecipe = DialogRecipe {
root: DIALOG_ROOT,
trigger: DIALOG_TRIGGER,
overlay: DIALOG_OVERLAY,
content: DIALOG_CONTENT,
header: DIALOG_HEADER,
footer: DIALOG_FOOTER,
title: DIALOG_TITLE,
description: DIALOG_DESCRIPTION,
close: DIALOG_CLOSE,
close_button: DIALOG_CLOSE_BUTTON,
close_label: "sr-only",
size_default: DialogSize::Md,
};
pub const fn dialog_recipe() -> &'static DialogRecipe {
&DIALOG_RECIPE
}
/// Compatibility helpers for the former standalone class API.
pub const fn dialog_trigger_classes() -> &'static str {
DIALOG_RECIPE.trigger
}
pub const fn dialog_overlay_classes() -> &'static str {
DIALOG_RECIPE.overlay
}
pub const fn dialog_content_classes(size: DialogSize) -> DialogContentStyle {
DIALOG_RECIPE.resolve_content(DialogContentRecipeProps { size: Some(size) })
}
pub const fn dialog_header_classes() -> &'static str {
DIALOG_RECIPE.header
}
pub const fn dialog_footer_classes() -> &'static str {
DIALOG_RECIPE.footer
}
pub const fn dialog_title_classes() -> &'static str {
DIALOG_RECIPE.title
}
pub const fn dialog_description_classes() -> &'static str {
DIALOG_RECIPE.description
}
/// Historical helper for the built-in X button.
pub const fn dialog_close_classes() -> &'static str {
DIALOG_RECIPE.close_button
}
impl crate::variant_spec::ComponentSpec for DialogRecipe {
const NAME: &'static str = "Dialog";
fn base_classes(&self) -> &'static str {
self.content
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<DialogSize>()]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::DIALOG_ANATOMY)
}
}
/// Export metadata for TypeScript code generation.
#[cfg(feature = "metadata-export")]
pub fn export_metadata() -> crate::variant_spec::ComponentMetadata {
use crate::variant_spec::{component_metadata_with_props, ComponentType, PropMetadata};
component_metadata_with_props(
&DIALOG_RECIPE,
ComponentType::Button,
"div",
vec![
PropMetadata::new("size", "DialogSize").optional(),
PropMetadata::new("open", "boolean").optional(),
PropMetadata::new("onOpenChange", "(open: boolean) => void").optional(),
PropMetadata::new("modal", "boolean")
.optional()
.with_default("true"),
PropMetadata::new("className", "string").optional(),
PropMetadata::new("children", "React.ReactNode")
.optional()
.children(),
],
)
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::render_to_string;
use proa_macros::html_sync;
#[test]
fn recipe_exposes_every_named_slot() {
let recipe = dialog_recipe();
assert!(recipe.root.contains("group/dialog"));
assert!(recipe.trigger.contains("inline-flex"));
assert!(recipe.overlay.contains("bg-black/50"));
assert!(recipe.content.contains("zoom-in-95"));
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.close.contains("items-center"));
assert!(recipe.close_button.contains("absolute"));
assert_eq!(recipe.close_label, "sr-only");
assert_eq!(
crate::variant_spec::ComponentSpec::anatomy(recipe),
Some(super::super::DIALOG_ANATOMY)
);
}
#[test]
fn root_and_content_resolve_independently() {
let root = DIALOG_RECIPE.resolve_root(DialogRootRecipeProps);
let root_markup = {
html_sync! {
<div class={root}></div>
}
};
let root_html = render_to_string(root_markup);
assert!(root_html.contains("class=\"group/dialog\""));
let content = DIALOG_RECIPE.resolve_content(DialogContentRecipeProps {
size: Some(DialogSize::Lg),
});
assert_eq!(content.size(), DialogSize::Lg);
let content_markup = {
html_sync! {
<div class={content}></div>
}
};
let content_html = render_to_string(content_markup);
assert!(content_html.contains("fixed"));
assert!(content_html.contains("max-w-2xl"));
}
#[test]
fn content_defaults_and_legacy_segments_share_resolution() {
let style = DIALOG_RECIPE.resolve_content(DialogContentRecipeProps::default());
assert_eq!(style.size(), DialogSize::Md);
let (segments, count) = DIALOG_RECIPE.classes(None);
assert_eq!(count, 2);
assert_eq!(segments[0], DIALOG_RECIPE.content);
assert_eq!(segments[1], DialogSize::Md.classes());
}
/// The footer aligns rather than stretches, and separates on both axes.
/// Both were regressions users could see: a label-only close rode the top
/// of the row beside a taller button, and the stacked mobile layout had its
/// two actions touching.
#[test]
fn footer_centres_its_row_and_gaps_its_stack() {
let footer = dialog_footer_classes();
assert!(footer.contains("sm:items-center"), "{footer}");
assert!(footer.contains("gap-2"), "{footer}");
assert!(!footer.contains("space-x-2"), "{footer}");
}
#[test]
fn dialog_size_values_are_stable() {
assert_eq!(DialogSize::Sm.as_str(), "sm");
assert!(DialogSize::Sm.classes().contains("max-w-sm"));
assert!(DialogSize::Lg.classes().contains("max-w-2xl"));
assert!(DialogSize::Full.classes().contains("100vw"));
}
}