Skip to main content

tablo_ui/components/primitives/
dialog.rs

1// SYNC: topcoat-ui-registry@0.10.0 sha256:df9073df351770ee0282b9c64b7724caa0422fb51d7823fd3615688f4d02ddf6 — do not hand-edit. Sync via `cargo xtask sync-topcoat-ui` (ADR-0007).
2use topcoat::{
3    Result,
4    runtime::Expr,
5    view::{Attributes, Child, StaticClass, View, class, component, view},
6};
7
8/// Classes for the overlay that covers the viewport.
9///
10/// Set `display` only for the open state so the native closed state remains hidden.
11const OVERLAY: StaticClass = class!(
12    "fixed inset-0 z-50 size-full max-h-none max-w-none items-start \
13     justify-center overflow-y-auto bg-background/80 p-4 text-foreground backdrop-blur-sm \
14     open:flex",
15);
16
17/// Classes that fade the overlay in and out.
18///
19/// `allow-discrete` keeps it displayed through the exit transition. `@starting-style`
20/// supplies the entry transition's initial opacity.
21const FADE: StaticClass = class!(
22    "opacity-0 open:opacity-100 starting:open:opacity-0 \
23     [transition:opacity_200ms_ease-out,display_200ms_allow-discrete]",
24);
25
26/// A panel displayed over the page.
27///
28/// Pass a boolean to `open` for a fixed state, or a runtime expression to control it in
29/// the browser. The overlay blocks clicks on the page behind it. Focus trapping and
30/// Escape dismissal require application scripting.
31///
32/// Pass a `dialog_content` panel as child content. `attrs` are forwarded to the
33/// `<dialog>`, with extra classes added to its classes.
34/// Give the dialog an accessible name with `aria-label`, or use `aria-labelledby`
35/// to reference its title's ID.
36///
37/// ```ignore
38/// view! {
39///     dialog(
40///         open: confirming,
41///         dialog_content(
42///             dialog_header(
43///                 dialog_title("Delete workspace")
44///                 dialog_description("This cannot be undone.")
45///             )
46///             dialog_footer(
47///                 // Closing the dialog is navigating to a page that
48///                 // renders it closed.
49///                 <a
50///                     href="/workspace"
51///                     class=(button_variants(ButtonVariant::Ghost, ButtonSize::Md))
52///                 >
53///                     "Cancel"
54///                 </a>
55///                 button(variant: ButtonVariant::Destructive, "Delete")
56///             )
57///         )
58///     )
59/// }
60/// ```
61#[component]
62pub async fn dialog(
63    /// Whether the dialog shows.
64    #[into]
65    open: Expr<bool>,
66    /// Extra attributes for the `<dialog>` element.
67    #[default]
68    mut attrs: Attributes,
69    /// The dialog's content.
70    #[default]
71    child: Child<'_>,
72) -> Result<impl View> {
73    Ok(view! {
74        <dialog
75            :open=(open)
76            class=(class!(OVERLAY, FADE, attrs.remove("class")))
77            (attrs)
78        >
79            (child)
80        </dialog>
81    })
82}
83
84/// Classes for the dialog panel. Automatic vertical margins center short panels while
85/// keeping the top of an oversized panel reachable by scrolling.
86const CONTENT: StaticClass = class!(
87    "relative my-auto flex w-full max-w-lg flex-col gap-4 rounded-xl \
88     border border-border bg-card p-6 text-card-foreground shadow-sm",
89);
90
91/// Classes that scale and fade the panel as the dialog opens or closes.
92const MOTION: StaticClass = class!(
93    "scale-95 opacity-0 in-[[open]]:scale-100 in-[[open]]:opacity-100 \
94     starting:in-[[open]]:scale-95 starting:in-[[open]]:opacity-0 \
95     [transition:scale_200ms_ease-out,opacity_200ms_ease-out]",
96);
97
98/// The content panel inside a dialog.
99///
100/// Pass the dialog's sections as children. To override its maximum width, use a more
101/// specific class such as `[&]:max-w-xl` in `attrs`, or edit the component's classes.
102#[component]
103pub async fn dialog_content(
104    #[default] mut attrs: Attributes,
105    #[default] child: Child<'_>,
106) -> Result<impl View> {
107    Ok(view! {
108        <div class=(class!(CONTENT, MOTION, attrs.remove("class"))) (attrs)>
109            (child)
110        </div>
111    })
112}
113
114/// The opening section of a [`dialog_content`], stacking a [`dialog_title`]
115/// and an optional [`dialog_description`].
116#[component]
117pub async fn dialog_header(
118    #[default] mut attrs: Attributes,
119    #[default] child: Child<'_>,
120) -> Result<impl View> {
121    Ok(view! {
122        <div class=(class!("flex flex-col gap-1.5", attrs.remove("class"))) (attrs)>
123            (child)
124        </div>
125    })
126}
127
128/// The heading of a [`dialog`], rendered as an `<h2>`.
129#[component]
130pub async fn dialog_title(
131    #[default] mut attrs: Attributes,
132    #[default] child: Child<'_>,
133) -> Result<impl View> {
134    Ok(view! {
135        <h2
136            class=(class!("text-lg leading-none font-semibold", attrs.remove("class")))
137            (attrs)
138        >
139            (child)
140        </h2>
141    })
142}
143
144/// Text that explains the dialog's purpose or requested action.
145#[component]
146pub async fn dialog_description(
147    #[default] mut attrs: Attributes,
148    #[default] child: Child<'_>,
149) -> Result<impl View> {
150    Ok(view! {
151        <p
152            class=(class!("text-sm text-muted-foreground", attrs.remove("class")))
153            (attrs)
154        >
155            (child)
156        </p>
157    })
158}
159
160/// A row of actions aligned to the right of the dialog. Actions wrap when they do not
161/// fit.
162#[component]
163pub async fn dialog_footer(
164    #[default] mut attrs: Attributes,
165    #[default] child: Child<'_>,
166) -> Result<impl View> {
167    Ok(view! {
168        <div
169            class=(class!(
170                "flex flex-wrap items-center justify-end gap-2",
171                attrs.remove("class"),
172            ))
173            (attrs)
174        >
175            (child)
176        </div>
177    })
178}