dioxus_bootstrap_css/modal.rs
1use dioxus::prelude::*;
2
3use crate::keyboard::is_escape_key;
4use crate::types::ModalSize;
5
6/// Modal fullscreen variants.
7#[derive(Clone, Copy, Debug, Default, PartialEq)]
8pub enum ModalFullscreen {
9 /// Not fullscreen.
10 #[default]
11 Off,
12 /// Always fullscreen.
13 Always,
14 /// Fullscreen below sm breakpoint.
15 SmDown,
16 /// Fullscreen below md breakpoint.
17 MdDown,
18 /// Fullscreen below lg breakpoint.
19 LgDown,
20 /// Fullscreen below xl breakpoint.
21 XlDown,
22 /// Fullscreen below xxl breakpoint.
23 XxlDown,
24}
25
26/// Bootstrap Modal component — signal-driven, no JavaScript.
27///
28/// Replaces Bootstrap's `<div class="modal">` + JavaScript with a signal-controlled component.
29///
30/// # Bootstrap HTML → Dioxus
31///
32/// ```html
33/// <!-- Bootstrap HTML (requires JavaScript) -->
34/// <div class="modal fade" tabindex="-1">
35/// <div class="modal-dialog modal-lg modal-dialog-centered">
36/// <div class="modal-content">
37/// <div class="modal-header"><h5 class="modal-title">Title</h5></div>
38/// <div class="modal-body"><p>Body</p></div>
39/// <div class="modal-footer"><button class="btn btn-primary">OK</button></div>
40/// </div>
41/// </div>
42/// </div>
43/// ```
44///
45/// ```rust,no_run
46/// # use dioxus::prelude::*;
47/// # use dioxus_bootstrap_css::prelude::*;
48/// # fn _doctest() -> Element {
49/// // Dioxus equivalent — no JavaScript needed
50/// let mut show = use_signal(|| false);
51/// rsx! {
52/// Button { onclick: move |_| show.set(true), "Open Modal" }
53/// Modal {
54/// show: show,
55/// title: "Confirm Action",
56/// size: ModalSize::Lg,
57/// centered: true,
58/// body: rsx! { p { "Are you sure?" } },
59/// footer: rsx! {
60/// Button { color: Color::Secondary, onclick: move |_| show.set(false), "Cancel" }
61/// Button { color: Color::Primary, "Confirm" }
62/// },
63/// }
64/// }
65/// # }
66/// ```
67///
68/// # Props
69///
70/// - `show` — `Signal<bool>` controlling visibility
71/// - `title` — modal title text
72/// - `body` — modal body content (Element)
73/// - `footer` — modal footer content (Element)
74/// - `size` — `ModalSize::Sm`, `Default`, `Lg`, `Xl`
75/// - `fullscreen` — `ModalFullscreen::Off`, `Always`, `SmDown`..`XxlDown`
76/// - `centered` — vertically center the modal
77/// - `scrollable` — scrollable modal body
78/// - `backdrop_close` — close when clicking backdrop (default: true)
79/// - `keyboard_close` — close on the Escape key (default: true)
80#[derive(Clone, PartialEq, Props)]
81pub struct ModalProps {
82 /// Signal controlling modal visibility.
83 pub show: Signal<bool>,
84 /// Modal title.
85 #[props(default)]
86 pub title: String,
87 /// Rich header content, rendered inside `.modal-header` in place of the
88 /// plain `title`. Bootstrap's header holds whatever markup the caller
89 /// wants — an icon and a title, a title and a badge — and a `String` title
90 /// cannot express that. [`Card`](crate::card::Card) already has this slot,
91 /// for the same reason.
92 #[props(default)]
93 pub header: Option<Element>,
94 /// Modal body content.
95 #[props(default)]
96 pub body: Option<Element>,
97 /// Modal footer content.
98 #[props(default)]
99 pub footer: Option<Element>,
100 /// Modal size.
101 #[props(default)]
102 pub size: ModalSize,
103 /// Close when clicking the backdrop.
104 #[props(default = true)]
105 pub backdrop_close: bool,
106 /// Close when the Escape key is pressed (Bootstrap's `keyboard` option).
107 #[props(default = true)]
108 pub keyboard_close: bool,
109 /// Show the close button in the header.
110 #[props(default = true)]
111 pub show_close: bool,
112 /// Center the modal vertically.
113 #[props(default)]
114 pub centered: bool,
115 /// Allow the modal body to scroll.
116 #[props(default)]
117 pub scrollable: bool,
118 /// Fullscreen mode.
119 #[props(default)]
120 pub fullscreen: ModalFullscreen,
121 /// Additional CSS classes for the modal-dialog.
122 #[props(default)]
123 pub class: String,
124 /// Additional CSS classes for the modal-content div.
125 ///
126 /// This and the three below exist because `.modal-content`,
127 /// `.modal-header`, `.modal-body` and `.modal-footer` are the elements a
128 /// caller most often needs to reach — a border, a padding override, a
129 /// scroll height. Without them the only route is hand-written Bootstrap
130 /// markup, which is what the typed component replaces.
131 /// [`Card`](crate::card::Card) already carries the equivalent set.
132 #[props(default)]
133 pub content_class: String,
134 /// Additional CSS classes for the modal-header div.
135 #[props(default)]
136 pub header_class: String,
137 /// Additional CSS classes for the modal-body div.
138 #[props(default)]
139 pub body_class: String,
140 /// Additional CSS classes for the modal-footer div.
141 #[props(default)]
142 pub footer_class: String,
143 /// Any additional HTML attributes.
144 #[props(extends = GlobalAttributes)]
145 attributes: Vec<Attribute>,
146 /// Child elements (alternative to body prop for custom layout).
147 #[props(default)]
148 pub children: Element,
149}
150
151#[component]
152pub fn Modal(props: ModalProps) -> Element {
153 let is_shown = *props.show.read();
154 let mut show_signal = props.show;
155
156 if !is_shown {
157 return rsx! {};
158 }
159
160 let size_class = match props.size {
161 ModalSize::Sm => " modal-sm",
162 ModalSize::Default => "",
163 ModalSize::Lg => " modal-lg",
164 ModalSize::Xl => " modal-xl",
165 };
166
167 let centered = if props.centered {
168 " modal-dialog-centered"
169 } else {
170 ""
171 };
172
173 let scrollable = if props.scrollable {
174 " modal-dialog-scrollable"
175 } else {
176 ""
177 };
178
179 let fullscreen = match props.fullscreen {
180 ModalFullscreen::Off => "",
181 ModalFullscreen::Always => " modal-fullscreen",
182 ModalFullscreen::SmDown => " modal-fullscreen-sm-down",
183 ModalFullscreen::MdDown => " modal-fullscreen-md-down",
184 ModalFullscreen::LgDown => " modal-fullscreen-lg-down",
185 ModalFullscreen::XlDown => " modal-fullscreen-xl-down",
186 ModalFullscreen::XxlDown => " modal-fullscreen-xxl-down",
187 };
188
189 let dialog_class = if props.class.is_empty() {
190 format!("modal-dialog{size_class}{centered}{scrollable}{fullscreen}")
191 } else {
192 format!(
193 "modal-dialog{size_class}{centered}{scrollable}{fullscreen} {}",
194 props.class
195 )
196 };
197
198 let backdrop_close = props.backdrop_close;
199 let keyboard_close = props.keyboard_close;
200
201 // Each inner element keeps its Bootstrap class first, with the caller's
202 // additions appended — same composition Card uses.
203 let with_extra = |base: &str, extra: &str| {
204 if extra.is_empty() {
205 base.to_string()
206 } else {
207 format!("{base} {extra}")
208 }
209 };
210 let content_class = with_extra("modal-content", &props.content_class);
211 let header_class = with_extra("modal-header", &props.header_class);
212 let body_class = with_extra("modal-body", &props.body_class);
213 let footer_class = with_extra("modal-footer", &props.footer_class);
214
215 rsx! {
216 // Backdrop
217 div {
218 class: "modal-backdrop fade show",
219 onclick: move |_| {
220 if backdrop_close {
221 show_signal.set(false);
222 }
223 },
224 }
225 // Modal
226 div {
227 class: "modal fade show",
228 style: "display: block;",
229 tabindex: "-1",
230 role: "dialog",
231 "aria-modal": "true",
232 // Focus the panel on open so it receives key events (Bootstrap
233 // moves focus to the modal on show); Escape then closes it.
234 onmounted: move |evt: MountedEvent| {
235 spawn(async move {
236 let _ = evt.set_focus(true).await;
237 });
238 },
239 onkeydown: move |evt: KeyboardEvent| {
240 if keyboard_close && is_escape_key(&evt.key()) {
241 show_signal.set(false);
242 }
243 },
244 onclick: move |_| {
245 if backdrop_close {
246 show_signal.set(false);
247 }
248 },
249 ..props.attributes,
250 div {
251 class: "{dialog_class}",
252 // Stop click propagation so clicking inside the modal doesn't close it
253 onclick: move |evt| evt.stop_propagation(),
254 div { class: "{content_class}",
255 // Header — the rich `header` slot replaces the plain title
256 // when given; the close button is independent of both.
257 if props.header.is_some() || !props.title.is_empty() || props.show_close {
258 div { class: "{header_class}",
259 if let Some(header) = props.header {
260 {header}
261 } else if !props.title.is_empty() {
262 h5 { class: "modal-title", "{props.title}" }
263 }
264 if props.show_close {
265 button {
266 class: "btn-close",
267 r#type: "button",
268 "aria-label": "Close",
269 onclick: move |_| show_signal.set(false),
270 }
271 }
272 }
273 }
274 // Body
275 if let Some(body) = props.body {
276 div { class: "{body_class}", {body} }
277 }
278 {props.children}
279 // Footer
280 if let Some(footer) = props.footer {
281 div { class: "{footer_class}", {footer} }
282 }
283 }
284 }
285 }
286 }
287}