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}