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/// - `on_dismiss` — callback when the toast is dismissed
63#[derive(Clone, PartialEq, Props)]
64pub struct ToastProps {
65    /// Signal controlling visibility.
66    pub show: Signal<bool>,
67    /// Toast title (shown in header).
68    #[props(default)]
69    pub title: String,
70    /// Small text in header (e.g., "just now", "2 mins ago").
71    #[props(default)]
72    pub subtitle: String,
73    /// Show close button.
74    #[props(default = true)]
75    pub show_close: bool,
76    /// Toast color variant (applied as bg class).
77    #[props(default)]
78    pub color: Option<Color>,
79    /// Callback when the toast is dismissed.
80    #[props(default)]
81    pub on_dismiss: Option<EventHandler<()>>,
82    /// Additional CSS classes.
83    #[props(default)]
84    pub class: String,
85    /// Toast body content.
86    pub children: Element,
87}
88
89#[component]
90pub fn Toast(props: ToastProps) -> Element {
91    let is_shown = *props.show.read();
92    let mut show_signal = props.show;
93    let on_dismiss = props.on_dismiss;
94
95    if !is_shown {
96        return rsx! {};
97    }
98
99    let dismiss = move |_| {
100        show_signal.set(false);
101        if let Some(handler) = &on_dismiss {
102            handler.call(());
103        }
104    };
105
106    let color_class = match &props.color {
107        Some(c) => format!(" text-bg-{c}"),
108        None => String::new(),
109    };
110
111    let full_class = if props.class.is_empty() {
112        format!("toast show{color_class}")
113    } else {
114        format!("toast show{color_class} {}", props.class)
115    };
116
117    // Determine close button class — use white variant for colored toasts
118    let close_class = if props.color.is_some() {
119        "btn-close btn-close-white me-2 m-auto"
120    } else {
121        "btn-close"
122    };
123
124    rsx! {
125        div {
126            class: "{full_class}",
127            role: "alert",
128            "aria-live": "assertive",
129            "aria-atomic": "true",
130            if !props.title.is_empty() {
131                // Header mode: title + subtitle + close button
132                div { class: "toast-header",
133                    strong { class: "me-auto", "{props.title}" }
134                    if !props.subtitle.is_empty() {
135                        small { "{props.subtitle}" }
136                    }
137                    if props.show_close {
138                        button {
139                            class: "btn-close",
140                            r#type: "button",
141                            "aria-label": "Close",
142                            onclick: dismiss,
143                        }
144                    }
145                }
146                div { class: "toast-body", {props.children} }
147            } else if props.show_close {
148                // Headerless mode with close button: d-flex layout (Bootstrap 5.3 pattern)
149                div { class: "d-flex",
150                    div { class: "toast-body", {props.children} }
151                    button {
152                        class: "{close_class}",
153                        r#type: "button",
154                        "aria-label": "Close",
155                        onclick: move |_| show_signal.set(false),
156                    }
157                }
158            } else {
159                // Simple body-only toast
160                div { class: "toast-body", {props.children} }
161            }
162        }
163    }
164}
165
166/// Container for positioning toasts on screen.
167///
168/// ```rust,no_run
169/// # use dioxus::prelude::*;
170/// # use dioxus_bootstrap_css::prelude::*;
171/// # fn _doctest() -> Element {
172/// # let signal1 = use_signal(|| true);
173/// # let signal2 = use_signal(|| true);
174/// rsx! {
175///     ToastContainer { position: ToastPosition::TopEnd,
176///         Toast { show: signal1, title: "Success", "Saved!" }
177///         Toast { show: signal2, title: "Error", color: Color::Danger, "Failed." }
178///     }
179/// }
180/// # }
181/// ```
182#[derive(Clone, PartialEq, Props)]
183pub struct ToastContainerProps {
184    /// Position on screen.
185    #[props(default)]
186    pub position: ToastPosition,
187    /// Additional CSS classes.
188    #[props(default)]
189    pub class: String,
190    /// Child elements (Toast components).
191    pub children: Element,
192}
193
194/// Toast position on screen.
195#[derive(Clone, Copy, Debug, Default, PartialEq)]
196pub enum ToastPosition {
197    TopStart,
198    TopCenter,
199    #[default]
200    TopEnd,
201    MiddleCenter,
202    BottomStart,
203    BottomCenter,
204    BottomEnd,
205}
206
207impl std::fmt::Display for ToastPosition {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        match self {
210            ToastPosition::TopStart => write!(f, "top-0 start-0"),
211            ToastPosition::TopCenter => write!(f, "top-0 start-50 translate-middle-x"),
212            ToastPosition::TopEnd => write!(f, "top-0 end-0"),
213            ToastPosition::MiddleCenter => {
214                write!(f, "top-50 start-50 translate-middle")
215            }
216            ToastPosition::BottomStart => write!(f, "bottom-0 start-0"),
217            ToastPosition::BottomCenter => {
218                write!(f, "bottom-0 start-50 translate-middle-x")
219            }
220            ToastPosition::BottomEnd => write!(f, "bottom-0 end-0"),
221        }
222    }
223}
224
225#[component]
226pub fn ToastContainer(props: ToastContainerProps) -> Element {
227    let pos = props.position;
228    let full_class = if props.class.is_empty() {
229        format!("toast-container position-fixed p-3 {pos}")
230    } else {
231        format!("toast-container position-fixed p-3 {pos} {}", props.class)
232    };
233
234    rsx! {
235        div { class: "{full_class}", {props.children} }
236    }
237}