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.
214    #[props(default)]
215    pub position: ToastPosition,
216    /// Additional CSS classes.
217    #[props(default)]
218    pub class: String,
219    /// Child elements (Toast components).
220    pub children: Element,
221}
222
223/// Toast position on screen.
224#[derive(Clone, Copy, Debug, Default, PartialEq)]
225pub enum ToastPosition {
226    TopStart,
227    TopCenter,
228    #[default]
229    TopEnd,
230    MiddleCenter,
231    BottomStart,
232    BottomCenter,
233    BottomEnd,
234}
235
236impl std::fmt::Display for ToastPosition {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        match self {
239            ToastPosition::TopStart => write!(f, "top-0 start-0"),
240            ToastPosition::TopCenter => write!(f, "top-0 start-50 translate-middle-x"),
241            ToastPosition::TopEnd => write!(f, "top-0 end-0"),
242            ToastPosition::MiddleCenter => {
243                write!(f, "top-50 start-50 translate-middle")
244            }
245            ToastPosition::BottomStart => write!(f, "bottom-0 start-0"),
246            ToastPosition::BottomCenter => {
247                write!(f, "bottom-0 start-50 translate-middle-x")
248            }
249            ToastPosition::BottomEnd => write!(f, "bottom-0 end-0"),
250        }
251    }
252}
253
254#[component]
255pub fn ToastContainer(props: ToastContainerProps) -> Element {
256    let pos = props.position;
257    let full_class = if props.class.is_empty() {
258        format!("toast-container position-fixed p-3 {pos}")
259    } else {
260        format!("toast-container position-fixed p-3 {pos} {}", props.class)
261    };
262
263    rsx! {
264        div { class: "{full_class}", {props.children} }
265    }
266}