Skip to main content

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    /// Inline style, appended to the modal element's own. Declared explicitly
144    /// rather than left to the attribute spread because this element always sets
145    /// `style` itself (`display: block`, which replaces Bootstrap's JS-driven
146    /// show): a caller style arriving through `..attributes` would be a second
147    /// `style` attribute on one element, and losing the fight would render the
148    /// modal invisible.
149    #[props(default)]
150    pub style: String,
151    /// Additional CSS classes for the backdrop element.
152    #[props(default)]
153    pub backdrop_class: String,
154    /// Inline style for the backdrop element. Bootstrap fixes the backdrop and
155    /// the modal at adjacent z-indexes; a page that stacks its own overlays
156    /// around them needs to be able to say where these two sit.
157    #[props(default)]
158    pub backdrop_style: String,
159    /// Called whenever the modal closes itself — the close button, a backdrop
160    /// click, or Escape. `show` is set to false first, so this is for the work
161    /// that must accompany closing rather than for the closing itself.
162    ///
163    /// Without it, state a caller clears in its own close handler is skipped by
164    /// every dismissal path the component owns, and the only way to be sure was
165    /// to switch all three off. Bootstrap fires `hide.bs.modal`/`hidden.bs.modal`
166    /// for the same reason.
167    #[props(default)]
168    pub on_dismiss: Option<EventHandler<()>>,
169    /// Any additional HTML attributes.
170    #[props(extends = GlobalAttributes)]
171    attributes: Vec<Attribute>,
172    /// Child elements (alternative to body prop for custom layout).
173    #[props(default)]
174    pub children: Element,
175}
176
177/// The modal element's inline style: the component's own `display: block` — which
178/// stands in for Bootstrap's JS-driven show — with the caller's appended.
179///
180/// This is a composition rather than an attribute, because the element already
181/// sets `style`. A caller style arriving through the attribute spread would be a
182/// second `style` on the same element; whichever one lost, the result is wrong,
183/// and if `display: block` is the loser the modal is invisible while every class
184/// assertion still passes.
185fn modal_inline_style(extra: &str) -> String {
186    if extra.is_empty() {
187        "display: block;".to_string()
188    } else {
189        format!("display: block; {extra}")
190    }
191}
192
193#[component]
194pub fn Modal(props: ModalProps) -> Element {
195    let is_shown = *props.show.read();
196    let mut show_signal = props.show;
197
198    if !is_shown {
199        return rsx! {};
200    }
201
202    let size_class = match props.size {
203        ModalSize::Sm => " modal-sm",
204        ModalSize::Default => "",
205        ModalSize::Lg => " modal-lg",
206        ModalSize::Xl => " modal-xl",
207    };
208
209    let centered = if props.centered {
210        " modal-dialog-centered"
211    } else {
212        ""
213    };
214
215    let scrollable = if props.scrollable {
216        " modal-dialog-scrollable"
217    } else {
218        ""
219    };
220
221    let fullscreen = match props.fullscreen {
222        ModalFullscreen::Off => "",
223        ModalFullscreen::Always => " modal-fullscreen",
224        ModalFullscreen::SmDown => " modal-fullscreen-sm-down",
225        ModalFullscreen::MdDown => " modal-fullscreen-md-down",
226        ModalFullscreen::LgDown => " modal-fullscreen-lg-down",
227        ModalFullscreen::XlDown => " modal-fullscreen-xl-down",
228        ModalFullscreen::XxlDown => " modal-fullscreen-xxl-down",
229    };
230
231    let dialog_class = if props.class.is_empty() {
232        format!("modal-dialog{size_class}{centered}{scrollable}{fullscreen}")
233    } else {
234        format!(
235            "modal-dialog{size_class}{centered}{scrollable}{fullscreen} {}",
236            props.class
237        )
238    };
239
240    let backdrop_close = props.backdrop_close;
241    let keyboard_close = props.keyboard_close;
242
243    // Each inner element keeps its Bootstrap class first, with the caller's
244    // additions appended — same composition Card uses.
245    let with_extra = |base: &str, extra: &str| {
246        if extra.is_empty() {
247            base.to_string()
248        } else {
249            format!("{base} {extra}")
250        }
251    };
252    let content_class = with_extra("modal-content", &props.content_class);
253    let header_class = with_extra("modal-header", &props.header_class);
254    let body_class = with_extra("modal-body", &props.body_class);
255    let footer_class = with_extra("modal-footer", &props.footer_class);
256
257    // Every dismissal the component owns runs through here, so a caller's
258    // `on_dismiss` cannot be reached by one path and missed by another.
259    let on_dismiss = props.on_dismiss;
260    let mut dismiss = move || {
261        show_signal.set(false);
262        if let Some(handler) = &on_dismiss {
263            handler.call(());
264        }
265    };
266
267    // Composed, never spread alongside the component's own — see `style`/
268    // `backdrop_style` on the props.
269    let backdrop_full_class = with_extra("modal-backdrop fade show", &props.backdrop_class);
270    let modal_style = modal_inline_style(&props.style);
271
272    rsx! {
273        // Backdrop
274        div {
275            class: "{backdrop_full_class}",
276            style: "{props.backdrop_style}",
277            onclick: move |_| {
278                if backdrop_close {
279                    dismiss();
280                }
281            },
282        }
283        // Modal
284        div {
285            class: "modal fade show",
286            style: "{modal_style}",
287            tabindex: "-1",
288            role: "dialog",
289            "aria-modal": "true",
290            // Focus the panel on open so it receives key events (Bootstrap
291            // moves focus to the modal on show); Escape then closes it.
292            onmounted: move |evt: MountedEvent| {
293                spawn(async move {
294                    let _ = evt.set_focus(true).await;
295                });
296            },
297            onkeydown: move |evt: KeyboardEvent| {
298                if keyboard_close && is_escape_key(&evt.key()) {
299                    dismiss();
300                }
301            },
302            onclick: move |_| {
303                if backdrop_close {
304                    dismiss();
305                }
306            },
307            ..props.attributes,
308            div {
309                class: "{dialog_class}",
310                // Stop click propagation so clicking inside the modal doesn't close it
311                onclick: move |evt| evt.stop_propagation(),
312                div { class: "{content_class}",
313                    // Header — the rich `header` slot replaces the plain title
314                    // when given; the close button is independent of both.
315                    if props.header.is_some() || !props.title.is_empty() || props.show_close {
316                        div { class: "{header_class}",
317                            if let Some(header) = props.header {
318                                {header}
319                            } else if !props.title.is_empty() {
320                                h5 { class: "modal-title", "{props.title}" }
321                            }
322                            if props.show_close {
323                                button {
324                                    class: "btn-close",
325                                    r#type: "button",
326                                    "aria-label": "Close",
327                                    onclick: move |_| dismiss(),
328                                }
329                            }
330                        }
331                    }
332                    // Body
333                    if let Some(body) = props.body {
334                        div { class: "{body_class}", {body} }
335                    }
336                    {props.children}
337                    // Footer
338                    if let Some(footer) = props.footer {
339                        div { class: "{footer_class}", {footer} }
340                    }
341                }
342            }
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn modal_style_alone_is_the_display_override() {
353        assert_eq!(modal_inline_style(""), "display: block;");
354    }
355
356    #[test]
357    fn a_caller_style_is_appended_not_substituted() {
358        // Both must survive. `display: block` is what makes the modal visible at
359        // all, so a composition that dropped it in favour of the caller's would
360        // render nothing while looking entirely correct in the class list.
361        assert_eq!(
362            modal_inline_style("z-index: 1080;"),
363            "display: block; z-index: 1080;"
364        );
365    }
366}