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}