Skip to main content

dioxus_bootstrap_css/
modal.rs

1use dioxus::prelude::*;
2
3use crate::types::ModalSize;
4
5/// Modal fullscreen variants.
6#[derive(Clone, Copy, Debug, Default, PartialEq)]
7pub enum ModalFullscreen {
8    /// Not fullscreen.
9    #[default]
10    Off,
11    /// Always fullscreen.
12    Always,
13    /// Fullscreen below sm breakpoint.
14    SmDown,
15    /// Fullscreen below md breakpoint.
16    MdDown,
17    /// Fullscreen below lg breakpoint.
18    LgDown,
19    /// Fullscreen below xl breakpoint.
20    XlDown,
21    /// Fullscreen below xxl breakpoint.
22    XxlDown,
23}
24
25/// Bootstrap Modal component — signal-driven, no JavaScript.
26///
27/// Replaces Bootstrap's `<div class="modal">` + JavaScript with a signal-controlled component.
28///
29/// # Bootstrap HTML → Dioxus
30///
31/// ```html
32/// <!-- Bootstrap HTML (requires JavaScript) -->
33/// <div class="modal fade" tabindex="-1">
34///   <div class="modal-dialog modal-lg modal-dialog-centered">
35///     <div class="modal-content">
36///       <div class="modal-header"><h5 class="modal-title">Title</h5></div>
37///       <div class="modal-body"><p>Body</p></div>
38///       <div class="modal-footer"><button class="btn btn-primary">OK</button></div>
39///     </div>
40///   </div>
41/// </div>
42/// ```
43///
44/// ```rust,no_run
45/// # use dioxus::prelude::*;
46/// # use dioxus_bootstrap_css::prelude::*;
47/// # fn _doctest() -> Element {
48/// // Dioxus equivalent — no JavaScript needed
49/// let mut show = use_signal(|| false);
50/// rsx! {
51///     Button { onclick: move |_| show.set(true), "Open Modal" }
52///     Modal {
53///         show: show,
54///         title: "Confirm Action",
55///         size: ModalSize::Lg,
56///         centered: true,
57///         body: rsx! { p { "Are you sure?" } },
58///         footer: rsx! {
59///             Button { color: Color::Secondary, onclick: move |_| show.set(false), "Cancel" }
60///             Button { color: Color::Primary, "Confirm" }
61///         },
62///     }
63/// }
64/// # }
65/// ```
66///
67/// # Props
68///
69/// - `show` — `Signal<bool>` controlling visibility
70/// - `title` — modal title text
71/// - `body` — modal body content (Element)
72/// - `footer` — modal footer content (Element)
73/// - `size` — `ModalSize::Sm`, `Default`, `Lg`, `Xl`
74/// - `fullscreen` — `ModalFullscreen::Off`, `Always`, `SmDown`..`XxlDown`
75/// - `centered` — vertically center the modal
76/// - `scrollable` — scrollable modal body
77/// - `backdrop_close` — close when clicking backdrop (default: true)
78#[derive(Clone, PartialEq, Props)]
79pub struct ModalProps {
80    /// Signal controlling modal visibility.
81    pub show: Signal<bool>,
82    /// Modal title.
83    #[props(default)]
84    pub title: String,
85    /// Modal body content.
86    #[props(default)]
87    pub body: Option<Element>,
88    /// Modal footer content.
89    #[props(default)]
90    pub footer: Option<Element>,
91    /// Modal size.
92    #[props(default)]
93    pub size: ModalSize,
94    /// Close when clicking the backdrop.
95    #[props(default = true)]
96    pub backdrop_close: bool,
97    /// Show the close button in the header.
98    #[props(default = true)]
99    pub show_close: bool,
100    /// Center the modal vertically.
101    #[props(default)]
102    pub centered: bool,
103    /// Allow the modal body to scroll.
104    #[props(default)]
105    pub scrollable: bool,
106    /// Fullscreen mode.
107    #[props(default)]
108    pub fullscreen: ModalFullscreen,
109    /// Additional CSS classes for the modal-dialog.
110    #[props(default)]
111    pub class: String,
112    /// Any additional HTML attributes.
113    #[props(extends = GlobalAttributes)]
114    attributes: Vec<Attribute>,
115    /// Child elements (alternative to body prop for custom layout).
116    #[props(default)]
117    pub children: Element,
118}
119
120#[component]
121pub fn Modal(props: ModalProps) -> Element {
122    let is_shown = *props.show.read();
123    let mut show_signal = props.show;
124
125    if !is_shown {
126        return rsx! {};
127    }
128
129    let size_class = match props.size {
130        ModalSize::Sm => " modal-sm",
131        ModalSize::Default => "",
132        ModalSize::Lg => " modal-lg",
133        ModalSize::Xl => " modal-xl",
134    };
135
136    let centered = if props.centered {
137        " modal-dialog-centered"
138    } else {
139        ""
140    };
141
142    let scrollable = if props.scrollable {
143        " modal-dialog-scrollable"
144    } else {
145        ""
146    };
147
148    let fullscreen = match props.fullscreen {
149        ModalFullscreen::Off => "",
150        ModalFullscreen::Always => " modal-fullscreen",
151        ModalFullscreen::SmDown => " modal-fullscreen-sm-down",
152        ModalFullscreen::MdDown => " modal-fullscreen-md-down",
153        ModalFullscreen::LgDown => " modal-fullscreen-lg-down",
154        ModalFullscreen::XlDown => " modal-fullscreen-xl-down",
155        ModalFullscreen::XxlDown => " modal-fullscreen-xxl-down",
156    };
157
158    let dialog_class = if props.class.is_empty() {
159        format!("modal-dialog{size_class}{centered}{scrollable}{fullscreen}")
160    } else {
161        format!(
162            "modal-dialog{size_class}{centered}{scrollable}{fullscreen} {}",
163            props.class
164        )
165    };
166
167    let backdrop_close = props.backdrop_close;
168
169    rsx! {
170        // Backdrop
171        div {
172            class: "modal-backdrop fade show",
173            onclick: move |_| {
174                if backdrop_close {
175                    show_signal.set(false);
176                }
177            },
178        }
179        // Modal
180        div {
181            class: "modal fade show",
182            style: "display: block;",
183            tabindex: "-1",
184            role: "dialog",
185            "aria-modal": "true",
186            onclick: move |_| {
187                if backdrop_close {
188                    show_signal.set(false);
189                }
190            },
191            ..props.attributes,
192            div {
193                class: "{dialog_class}",
194                // Stop click propagation so clicking inside the modal doesn't close it
195                onclick: move |evt| evt.stop_propagation(),
196                div { class: "modal-content",
197                    // Header
198                    if !props.title.is_empty() || props.show_close {
199                        div { class: "modal-header",
200                            if !props.title.is_empty() {
201                                h5 { class: "modal-title", "{props.title}" }
202                            }
203                            if props.show_close {
204                                button {
205                                    class: "btn-close",
206                                    r#type: "button",
207                                    "aria-label": "Close",
208                                    onclick: move |_| show_signal.set(false),
209                                }
210                            }
211                        }
212                    }
213                    // Body
214                    if let Some(body) = props.body {
215                        div { class: "modal-body", {body} }
216                    }
217                    {props.children}
218                    // Footer
219                    if let Some(footer) = props.footer {
220                        div { class: "modal-footer", {footer} }
221                    }
222                }
223            }
224        }
225    }
226}