Skip to main content

fc_dioxus/components/
toast.rs

1//! Brief, non-blocking notifications and the queue that times them, from toast.css.
2//!
3//! The policy follows upstream's `toast.ts`: two live regions created up front (polite,
4//! and assertive for danger), danger toasts that never expire on their own, a longer
5//! default when there is an action, timers that pause while the pointer or focus is in
6//! the stack, a cap past which the oldest unfocused toast goes, and toasts that never
7//! take focus.
8use crate::{
9    core::{
10        assets,
11        attrs::{component_attributes, leaving_attribute, FcData, Kind},
12        platform::DomBridge,
13    },
14    prelude::*,
15    theme::use_stylesheets,
16};
17const TOAST: Bem = Bem::new("fc-toast");
18const TOASTER: Bem = Bem::new("fc-toaster");
19fc_modifier! {
20    /// Toast tones.
21    pub enum ToastTone for TOAST {
22        /// No tone of its own. Toasts default to `Info` instead.
23        Plain => None,
24        /// Information.
25        Info => "info",
26        /// Something succeeded.
27        Success => "success",
28        /// Something needs attention soon.
29        Warning => "warning",
30        /// Something failed. Never expires on its own, and is announced assertively.
31        Danger => "danger",
32    }
33}
34impl ToastTone {
35    const fn glyph(self) -> &'static str {
36        match self {
37            Self::Plain | Self::Info => "i",
38            Self::Success => "✓",
39            Self::Warning => "!",
40            Self::Danger => "✕",
41        }
42    }
43}
44fc_modifier! { enum ToasterPlacement for TOASTER { Corner => None, Top => "top" } }
45
46/// One optional action inside a toast, e.g. "Undo".
47#[derive(Debug, Clone, PartialEq)]
48pub struct ToastAction {
49    /// Visible label.
50    pub label: String,
51    /// Called on activation; the toast is then dismissed.
52    pub onaction: EventHandler<()>,
53}
54
55/// One toast. Use it directly for a static list, or let a [`Toaster`] render it.
56#[component]
57pub fn Toast(
58    #[props(default = ToastTone::Info)] tone: ToastTone,
59    #[props(default)] title: Option<String>,
60    #[props(default)] body: Option<String>,
61    #[props(default)] action: Option<ToastAction>,
62    ondismiss: EventHandler<()>,
63    /// The runtime id for `data-fc-id`.
64    #[props(default)]
65    fc_id: Option<Id>,
66    #[props(default)] leaving: bool,
67    /// Called with `true` when focus enters the toast and `false` when it leaves, so a
68    /// queue can keep the toast someone is using.
69    #[props(default)]
70    onfocuschange: Option<EventHandler<bool>>,
71) -> Element {
72    let class = ClassList::new(TOAST.block())
73        .with_modifier(TOAST, tone)
74        .to_string();
75    let mut data = FcData {
76        kind: Some(Kind::Toast),
77        id: fc_id,
78        ..FcData::default()
79    }
80    .attributes();
81    data.extend(leaving_attribute(leaving));
82    // The name says what is dismissed, so several close buttons are not all "Dismiss".
83    let what = title
84        .clone()
85        .or_else(|| body.clone())
86        .unwrap_or_else(|| "notification".to_owned());
87    rsx! {
88        li { class,
89            onfocusin: move |_| if let Some(on) = onfocuschange { on.call(true) },
90            onfocusout: move |_| if let Some(on) = onfocuschange { on.call(false) },
91            onkeydown: move |event| if event.key() == Key::Escape { event.prevent_default(); ondismiss.call(()); },
92            ..data,
93            span { class: TOAST.element("icon"), aria_hidden: "true", "{tone.glyph()}" }
94            div { class: TOAST.element("content"),
95                if let Some(title) = title { p { class: TOAST.element("title"), "{title}" } }
96                if let Some(body) = body { p { class: TOAST.element("body"), "{body}" } }
97            }
98            if let Some(action) = action {
99                button { r#type: "button", class: TOAST.element("action"),
100                    onclick: move |_| { action.onaction.call(()); ondismiss.call(()); }, "{action.label}" }
101            }
102            button { r#type: "button", class: TOAST.element("close"), aria_label: "Dismiss: {what}",
103                onclick: move |_| ondismiss.call(()), "✕" }
104        }
105    }
106}
107
108/// A static list of [`Toast`]s, e.g. for documentation. Live toasts belong in a
109/// [`Toaster`].
110#[component]
111pub fn ToastList(children: Element) -> Element {
112    rsx! {
113        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_TOAST_CSS } }
114        ol { class: TOASTER.element("list"), {children} }
115    }
116}
117
118/// What to show in a toast.
119#[derive(Debug, Clone, PartialEq)]
120pub struct ToastOptions {
121    /// Tone; info by default.
122    pub tone: ToastTone,
123    /// Short title.
124    pub title: Option<String>,
125    /// One sentence.
126    pub body: Option<String>,
127    /// Milliseconds before it goes; `Some(0)` keeps it. Defaults by [`default_timeout`].
128    pub timeout: Option<u32>,
129    /// One optional action.
130    pub action: Option<ToastAction>,
131}
132
133impl Default for ToastOptions {
134    fn default() -> Self {
135        Self {
136            tone: ToastTone::Info,
137            title: None,
138            body: None,
139            timeout: None,
140            action: None,
141        }
142    }
143}
144
145/// How long a toast stays when the caller does not say: never for danger, half as long
146/// again with an action (deciding takes longer than reading), `base` otherwise.
147#[must_use]
148pub fn default_timeout(tone: ToastTone, has_action: bool, base: u32) -> u32 {
149    if tone == ToastTone::Danger {
150        0
151    } else if has_action {
152        base.saturating_add(base / 2)
153    } else {
154        base
155    }
156}
157
158/// Which toasts to dismiss to get back to `max`: the oldest first, but never the one
159/// holding focus. `keys` is in arrival order.
160#[must_use]
161pub fn overflow(keys: &[u64], max: usize, focused: Option<u64>) -> Vec<u64> {
162    let excess = keys.len().saturating_sub(max);
163    // As upstream's toast.ts: the oldest past the cap go, skipping a focused one rather
164    // than evicting another in its place.
165    keys.iter()
166        .take(excess)
167        .copied()
168        .filter(|key| Some(*key) != focused)
169        .collect()
170}
171
172#[derive(Debug, Clone, PartialEq)]
173struct Live {
174    key: u64,
175    options: ToastOptions,
176    remaining: u32,
177    leaving: bool,
178}
179
180/// A handle for showing and dismissing toasts, from [`use_toasts`].
181#[derive(Clone, Copy)]
182pub struct Toasts {
183    live: Signal<Vec<Live>>,
184    next: Signal<u64>,
185    focused: Signal<Option<u64>>,
186    max: usize,
187    base: u32,
188}
189impl Toasts {
190    /// Show a toast; returns its key for [`Toasts::dismiss`].
191    // Most callers fire and forget; the key is only for dismissing early.
192    #[allow(clippy::must_use_candidate)]
193    pub fn show(mut self, options: ToastOptions) -> u64 {
194        let key = *self.next.peek();
195        self.next.set(key + 1);
196        let remaining = options
197            .timeout
198            .unwrap_or_else(|| default_timeout(options.tone, options.action.is_some(), self.base));
199        self.live.write().push(Live {
200            key,
201            options,
202            remaining,
203            leaving: false,
204        });
205        let keys: Vec<u64> = self
206            .live
207            .peek()
208            .iter()
209            .filter(|t| !t.leaving)
210            .map(|t| t.key)
211            .collect();
212        for old in overflow(&keys, self.max, *self.focused.peek()) {
213            self.dismiss(old);
214        }
215        key
216    }
217    /// Dismiss a toast, letting it animate out first where the host can time it.
218    pub fn dismiss(self, key: u64) {
219        let mut live = self.live;
220        if let Some(toast) = live.write().iter_mut().find(|t| t.key == key) {
221            toast.leaving = true;
222        }
223        spawn(async move {
224            // Reduced motion and hosts without timers remove at once.
225            let _ = DomBridge.delay(400).await;
226            live.write().retain(|t| t.key != key);
227        });
228    }
229}
230
231/// The toast queue provided by the nearest [`Toaster`].
232///
233/// # Panics
234/// Never; outside a `Toaster` it returns a detached queue whose toasts render nowhere.
235#[must_use]
236pub fn use_toasts() -> Toasts {
237    use_hook(|| {
238        try_consume_context::<Toasts>().unwrap_or_else(|| Toasts {
239            live: Signal::new(Vec::new()),
240            next: Signal::new(0),
241            focused: Signal::new(None),
242            max: 3,
243            base: 6000,
244        })
245    })
246}
247
248/// Provides [`use_toasts`] to its children and renders the toast region after them.
249#[component]
250pub fn Toaster(
251    /// Names the region; "Notifications" when omitted.
252    #[props(default)]
253    label: Option<AccessibleName>,
254    /// Centre at the top, for layouts whose block-end corner holds a composer.
255    #[props(default)]
256    top: bool,
257    #[props(default = 3)] max: usize,
258    /// Default lifetime in milliseconds.
259    #[props(default = 6000)]
260    timeout: u32,
261    #[props(default)] class: Option<String>,
262    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
263    children: Element,
264) -> Element {
265    let live = use_signal(Vec::<Live>::new);
266    let next = use_signal(|| 0);
267    let mut focused = use_signal(|| None::<u64>);
268    let mut paused = use_signal(|| false);
269    let toasts = use_context_provider(|| Toasts {
270        live,
271        next,
272        focused,
273        max,
274        base: timeout,
275    });
276    // One ticker counts every toast down while the stack is not paused.
277    use_future(move || async move {
278        const TICK: u32 = 250;
279        while DomBridge.delay(TICK).await.is_ok() {
280            if *paused.peek() {
281                continue;
282            }
283            let mut expired = Vec::new();
284            let mut list = live;
285            for toast in list
286                .write()
287                .iter_mut()
288                .filter(|t| !t.leaving && t.remaining > 0)
289            {
290                toast.remaining = toast.remaining.saturating_sub(TICK);
291                if toast.remaining == 0 {
292                    expired.push(toast.key);
293                }
294            }
295            for key in expired {
296                toasts.dismiss(key);
297            }
298        }
299    });
300    let attributes = component_attributes(
301        attributes,
302        ClassList::new(TOASTER.block())
303            .with_modifier(
304                TOASTER,
305                if top {
306                    ToasterPlacement::Top
307                } else {
308                    ToasterPlacement::Corner
309                },
310            )
311            .extend(class.as_deref()),
312        &["aria-label"],
313    );
314    let label = label.map_or_else(|| "Notifications".to_owned(), |l| l.to_string());
315    let render = move |danger: bool| {
316        let items: Vec<Live> = live
317            .read()
318            .iter()
319            .filter(|t| (t.options.tone == ToastTone::Danger) == danger)
320            .cloned()
321            .collect();
322        rsx! {
323            for toast in items {
324                Toast { key: "{toast.key}", tone: toast.options.tone, title: toast.options.title.clone(),
325                    body: toast.options.body.clone(), action: toast.options.action.clone(), leaving: toast.leaving,
326                    fc_id: Id::try_from(format!("toast-{}", toast.key)).ok(),
327                    ondismiss: move |()| toasts.dismiss(toast.key),
328                    // The queue's cap skips this key while it holds focus.
329                    onfocuschange: move |inside: bool| {
330                        if inside { focused.set(Some(toast.key)); }
331                        else if *focused.peek() == Some(toast.key) { focused.set(None); }
332                    } }
333            }
334        }
335    };
336    rsx! {
337        {children}
338        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_TOAST_CSS } }
339        section { aria_label: label,
340            onpointerenter: move |_| paused.set(true),
341            onpointerleave: move |_| if focused.peek().is_none() { paused.set(false); },
342            onfocusin: move |_| paused.set(true),
343            onfocusout: move |_| { focused.set(None); paused.set(false); },
344            ..attributes,
345            // Both regions exist, empty, before any message: a live region created with
346            // its content is announced unreliably.
347            ol { class: TOASTER.element("list"), role: "status", aria_live: "polite", {render(false)} }
348            ol { class: TOASTER.element("list"), role: "alert", aria_live: "assertive", {render(true)} }
349        }
350    }
351}