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}