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