Skip to main content

dioxus_bootstrap_css/
toast.rs

1use dioxus::prelude::*;
2
3use crate::types::Color;
4
5/// Bootstrap Toast notification — signal-driven, no JavaScript.
6///
7/// # Bootstrap HTML → Dioxus
8///
9/// ```html
10/// <!-- Bootstrap HTML (requires JavaScript) -->
11/// <div class="toast show">
12///   <div class="toast-header">
13///     <strong class="me-auto">Notification</strong>
14///     <small>just now</small>
15///     <button class="btn-close" data-bs-dismiss="toast"></button>
16///   </div>
17///   <div class="toast-body">You have a new message.</div>
18/// </div>
19/// ```
20///
21/// ```rust,no_run
22/// # use dioxus::prelude::*;
23/// # use dioxus_bootstrap_css::prelude::*;
24/// # fn _doctest() -> Element {
25/// // Dioxus equivalent
26/// let show = use_signal(|| true);
27/// rsx! {
28///     ToastContainer { position: ToastPosition::TopEnd,
29///         Toast { show: show, title: "Notification", subtitle: "just now",
30///             "You have a new message."
31///         }
32///     }
33/// }
34/// # }
35/// ```
36///
37/// # Headerless Mode
38///
39/// Omit `title` and set `show_close: true` to render a headerless toast with
40/// a side-aligned close button (Bootstrap 5.3 `d-flex` pattern):
41///
42/// ```rust,no_run
43/// # use dioxus::prelude::*;
44/// # use dioxus_bootstrap_css::prelude::*;
45/// # fn _doctest() -> Element {
46/// # let signal = use_signal(|| true);
47/// rsx! {
48///     Toast { show: signal, show_close: true, color: Color::Primary,
49///         "Body-only toast with close button."
50///     }
51/// }
52/// # }
53/// ```
54///
55/// # Props
56///
57/// - `show` — `Signal<bool>` controlling visibility
58/// - `title` — toast header title (omit for headerless mode)
59/// - `subtitle` — small text in header (e.g., "just now")
60/// - `color` — background color variant
61/// - `show_close` — show close button (default: true)
62/// - `autohide` — auto-dismiss after `delay_ms` (default: false)
63/// - `delay_ms` — auto-dismiss delay in milliseconds (default: 5000)
64/// - `on_dismiss` — callback when the toast is dismissed
65#[derive(Clone, PartialEq, Props)]
66pub struct ToastProps {
67    /// Signal controlling visibility.
68    pub show: Signal<bool>,
69    /// Toast title (shown in header).
70    #[props(default)]
71    pub title: String,
72    /// Small text in header (e.g., "just now", "2 mins ago").
73    #[props(default)]
74    pub subtitle: String,
75    /// Show close button.
76    #[props(default = true)]
77    pub show_close: bool,
78    /// Auto-dismiss the toast after `delay_ms` (Bootstrap's `autohide` option).
79    #[props(default)]
80    pub autohide: bool,
81    /// Auto-dismiss delay in milliseconds (Bootstrap's `delay` option).
82    #[props(default = 5000)]
83    pub delay_ms: u32,
84    /// Toast color variant (applied as bg class).
85    #[props(default)]
86    pub color: Option<Color>,
87    /// Callback when the toast is dismissed.
88    #[props(default)]
89    pub on_dismiss: Option<EventHandler<()>>,
90    /// Additional CSS classes.
91    #[props(default)]
92    pub class: String,
93    /// Toast body content.
94    pub children: Element,
95}
96
97#[component]
98pub fn Toast(props: ToastProps) -> Element {
99    let mut show_signal = props.show;
100    let on_dismiss = props.on_dismiss;
101    let autohide = props.autohide;
102    let delay_ms = props.delay_ms;
103
104    // Autohide — when the toast is shown and autohide is on, close it after
105    // `delay_ms`, matching Bootstrap's `autohide` + `delay`. Runs before the
106    // early return so the hook count is stable across shown/hidden renders.
107    use_effect(move || {
108        let shown = *show_signal.read();
109        if autohide && shown {
110            spawn(async move {
111                gloo_timers::future::TimeoutFuture::new(delay_ms).await;
112                // The toast may have been dismissed already while we waited.
113                if *show_signal.peek() {
114                    show_signal.set(false);
115                    if let Some(handler) = &on_dismiss {
116                        handler.call(());
117                    }
118                }
119            });
120        }
121    });
122
123    let is_shown = *show_signal.read();
124    if !is_shown {
125        return rsx! {};
126    }
127
128    let dismiss = move |_| {
129        show_signal.set(false);
130        if let Some(handler) = &on_dismiss {
131            handler.call(());
132        }
133    };
134
135    let color_class = match &props.color {
136        Some(c) => format!(" text-bg-{c}"),
137        None => String::new(),
138    };
139
140    let full_class = if props.class.is_empty() {
141        format!("toast show{color_class}")
142    } else {
143        format!("toast show{color_class} {}", props.class)
144    };
145
146    // Determine close button class — use white variant for colored toasts
147    let close_class = if props.color.is_some() {
148        "btn-close btn-close-white me-2 m-auto"
149    } else {
150        "btn-close"
151    };
152
153    rsx! {
154        div {
155            class: "{full_class}",
156            role: "alert",
157            "aria-live": "assertive",
158            "aria-atomic": "true",
159            if !props.title.is_empty() {
160                // Header mode: title + subtitle + close button
161                div { class: "toast-header",
162                    strong { class: "me-auto", "{props.title}" }
163                    if !props.subtitle.is_empty() {
164                        small { "{props.subtitle}" }
165                    }
166                    if props.show_close {
167                        button {
168                            class: "btn-close",
169                            r#type: "button",
170                            "aria-label": "Close",
171                            onclick: dismiss,
172                        }
173                    }
174                }
175                div { class: "toast-body", {props.children} }
176            } else if props.show_close {
177                // Headerless mode with close button: d-flex layout (Bootstrap 5.3 pattern)
178                div { class: "d-flex",
179                    div { class: "toast-body", {props.children} }
180                    button {
181                        class: "{close_class}",
182                        r#type: "button",
183                        "aria-label": "Close",
184                        onclick: move |_| show_signal.set(false),
185                    }
186                }
187            } else {
188                // Simple body-only toast
189                div { class: "toast-body", {props.children} }
190            }
191        }
192    }
193}
194
195/// Container for positioning toasts on screen.
196///
197/// ```rust,no_run
198/// # use dioxus::prelude::*;
199/// # use dioxus_bootstrap_css::prelude::*;
200/// # fn _doctest() -> Element {
201/// # let signal1 = use_signal(|| true);
202/// # let signal2 = use_signal(|| true);
203/// rsx! {
204///     ToastContainer { position: ToastPosition::TopEnd,
205///         Toast { show: signal1, title: "Success", "Saved!" }
206///         Toast { show: signal2, title: "Error", color: Color::Danger, "Failed." }
207///     }
208/// }
209/// # }
210/// ```
211#[derive(Clone, PartialEq, Props)]
212pub struct ToastContainerProps {
213    /// Position on screen. Ignored when `positioned` is false.
214    #[props(default)]
215    pub position: ToastPosition,
216    /// Emit the fixed-position utilities. Set false when the host's own CSS
217    /// places the stack: `position-fixed` and its offsets are `!important`
218    /// utilities and would otherwise win, leaving the container pinned to the
219    /// viewport instead of sitting where the page put it.
220    #[props(default = true)]
221    pub positioned: bool,
222    /// Additional CSS classes.
223    #[props(default)]
224    pub class: String,
225    /// Child elements (Toast components).
226    pub children: Element,
227}
228
229/// Toast position on screen.
230#[derive(Clone, Copy, Debug, Default, PartialEq)]
231pub enum ToastPosition {
232    TopStart,
233    TopCenter,
234    #[default]
235    TopEnd,
236    MiddleCenter,
237    BottomStart,
238    BottomCenter,
239    BottomEnd,
240}
241
242impl std::fmt::Display for ToastPosition {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            ToastPosition::TopStart => write!(f, "top-0 start-0"),
246            ToastPosition::TopCenter => write!(f, "top-0 start-50 translate-middle-x"),
247            ToastPosition::TopEnd => write!(f, "top-0 end-0"),
248            ToastPosition::MiddleCenter => {
249                write!(f, "top-50 start-50 translate-middle")
250            }
251            ToastPosition::BottomStart => write!(f, "bottom-0 start-0"),
252            ToastPosition::BottomCenter => {
253                write!(f, "bottom-0 start-50 translate-middle-x")
254            }
255            ToastPosition::BottomEnd => write!(f, "bottom-0 end-0"),
256        }
257    }
258}
259
260/// The toast container's class string. Extracted so the positioned/unpositioned
261/// split is assertable without rendering — the whole point of `positioned` is
262/// which classes are absent.
263fn toast_container_class(positioned: bool, position: ToastPosition, class: &str) -> String {
264    let placement = if positioned {
265        format!(" position-fixed p-3 {position}")
266    } else {
267        String::new()
268    };
269    if class.is_empty() {
270        format!("toast-container{placement}")
271    } else {
272        format!("toast-container{placement} {class}")
273    }
274}
275
276#[component]
277pub fn ToastContainer(props: ToastContainerProps) -> Element {
278    let full_class = toast_container_class(props.positioned, props.position, &props.class);
279
280    rsx! {
281        div { class: "{full_class}", {props.children} }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn toast_container_is_positioned_by_default() {
291        // Unchanged behaviour: every container rendered before this prop existed
292        // carried the fixed-position utilities.
293        assert_eq!(
294            toast_container_class(true, ToastPosition::default(), ""),
295            format!(
296                "toast-container position-fixed p-3 {}",
297                ToastPosition::default()
298            )
299        );
300    }
301
302    #[test]
303    fn toast_container_unpositioned_drops_the_utilities_entirely() {
304        // Not "keeps them with a different position" — the point is that the
305        // !important utilities are absent so the host's CSS can win.
306        let c = toast_container_class(false, ToastPosition::TopEnd, "");
307        assert_eq!(c, "toast-container");
308        assert!(!c.contains("position-fixed"));
309    }
310
311    #[test]
312    fn toast_container_extra_classes_survive_both_modes() {
313        assert!(toast_container_class(true, ToastPosition::TopEnd, "mt-5").ends_with(" mt-5"));
314        assert_eq!(
315            toast_container_class(false, ToastPosition::TopEnd, "mt-5"),
316            "toast-container mt-5"
317        );
318    }
319}