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}