hypersig_ui/dialog.rs
1//! Modal dialogs, opened and closed by the browser's invoker commands.
2
3use std::fmt;
4use std::marker::PhantomData;
5
6use hypersig::html::{El, Node, button as button_el, div, h2, p};
7use hypersig::style::pattern::{hstack, stack};
8use hypersig::style::value::{
9 BorderStyle, Color, FontWeight, JustifyContent, LineHeight, Position, px, raw, rem,
10};
11use hypersig::style::{PseudoElement, Style};
12use hypersig::{DialogRef, Stmt};
13
14use crate::button::{ButtonSize, ButtonVariant, button};
15use crate::id::check_name;
16use crate::look::{self, icon};
17use crate::token;
18
19/// A modal dialog on a page whose signals are `S`.
20///
21/// Declare one as a `const` and use the same value for the dialog and for the
22/// buttons that open and close it, so they agree on its id. The buttons carry
23/// `commandfor` and `command`, and the browser opens the `<dialog>` with
24/// `showModal()`: it makes the rest of the page inert, so that Tab moves
25/// between the dialog's controls and the browser's own and never reaches the
26/// page behind, closes on Escape, and returns focus to the button that
27/// opened it. The
28/// browser needs invoker commands for this, which Chrome 135, Firefox 144,
29/// and Safari 26.2 ship.
30///
31/// The dialog is labeled by its title and described by its description
32/// through ids derived from the name. Two dialogs with the same name on one
33/// page share an id, and rendering the page panics.
34///
35/// ```
36/// use hypersig::Signals;
37/// use hypersig::html::p;
38/// use hypersig_ui::{ButtonSize, ButtonVariant, Dialog, dialog_footer};
39/// use serde::{Deserialize, Serialize};
40///
41/// #[derive(Signals, Serialize, Deserialize)]
42/// struct Profile {}
43///
44/// const EDIT: Dialog<Profile> = Dialog::new("edit-profile");
45///
46/// let open = EDIT.trigger(ButtonVariant::Outline, ButtonSize::Default).text("Edit profile");
47/// let dialog = EDIT.dialog(
48/// "Edit profile",
49/// "Changes are saved when you press Save.",
50/// dialog_footer().child(EDIT.close(ButtonVariant::Default, ButtonSize::Default).text("Save")),
51/// );
52/// assert!(open.render().contains(r#"command="show-modal""#));
53/// assert!(dialog.render().contains(r#"aria-labelledby="edit-profile-title""#));
54/// ```
55pub struct Dialog<S> {
56 name: &'static str,
57 marker: PhantomData<fn() -> S>,
58}
59
60impl<S> Dialog<S> {
61 /// A dialog whose id is `name`.
62 ///
63 /// # Panics
64 ///
65 /// Panics if `name` is empty or holds a character other than an ASCII
66 /// letter, digit, `-`, or `_`. In a `const`, that is a compile error.
67 #[must_use]
68 pub const fn new(name: &'static str) -> Self {
69 check_name(name);
70 Self {
71 name,
72 marker: PhantomData,
73 }
74 }
75
76 /// A button that opens the dialog.
77 #[must_use]
78 pub fn trigger(self, variant: ButtonVariant, size: ButtonSize) -> El<S> {
79 button(variant, size)
80 .attr("aria-haspopup", "dialog")
81 .attr("commandfor", self.name)
82 .attr("command", "show-modal")
83 }
84
85 /// A statement that opens the dialog, as its trigger button does.
86 ///
87 /// Use it where no button opens the dialog, such as a keyboard shortcut
88 /// from [`Page::on_key`](hypersig::Page::on_key). Focus returns to the
89 /// element that had it when the dialog closes.
90 #[must_use]
91 pub fn open(self) -> Stmt<S> {
92 DialogRef::new(self.name).show_modal()
93 }
94
95 /// A statement that closes the dialog, as its close button does.
96 #[must_use]
97 pub fn dismiss(self) -> Stmt<S> {
98 DialogRef::new(self.name).close()
99 }
100
101 /// A button that closes the dialog. Put it inside the dialog.
102 #[must_use]
103 pub fn close(self, variant: ButtonVariant, size: ButtonSize) -> El<S> {
104 button(variant, size)
105 .attr("commandfor", self.name)
106 .attr("command", "close")
107 }
108
109 /// The dialog, with `title`, `description`, `content`, and a close button
110 /// in its corner.
111 ///
112 /// A click on the backdrop closes it as Escape does (`closedby="any"`),
113 /// in a browser that supports `closedby`. Focus moves to the first
114 /// control in `content` when it opens, and the corner button comes after
115 /// `content` so that it is not that control.
116 #[must_use]
117 pub fn dialog(
118 self,
119 title: impl Into<String>,
120 description: impl Into<String>,
121 content: impl Into<Node<S>>,
122 ) -> El<S> {
123 self.closable(
124 dialog_style(),
125 title.into(),
126 description.into(),
127 content.into(),
128 )
129 }
130
131 /// A sheet: a dialog along the `side` edge of the viewport, with
132 /// `title`, `description`, `content`, and a close button in its corner.
133 ///
134 /// It is the same modal `<dialog>` as [`Dialog::dialog`], opened by
135 /// [`Dialog::trigger`], with the same focus, Escape, and backdrop
136 /// behavior. Only its place and size differ: it spans the full height of
137 /// the viewport at the left or the right, and the full width at the top
138 /// or the bottom.
139 ///
140 /// ```
141 /// use hypersig::Signals;
142 /// use hypersig::html::p;
143 /// use hypersig_ui::{ButtonSize, ButtonVariant, Dialog, Side};
144 /// use serde::{Deserialize, Serialize};
145 ///
146 /// #[derive(Signals, Serialize, Deserialize)]
147 /// struct Shop {}
148 ///
149 /// const CART: Dialog<Shop> = Dialog::new("cart");
150 ///
151 /// let open = CART.trigger(ButtonVariant::Outline, ButtonSize::Default).text("Cart");
152 /// let sheet = CART.sheet(Side::Right, "Cart", "Two items.", p().text("Tea, cups"));
153 /// assert!(sheet.render().contains(r#"aria-labelledby="cart-title""#));
154 /// # let _ = open;
155 /// ```
156 #[must_use]
157 pub fn sheet(
158 self,
159 side: Side,
160 title: impl Into<String>,
161 description: impl Into<String>,
162 content: impl Into<Node<S>>,
163 ) -> El<S> {
164 self.closable(
165 sheet_style(side),
166 title.into(),
167 description.into(),
168 content.into(),
169 )
170 }
171
172 /// The dialog in `style`, closed by a click on the backdrop, with a close
173 /// button in its corner after `content`.
174 fn closable(self, style: Style, title: String, description: String, content: Node<S>) -> El<S> {
175 self.frame(style, title, description, content)
176 .attr("closedby", "any")
177 .child(
178 button_el()
179 .attr("type", "button")
180 .attr("aria-label", "Close")
181 .attr("commandfor", self.name)
182 .attr("command", "close")
183 .css(corner_style())
184 .child(icon(look::icon::X, rem(1))),
185 )
186 }
187
188 /// An alert dialog, which asks the visitor to confirm or cancel before
189 /// going on, with `title`, `description`, and `content`.
190 ///
191 /// It has `role="alertdialog"`, no corner button, and stays open on a
192 /// click on the backdrop, so that the visitor answers with one of the
193 /// buttons in `content`. Escape still cancels it.
194 #[must_use]
195 pub fn alert_dialog(
196 self,
197 title: impl Into<String>,
198 description: impl Into<String>,
199 content: impl Into<Node<S>>,
200 ) -> El<S> {
201 self.frame(
202 dialog_style(),
203 title.into(),
204 description.into(),
205 content.into(),
206 )
207 .attr("role", "alertdialog")
208 }
209
210 fn frame(self, style: Style, title: String, description: String, content: Node<S>) -> El<S> {
211 let title_id = format!("{}-title", self.name);
212 let description_id = format!("{}-description", self.name);
213 DialogRef::new(self.name)
214 .dialog()
215 .id(self.name)
216 .attr("aria-labelledby", title_id.clone())
217 .attr("aria-describedby", description_id.clone())
218 .css(style)
219 .child(
220 div()
221 .css(stack(rem(1)))
222 .child(
223 div()
224 .css(stack(rem(0.5)))
225 .child(
226 h2().id(title_id)
227 .css(
228 Style::new()
229 .font_size(rem(1.125))
230 .line_height(LineHeight::Number(1.0))
231 .font_weight(FontWeight::Number(600.0)),
232 )
233 .text(title),
234 )
235 .child(
236 p().id(description_id)
237 .css(look::small_text().color(token::muted_foreground().var()))
238 .text(description),
239 ),
240 )
241 .child(content),
242 )
243 }
244}
245
246impl<S> Clone for Dialog<S> {
247 fn clone(&self) -> Self {
248 *self
249 }
250}
251
252impl<S> Copy for Dialog<S> {}
253
254impl<S> fmt::Debug for Dialog<S> {
255 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256 f.debug_tuple("Dialog").field(&self.name).finish()
257 }
258}
259
260/// The row of buttons at the bottom of a dialog, aligned to the end.
261#[must_use]
262pub fn dialog_footer<S>() -> El<S> {
263 div().css(
264 hstack(rem(0.5))
265 .justify_content(JustifyContent::FlexEnd)
266 .flex_wrap(hypersig::style::value::FlexWrap::Wrap),
267 )
268}
269
270/// The edge of the viewport a [`Dialog::sheet`] sits along.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
272#[non_exhaustive]
273pub enum Side {
274 /// The top edge, the full width of the viewport.
275 Top,
276 /// The right edge, the full height of the viewport.
277 Right,
278 /// The bottom edge, the full width of the viewport.
279 Bottom,
280 /// The left edge, the full height of the viewport.
281 Left,
282}
283
284fn dialog_style() -> Style {
285 // The browser hides a closed <dialog> with `display: none`, which a
286 // `display` here would override, so the layout lives on the child. The
287 // browser centers a modal dialog with `margin: auto`, which a reset that
288 // zeroes every margin removes, so it is set again here.
289 Style::new()
290 .margin(raw("auto"))
291 .width(raw("min(32rem, calc(100% - 2rem))"))
292 .max_height(raw("calc(100% - 2rem)"))
293 .merge(panel_style())
294}
295
296fn sheet_style(side: Side) -> Style {
297 // A modal dialog sits in the top layer at `position: fixed; inset: 0`,
298 // so the margin on the side away from `side` pushes it to that edge. The
299 // browser's own `max-width` and `max-height` keep it off the edges, so
300 // they are lifted.
301 let (margin, width, height) = match side {
302 Side::Top => ("0 0 auto 0", "100%", "auto"),
303 Side::Right => ("0 0 0 auto", "min(24rem, calc(100% - 3rem))", "100%"),
304 Side::Bottom => ("auto 0 0 0", "100%", "auto"),
305 Side::Left => ("0 auto 0 0", "min(24rem, calc(100% - 3rem))", "100%"),
306 };
307 Style::new()
308 .margin(raw(margin))
309 .width(raw(width))
310 .height(raw(height))
311 .max_width(raw("100%"))
312 .max_height(raw("100%"))
313 .overflow_y(hypersig::style::value::OverflowY::Auto)
314 .merge(panel_style())
315}
316
317/// The padding, border, colors, and backdrop a dialog and a sheet share.
318fn panel_style() -> Style {
319 Style::new()
320 .padding(rem(1.5))
321 .border_width(px(1))
322 .border_style(BorderStyle::Solid)
323 .border_color(token::border().var())
324 .border_radius(token::radius().var())
325 .background_color(token::background().var())
326 .color(token::foreground().var())
327 .box_shadow(raw(
328 "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
329 ))
330 .pseudo(
331 PseudoElement::Backdrop,
332 Style::new().background_color(Color::rgb(0, 0, 0).opacity(50)),
333 )
334}
335
336fn corner_style() -> Style {
337 Style::new()
338 .position(Position::Absolute)
339 .top(rem(1))
340 .right(rem(1))
341 .display(hypersig::style::value::Display::InlineFlex)
342 .padding(px(2))
343 .border_style(BorderStyle::None)
344 .border_radius(px(4))
345 .background_color(Color::TRANSPARENT)
346 .color(token::muted_foreground().var())
347 .cursor(hypersig::style::value::Cursor::Pointer)
348 .merge(look::transition())
349 .merge(look::focus_ring())
350 .on(
351 hypersig::style::PseudoClass::Hover,
352 Style::new().color(token::foreground().var()),
353 )
354}