ironhtml_bootstrap/
modal.rs

1//! Bootstrap modal components.
2//!
3//! Provides type-safe Bootstrap modals matching the
4//! [Bootstrap modal documentation](https://getbootstrap.com/docs/5.3/components/modal/).
5
6use ironhtml::typed::Element;
7use ironhtml_elements::{Button, Div, H1, H5};
8
9extern crate alloc;
10use alloc::format;
11use alloc::string::ToString;
12
13/// Modal size options.
14#[derive(Clone, Copy, Default)]
15pub enum ModalSize {
16    Small,
17    #[default]
18    Default,
19    Large,
20    ExtraLarge,
21    Fullscreen,
22}
23
24impl ModalSize {
25    const fn class(self) -> &'static str {
26        match self {
27            Self::Small => "modal-sm",
28            Self::Default => "",
29            Self::Large => "modal-lg",
30            Self::ExtraLarge => "modal-xl",
31            Self::Fullscreen => "modal-fullscreen",
32        }
33    }
34}
35
36/// Create a trigger button for a modal.
37///
38/// ## Example
39///
40/// ```rust
41/// use ironhtml_bootstrap::modal::modal_button;
42/// use ironhtml_bootstrap::Color;
43///
44/// let btn = modal_button("myModal", Color::Primary, "Launch modal");
45/// assert!(btn.render().contains("data-bs-toggle"));
46/// ```
47#[must_use]
48pub fn modal_button(target_id: &str, color: crate::Color, text: &str) -> Element<Button> {
49    let class = format!("btn btn-{}", color.as_str());
50    Element::<Button>::new()
51        .attr("type", "button")
52        .class(&class)
53        .attr("data-bs-toggle", "modal")
54        .attr("data-bs-target", format!("#{target_id}"))
55        .text(text)
56}
57
58/// Create a basic modal structure.
59///
60/// ## Example
61///
62/// ```rust
63/// use ironhtml_bootstrap::modal::{modal, ModalSize};
64///
65/// let m = modal("myModal", ModalSize::Default, "Modal Title", |body| {
66///     body.text("Modal content goes here.")
67/// });
68/// assert!(m.render().contains("modal"));
69/// ```
70#[must_use]
71pub fn modal<F>(id: &str, size: ModalSize, title: &str, body_fn: F) -> Element<Div>
72where
73    F: FnOnce(Element<Div>) -> Element<Div>,
74{
75    let dialog_class = if size.class().is_empty() {
76        "modal-dialog".to_string()
77    } else {
78        format!("modal-dialog {}", size.class())
79    };
80
81    Element::<Div>::new()
82        .class("modal fade")
83        .attr("id", id)
84        .attr("tabindex", "-1")
85        .attr("aria-labelledby", format!("{id}Label"))
86        .attr("aria-hidden", "true")
87        .child::<Div, _>(|d| {
88            d.class(&dialog_class).child::<Div, _>(|content| {
89                content
90                    .class("modal-content")
91                    // Header
92                    .child::<Div, _>(|header| {
93                        header
94                            .class("modal-header")
95                            .child::<H1, _>(|h| {
96                                h.class("modal-title fs-5")
97                                    .attr("id", format!("{id}Label"))
98                                    .text(title)
99                            })
100                            .child::<Button, _>(|b| {
101                                b.attr("type", "button")
102                                    .class("btn-close")
103                                    .attr("data-bs-dismiss", "modal")
104                                    .attr("aria-label", "Close")
105                            })
106                    })
107                    // Body
108                    .child::<Div, _>(|body| body_fn(body.class("modal-body")))
109            })
110        })
111}
112
113/// Create a modal with footer buttons.
114#[must_use]
115pub fn modal_with_footer<F>(
116    id: &str,
117    size: ModalSize,
118    title: &str,
119    body_fn: F,
120    primary_btn_text: &str,
121) -> Element<Div>
122where
123    F: FnOnce(Element<Div>) -> Element<Div>,
124{
125    let dialog_class = if size.class().is_empty() {
126        "modal-dialog".to_string()
127    } else {
128        format!("modal-dialog {}", size.class())
129    };
130
131    Element::<Div>::new()
132        .class("modal fade")
133        .attr("id", id)
134        .attr("tabindex", "-1")
135        .attr("aria-labelledby", format!("{id}Label"))
136        .attr("aria-hidden", "true")
137        .child::<Div, _>(|d| {
138            d.class(&dialog_class).child::<Div, _>(|content| {
139                content
140                    .class("modal-content")
141                    // Header
142                    .child::<Div, _>(|header| {
143                        header
144                            .class("modal-header")
145                            .child::<H5, _>(|h| {
146                                h.class("modal-title")
147                                    .attr("id", format!("{id}Label"))
148                                    .text(title)
149                            })
150                            .child::<Button, _>(|b| {
151                                b.attr("type", "button")
152                                    .class("btn-close")
153                                    .attr("data-bs-dismiss", "modal")
154                                    .attr("aria-label", "Close")
155                            })
156                    })
157                    // Body
158                    .child::<Div, _>(|body| body_fn(body.class("modal-body")))
159                    // Footer
160                    .child::<Div, _>(|footer| {
161                        footer
162                            .class("modal-footer")
163                            .child::<Button, _>(|b| {
164                                b.attr("type", "button")
165                                    .class("btn btn-secondary")
166                                    .attr("data-bs-dismiss", "modal")
167                                    .text("Close")
168                            })
169                            .child::<Button, _>(|b| {
170                                b.attr("type", "button")
171                                    .class("btn btn-primary")
172                                    .text(primary_btn_text)
173                            })
174                    })
175            })
176        })
177}
178
179/// Create a scrollable modal.
180#[must_use]
181pub fn modal_scrollable<F>(id: &str, title: &str, body_fn: F) -> Element<Div>
182where
183    F: FnOnce(Element<Div>) -> Element<Div>,
184{
185    Element::<Div>::new()
186        .class("modal fade")
187        .attr("id", id)
188        .attr("tabindex", "-1")
189        .attr("aria-labelledby", format!("{id}Label"))
190        .attr("aria-hidden", "true")
191        .child::<Div, _>(|d| {
192            d.class("modal-dialog modal-dialog-scrollable")
193                .child::<Div, _>(|content| {
194                    content
195                        .class("modal-content")
196                        .child::<Div, _>(|header| {
197                            header
198                                .class("modal-header")
199                                .child::<H5, _>(|h| {
200                                    h.class("modal-title")
201                                        .attr("id", format!("{id}Label"))
202                                        .text(title)
203                                })
204                                .child::<Button, _>(|b| {
205                                    b.attr("type", "button")
206                                        .class("btn-close")
207                                        .attr("data-bs-dismiss", "modal")
208                                        .attr("aria-label", "Close")
209                                })
210                        })
211                        .child::<Div, _>(|body| body_fn(body.class("modal-body")))
212                })
213        })
214}
215
216/// Create a vertically centered modal.
217#[must_use]
218pub fn modal_centered<F>(id: &str, title: &str, body_fn: F) -> Element<Div>
219where
220    F: FnOnce(Element<Div>) -> Element<Div>,
221{
222    Element::<Div>::new()
223        .class("modal fade")
224        .attr("id", id)
225        .attr("tabindex", "-1")
226        .attr("aria-labelledby", format!("{id}Label"))
227        .attr("aria-hidden", "true")
228        .child::<Div, _>(|d| {
229            d.class("modal-dialog modal-dialog-centered")
230                .child::<Div, _>(|content| {
231                    content
232                        .class("modal-content")
233                        .child::<Div, _>(|header| {
234                            header
235                                .class("modal-header")
236                                .child::<H5, _>(|h| {
237                                    h.class("modal-title")
238                                        .attr("id", format!("{id}Label"))
239                                        .text(title)
240                                })
241                                .child::<Button, _>(|b| {
242                                    b.attr("type", "button")
243                                        .class("btn-close")
244                                        .attr("data-bs-dismiss", "modal")
245                                        .attr("aria-label", "Close")
246                                })
247                        })
248                        .child::<Div, _>(|body| body_fn(body.class("modal-body")))
249                })
250        })
251}
252
253/// Create a static backdrop modal (won't close when clicking outside).
254#[must_use]
255pub fn modal_static<F>(id: &str, title: &str, body_fn: F) -> Element<Div>
256where
257    F: FnOnce(Element<Div>) -> Element<Div>,
258{
259    Element::<Div>::new()
260        .class("modal fade")
261        .attr("id", id)
262        .attr("data-bs-backdrop", "static")
263        .attr("data-bs-keyboard", "false")
264        .attr("tabindex", "-1")
265        .attr("aria-labelledby", format!("{id}Label"))
266        .attr("aria-hidden", "true")
267        .child::<Div, _>(|d| {
268            d.class("modal-dialog").child::<Div, _>(|content| {
269                content
270                    .class("modal-content")
271                    .child::<Div, _>(|header| {
272                        header
273                            .class("modal-header")
274                            .child::<H5, _>(|h| {
275                                h.class("modal-title")
276                                    .attr("id", format!("{id}Label"))
277                                    .text(title)
278                            })
279                            .child::<Button, _>(|b| {
280                                b.attr("type", "button")
281                                    .class("btn-close")
282                                    .attr("data-bs-dismiss", "modal")
283                                    .attr("aria-label", "Close")
284                            })
285                    })
286                    .child::<Div, _>(|body| body_fn(body.class("modal-body")))
287            })
288        })
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_modal() {
297        let m = modal("test", ModalSize::Default, "Title", |body| {
298            body.text("Content")
299        });
300        let html = m.render();
301        assert!(html.contains("modal fade"));
302        assert!(html.contains("modal-dialog"));
303        assert!(html.contains("modal-content"));
304        assert!(html.contains("modal-header"));
305        assert!(html.contains("modal-body"));
306    }
307
308    #[test]
309    fn test_modal_sizes() {
310        let small = modal("sm", ModalSize::Small, "Small", |b| b);
311        assert!(small.render().contains("modal-sm"));
312
313        let large = modal("lg", ModalSize::Large, "Large", |b| b);
314        assert!(large.render().contains("modal-lg"));
315
316        let xl = modal("xl", ModalSize::ExtraLarge, "XL", |b| b);
317        assert!(xl.render().contains("modal-xl"));
318    }
319
320    #[test]
321    fn test_modal_button() {
322        let btn = modal_button("myModal", crate::Color::Primary, "Open");
323        let html = btn.render();
324        assert!(html.contains(r#"data-bs-toggle="modal""#));
325        assert!(html.contains(r##"data-bs-target="#myModal""##));
326    }
327}