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    /// Modal body content.
88    #[props(default)]
89    pub body: Option<Element>,
90    /// Modal footer content.
91    #[props(default)]
92    pub footer: Option<Element>,
93    /// Modal size.
94    #[props(default)]
95    pub size: ModalSize,
96    /// Close when clicking the backdrop.
97    #[props(default = true)]
98    pub backdrop_close: bool,
99    /// Close when the Escape key is pressed (Bootstrap's `keyboard` option).
100    #[props(default = true)]
101    pub keyboard_close: bool,
102    /// Show the close button in the header.
103    #[props(default = true)]
104    pub show_close: bool,
105    /// Center the modal vertically.
106    #[props(default)]
107    pub centered: bool,
108    /// Allow the modal body to scroll.
109    #[props(default)]
110    pub scrollable: bool,
111    /// Fullscreen mode.
112    #[props(default)]
113    pub fullscreen: ModalFullscreen,
114    /// Additional CSS classes for the modal-dialog.
115    #[props(default)]
116    pub class: String,
117    /// Any additional HTML attributes.
118    #[props(extends = GlobalAttributes)]
119    attributes: Vec<Attribute>,
120    /// Child elements (alternative to body prop for custom layout).
121    #[props(default)]
122    pub children: Element,
123}
124
125#[component]
126pub fn Modal(props: ModalProps) -> Element {
127    let is_shown = *props.show.read();
128    let mut show_signal = props.show;
129
130    if !is_shown {
131        return rsx! {};
132    }
133
134    let size_class = match props.size {
135        ModalSize::Sm => " modal-sm",
136        ModalSize::Default => "",
137        ModalSize::Lg => " modal-lg",
138        ModalSize::Xl => " modal-xl",
139    };
140
141    let centered = if props.centered {
142        " modal-dialog-centered"
143    } else {
144        ""
145    };
146
147    let scrollable = if props.scrollable {
148        " modal-dialog-scrollable"
149    } else {
150        ""
151    };
152
153    let fullscreen = match props.fullscreen {
154        ModalFullscreen::Off => "",
155        ModalFullscreen::Always => " modal-fullscreen",
156        ModalFullscreen::SmDown => " modal-fullscreen-sm-down",
157        ModalFullscreen::MdDown => " modal-fullscreen-md-down",
158        ModalFullscreen::LgDown => " modal-fullscreen-lg-down",
159        ModalFullscreen::XlDown => " modal-fullscreen-xl-down",
160        ModalFullscreen::XxlDown => " modal-fullscreen-xxl-down",
161    };
162
163    let dialog_class = if props.class.is_empty() {
164        format!("modal-dialog{size_class}{centered}{scrollable}{fullscreen}")
165    } else {
166        format!(
167            "modal-dialog{size_class}{centered}{scrollable}{fullscreen} {}",
168            props.class
169        )
170    };
171
172    let backdrop_close = props.backdrop_close;
173    let keyboard_close = props.keyboard_close;
174
175    rsx! {
176        // Backdrop
177        div {
178            class: "modal-backdrop fade show",
179            onclick: move |_| {
180                if backdrop_close {
181                    show_signal.set(false);
182                }
183            },
184        }
185        // Modal
186        div {
187            class: "modal fade show",
188            style: "display: block;",
189            tabindex: "-1",
190            role: "dialog",
191            "aria-modal": "true",
192            // Focus the panel on open so it receives key events (Bootstrap
193            // moves focus to the modal on show); Escape then closes it.
194            onmounted: move |evt: MountedEvent| {
195                spawn(async move {
196                    let _ = evt.set_focus(true).await;
197                });
198            },
199            onkeydown: move |evt: KeyboardEvent| {
200                if keyboard_close && is_escape_key(&evt.key()) {
201                    show_signal.set(false);
202                }
203            },
204            onclick: move |_| {
205                if backdrop_close {
206                    show_signal.set(false);
207                }
208            },
209            ..props.attributes,
210            div {
211                class: "{dialog_class}",
212                // Stop click propagation so clicking inside the modal doesn't close it
213                onclick: move |evt| evt.stop_propagation(),
214                div { class: "modal-content",
215                    // Header
216                    if !props.title.is_empty() || props.show_close {
217                        div { class: "modal-header",
218                            if !props.title.is_empty() {
219                                h5 { class: "modal-title", "{props.title}" }
220                            }
221                            if props.show_close {
222                                button {
223                                    class: "btn-close",
224                                    r#type: "button",
225                                    "aria-label": "Close",
226                                    onclick: move |_| show_signal.set(false),
227                                }
228                            }
229                        }
230                    }
231                    // Body
232                    if let Some(body) = props.body {
233                        div { class: "modal-body", {body} }
234                    }
235                    {props.children}
236                    // Footer
237                    if let Some(footer) = props.footer {
238                        div { class: "modal-footer", {footer} }
239                    }
240                }
241            }
242        }
243    }
244}