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}