Skip to main content

nightshade_api/web/base/
feedback.rs

1use leptos::prelude::*;
2use wasm_bindgen::JsCast;
3use wasm_bindgen::prelude::*;
4
5/// An indeterminate loading spinner.
6#[component]
7pub fn Spinner() -> impl IntoView {
8    view! { <span class="nightshade-spinner"></span> }
9}
10
11/// A single queued toast notification held by a [`Toaster`].
12#[derive(Clone)]
13pub struct Toast {
14    /// Unique, monotonically increasing identifier used for keying and dismissal.
15    pub id: usize,
16    /// The message shown in the toast body.
17    pub text: String,
18    /// The severity class (`"info"`, `"success"`, `"warn"`, or `"error"`).
19    pub kind: &'static str,
20    /// Optional action button as a `(label, callback)` pair.
21    pub action: Option<(String, Callback<()>)>,
22}
23
24/// A handle for pushing toast notifications. Obtain one with [`use_toaster`]
25/// inside a [`ToastHub`].
26#[derive(Clone, Copy)]
27pub struct Toaster {
28    toasts: RwSignal<Vec<Toast>>,
29    next: RwSignal<usize>,
30}
31
32impl Toaster {
33    /// Push an informational toast (auto-dismisses after ~3.2s).
34    pub fn info(&self, text: impl Into<String>) {
35        self.push(text.into(), "info", 3200, None);
36    }
37
38    /// Push a success toast (auto-dismisses after ~3.2s).
39    pub fn success(&self, text: impl Into<String>) {
40        self.push(text.into(), "success", 3200, None);
41    }
42
43    /// Push a warning toast (auto-dismisses after ~4.2s).
44    pub fn warning(&self, text: impl Into<String>) {
45        self.push(text.into(), "warn", 4200, None);
46    }
47
48    /// Push an error toast (auto-dismisses after ~5s).
49    pub fn error(&self, text: impl Into<String>) {
50        self.push(text.into(), "error", 5000, None);
51    }
52
53    /// Push an informational toast with an action button labelled `label` that
54    /// runs `on_action` when clicked (auto-dismisses after ~6s).
55    pub fn action(
56        &self,
57        text: impl Into<String>,
58        label: impl Into<String>,
59        on_action: Callback<()>,
60    ) {
61        self.push(text.into(), "info", 6000, Some((label.into(), on_action)));
62    }
63
64    fn push(
65        &self,
66        text: String,
67        kind: &'static str,
68        duration_ms: i32,
69        action: Option<(String, Callback<()>)>,
70    ) {
71        let id = self.next.get_untracked();
72        self.next.set(id + 1);
73        self.toasts.update(|list| {
74            list.push(Toast {
75                id,
76                text,
77                kind,
78                action,
79            })
80        });
81        let toasts = self.toasts;
82        after(duration_ms, move || {
83            toasts.update(|list| list.retain(|toast| toast.id != id));
84        });
85    }
86
87    fn dismiss(&self, id: usize) {
88        self.toasts
89            .update(|list| list.retain(|toast| toast.id != id));
90    }
91}
92
93/// Retrieve the [`Toaster`] provided by an enclosing [`ToastHub`]. Falls back
94/// to a detached, standalone toaster when no hub is present.
95pub fn use_toaster() -> Toaster {
96    use_context::<Toaster>().unwrap_or_else(|| Toaster {
97        toasts: RwSignal::new(Vec::new()),
98        next: RwSignal::new(0),
99    })
100}
101
102/// Provides a [`Toaster`] to `children` via context and renders the stacked
103/// toast overlay. Wrap your app in this, then call [`use_toaster`] to push
104/// messages.
105#[component]
106pub fn ToastHub(children: Children) -> impl IntoView {
107    let toaster = Toaster {
108        toasts: RwSignal::new(Vec::new()),
109        next: RwSignal::new(0),
110    };
111    provide_context(toaster);
112    view! {
113        {children()}
114        <div class="nightshade-toast-stack">
115            <For each=move || toaster.toasts.get() key=|toast| toast.id let:toast>
116                {
117                    let id = toast.id;
118                    let action = toast.action.clone();
119                    view! {
120                        <div class=format!("nightshade-toast {}", toast.kind)>
121                            <span class="nightshade-toast-text">{toast.text.clone()}</span>
122                            {action
123                                .map(|(label, callback)| {
124                                    view! {
125                                        <button
126                                            class="nightshade-toast-action"
127                                            on:click=move |_| {
128                                                callback.run(());
129                                                toaster.dismiss(id);
130                                            }
131                                        >
132                                            {label}
133                                        </button>
134                                    }
135                                })}
136                            <button
137                                class="nightshade-toast-close"
138                                aria-label="Dismiss"
139                                on:click=move |_| toaster.dismiss(id)
140                            >
141                                "\u{00d7}"
142                            </button>
143                        </div>
144                    }
145                }
146            </For>
147        </div>
148    }
149}
150
151fn after(milliseconds: i32, callback: impl FnOnce() + 'static) {
152    let closure = Closure::once_into_js(callback);
153    if let Some(window) = web_sys::window() {
154        let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
155            closure.unchecked_ref(),
156            milliseconds,
157        );
158    }
159}