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

//! 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"));
    }
}

Copy this source with the Free Proa UI components it imports; those component files remain available from their own source pages.

Licensed under MIT OR Apache-2.0. Third-party notices remain attached to the installed payload.

Open Markdown
FreeDialog

Modal content and focus-management markup.

Install with the Proa CLI
proa ui add dialog

Installs 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

//! 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",

Search

Type at least 2 characters

Customize

Make it yours

Palette

Radius

Overview
Components
Settings

Build your own

Themed Proa UI surface

Preview
Components52
RecipesTyped
customer-portal