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}