Skip to main content

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}