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