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}