Skip to main content

hypersig_ui/
toast.rs

1//! Toasts: short notices stacked in a corner of the page.
2
3use std::sync::atomic::{AtomicI32, Ordering};
4use std::time::Duration;
5
6use hypersig::html::{El, div, each_keyed, li, ol, p, section};
7use hypersig::style::pattern::stack;
8use hypersig::style::value::{
9    AlignItems, BorderStyle, Display, FontWeight, Position, ZIndex, pct, px, raw, rem,
10};
11use hypersig::style::{Media, Style};
12use hypersig::{IntoExpr, Sig, Signals, Stmt};
13use serde::{Deserialize, Serialize};
14
15use crate::button::{ButtonVariant, icon_button};
16use crate::look::{self, icon};
17use crate::token;
18
19/// How a [`Toast`] is drawn.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
21#[non_exhaustive]
22pub enum ToastVariant {
23    /// The card colors.
24    #[default]
25    Default,
26    /// The title in [`token::destructive`], for a failure.
27    Destructive,
28}
29
30/// One notice in a [`Toaster`]: a title, an optional description, and an id.
31///
32/// The id tells toasts apart while the list changes, and nothing outside
33/// this type sets it. On the server, [`Toast::new`] numbers each toast from
34/// a counter of the process, so toasts made in one process have different
35/// ids until 2³² of them have been made. In the browser, [`Toast::push`] and
36/// [`Toast::push_expr`] give a toast one more than the highest id in the
37/// list. A clone keeps its id, so a list that holds a toast and its clone
38/// repeats one; the runtime then tells the two apart by position and writes
39/// a warning to the console.
40///
41/// ```
42/// use hypersig::{Expr, Signals};
43/// use hypersig_ui::{Toast, ToastVariant};
44/// use serde::{Deserialize, Serialize};
45///
46/// #[derive(Signals, Serialize, Deserialize)]
47/// struct App {
48///     toasts: Vec<Toast>,
49///     name: String,
50/// }
51///
52/// // On the server, in the list a patch sends.
53/// let failed = App::toasts().patch(vec![
54///     Toast::new("Upload failed").variant(ToastVariant::Destructive),
55/// ]);
56///
57/// // In the browser, with fixed text.
58/// let saved = Toast::new("Saved").description("Your changes are live.").push(App::toasts());
59///
60/// // In the browser, with text from a signal.
61/// let renamed = Toast::push_expr(
62///     App::toasts(),
63///     Expr::lit(&Toast::new("Renamed")).with(Toast::body(), App::name().concat(" is the new name.")),
64/// );
65/// # let _ = (failed, saved, renamed);
66/// ```
67#[derive(Signals, Serialize, Deserialize, Debug, Clone, PartialEq)]
68#[non_exhaustive]
69pub struct Toast {
70    key: i32,
71    /// The title, in bold.
72    pub title: String,
73    /// The line under the title, hidden while it is empty.
74    pub body: String,
75    /// How the toast is drawn.
76    pub tone: ToastVariant,
77}
78
79/// The id of the next toast [`Toast::new`] makes.
80static NEXT_KEY: AtomicI32 = AtomicI32::new(1);
81
82impl Toast {
83    /// A toast titled `title`, with an id no other toast of the process has.
84    #[must_use]
85    pub fn new(title: impl Into<String>) -> Self {
86        Self {
87            key: NEXT_KEY.fetch_add(1, Ordering::Relaxed),
88            title: title.into(),
89            body: String::new(),
90            tone: ToastVariant::Default,
91        }
92    }
93
94    /// Adds a line of text under the title.
95    #[must_use]
96    pub fn description(mut self, text: impl Into<String>) -> Self {
97        self.body = text.into();
98        self
99    }
100
101    /// Sets the look of the toast.
102    #[must_use]
103    pub fn variant(mut self, variant: ToastVariant) -> Self {
104        self.tone = variant;
105        self
106    }
107
108    /// A statement that adds this toast to `toasts` in the browser.
109    #[must_use]
110    pub fn push<S>(self, toasts: Sig<S, Vec<Self>>) -> Stmt<S> {
111        Self::push_expr(toasts, &self)
112    }
113
114    /// A statement that adds `toast`, which the browser evaluates, to `toasts`.
115    ///
116    /// Build `toast` from a literal toast with [`Expr::with`](hypersig::Expr::with) on
117    /// [`Toast::title`], [`Toast::body`], or [`Toast::tone`], so that its
118    /// text can come from a signal. Its id is replaced by one more than the
119    /// highest id in `toasts`.
120    #[must_use]
121    pub fn push_expr<S>(toasts: Sig<S, Vec<Self>>, toast: impl IntoExpr<S, Self>) -> Stmt<S> {
122        let next = toasts.max_of(|toast| toast.at(Self::key())).unwrap_or(0) + 1;
123        toasts.push(toast.into_expr().with(Self::key(), next))
124    }
125}
126
127/// The corner of the page that shows the toasts in the list `toasts`.
128///
129/// It is a landmark region named "Notifications" holding an
130/// `aria-live="polite"` list, so a screen reader reads a toast when it is
131/// added, after what it is reading. Each toast has a button that removes it.
132/// A toast stays until it is dismissed, unless [`Toaster::dismiss_after`]
133/// sets a time, because a visitor who reads slowly or uses a magnifier can
134/// miss a notice that leaves by itself (WCAG 2.2.1).
135///
136/// The region sits in the page rather than in the top layer, so an open modal
137/// dialog covers it and makes it inert until the dialog closes.
138///
139/// ```
140/// use hypersig::Signals;
141/// use hypersig_ui::{Toast, Toaster};
142/// use serde::{Deserialize, Serialize};
143///
144/// #[derive(Signals, Serialize, Deserialize)]
145/// struct App {
146///     toasts: Vec<Toast>,
147/// }
148///
149/// let toaster = Toaster::new(App::toasts()).render();
150/// let html = toaster.render();
151/// assert!(html.contains(r#"aria-label="Notifications""#));
152/// assert!(html.contains(r#"aria-live="polite""#));
153/// ```
154#[derive(Debug)]
155pub struct Toaster<S> {
156    toasts: Sig<S, Vec<Toast>>,
157    label: String,
158    dismiss_after: Option<Duration>,
159}
160
161impl<S> Toaster<S> {
162    /// A region that shows `toasts`, newest last.
163    #[must_use]
164    pub fn new(toasts: Sig<S, Vec<Toast>>) -> Self {
165        Self {
166            toasts,
167            label: "Notifications".to_owned(),
168            dismiss_after: None,
169        }
170    }
171
172    /// Names the region for assistive technology, in place of
173    /// "Notifications".
174    #[must_use]
175    pub fn label(mut self, label: impl Into<String>) -> Self {
176        self.label = label.into();
177        self
178    }
179
180    /// Removes each toast `delay` after it appears.
181    ///
182    /// The time runs while the pointer or focus is on the toast, so give it
183    /// long enough to read the longest toast twice.
184    #[must_use]
185    pub fn dismiss_after(mut self, delay: Duration) -> Self {
186        self.dismiss_after = Some(delay);
187        self
188    }
189
190    /// The region and its list.
191    #[must_use]
192    pub fn render(self) -> El<S> {
193        let dismiss_after = self.dismiss_after;
194        section()
195            .attr("aria-label", self.label)
196            .css(region_style())
197            .child(
198                ol().attr("aria-live", "polite")
199                    .css(stack(rem(0.5)))
200                    .child(each_keyed(
201                        self.toasts,
202                        |toast| toast.at(Toast::key()),
203                        move |toast| {
204                            let tone = toast.at(Toast::tone());
205                            let body = toast.at(Toast::body());
206                            let mut item = li()
207                                .attr("aria-atomic", "true")
208                                .css(toast_style())
209                                .child(
210                                    div()
211                                        .css(stack(rem(0.25)).flex_grow(1.0))
212                                        .child(
213                                            p().css(
214                                                look::small_text()
215                                                    .font_weight(FontWeight::Number(600.0)),
216                                            )
217                                            .css_when(
218                                                Style::new().color(token::destructive().var()),
219                                                tone.equals(&ToastVariant::Destructive),
220                                            )
221                                            .bind_text(toast.at(Toast::title())),
222                                        )
223                                        .child(
224                                            p().css(
225                                                look::small_text()
226                                                    .color(token::muted_foreground().var()),
227                                            )
228                                            .show(!body.is_empty())
229                                            .bind_text(body),
230                                        ),
231                                )
232                                .child(
233                                    icon_button(ButtonVariant::Ghost, "Dismiss")
234                                        .css(Style::new().width(rem(1.75)).height(rem(1.75)))
235                                        .on_click(toast.remove())
236                                        .child(icon(look::icon::X, rem(1))),
237                                );
238                            if let Some(delay) = dismiss_after {
239                                item = item.on_timeout(delay, toast.remove());
240                            }
241                            item
242                        },
243                    )),
244            )
245    }
246}
247
248impl<S> From<Toaster<S>> for El<S> {
249    fn from(toaster: Toaster<S>) -> Self {
250        toaster.render()
251    }
252}
253
254impl<S> From<Toaster<S>> for hypersig::html::Node<S> {
255    fn from(toaster: Toaster<S>) -> Self {
256        toaster.render().into()
257    }
258}
259
260fn region_style() -> Style {
261    Style::new()
262        .position(Position::Fixed)
263        .right(rem(1))
264        .bottom(rem(1))
265        .left(rem(1))
266        .z_index(ZIndex::Integer(100))
267        .pointer_events(hypersig::style::value::PointerEvents::None)
268        .on(Media::Sm, Style::new().left(raw("auto")).width(rem(22)))
269}
270
271fn toast_style() -> Style {
272    Style::new()
273        .display(Display::Flex)
274        .align_items(AlignItems::Start)
275        .gap(rem(0.75))
276        .width(pct(100))
277        .padding(rem(1))
278        .border_width(px(1))
279        .border_style(BorderStyle::Solid)
280        .border_color(token::border().var())
281        .border_radius(token::radius().var())
282        .background_color(token::popover().var())
283        .color(token::popover_foreground().var())
284        .box_shadow(raw(
285            "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
286        ))
287        .pointer_events(hypersig::style::value::PointerEvents::Auto)
288}