ironhtml_bootstrap/
offcanvas.rs

1//! Bootstrap offcanvas components.
2//!
3//! Provides type-safe Bootstrap offcanvas matching the
4//! [Bootstrap offcanvas documentation](https://getbootstrap.com/docs/5.3/components/offcanvas/).
5
6use ironhtml::typed::Element;
7use ironhtml_elements::{Button, Div, A, H5};
8
9extern crate alloc;
10use alloc::format;
11
12/// Offcanvas placement options.
13#[derive(Clone, Copy, Default)]
14pub enum OffcanvasPlacement {
15    #[default]
16    Start,
17    End,
18    Top,
19    Bottom,
20}
21
22impl OffcanvasPlacement {
23    const fn class(self) -> &'static str {
24        match self {
25            Self::Start => "offcanvas-start",
26            Self::End => "offcanvas-end",
27            Self::Top => "offcanvas-top",
28            Self::Bottom => "offcanvas-bottom",
29        }
30    }
31}
32
33/// Create a trigger button for an offcanvas.
34///
35/// ## Example
36///
37/// ```rust
38/// use ironhtml_bootstrap::offcanvas::offcanvas_button;
39/// use ironhtml_bootstrap::Color;
40///
41/// let btn = offcanvas_button("offcanvasExample", Color::Primary, "Toggle sidebar");
42/// assert!(btn.render().contains("data-bs-toggle"));
43/// ```
44#[must_use]
45pub fn offcanvas_button(target_id: &str, color: crate::Color, text: &str) -> Element<Button> {
46    let class = format!("btn btn-{}", color.as_str());
47    Element::<Button>::new()
48        .class(&class)
49        .attr("type", "button")
50        .attr("data-bs-toggle", "offcanvas")
51        .attr("data-bs-target", format!("#{target_id}"))
52        .attr("aria-controls", target_id)
53        .text(text)
54}
55
56/// Create a trigger link for an offcanvas.
57#[must_use]
58pub fn offcanvas_link(target_id: &str, text: &str) -> Element<A> {
59    Element::<A>::new()
60        .class("btn btn-primary")
61        .attr("data-bs-toggle", "offcanvas")
62        .attr("href", format!("#{target_id}"))
63        .attr("role", "button")
64        .attr("aria-controls", target_id)
65        .text(text)
66}
67
68/// Create a Bootstrap offcanvas.
69///
70/// ## Example
71///
72/// ```rust
73/// use ironhtml_bootstrap::offcanvas::{offcanvas, OffcanvasPlacement};
74///
75/// let oc = offcanvas("sidebar", OffcanvasPlacement::Start, "Sidebar", |body| {
76///     body.text("Sidebar content here")
77/// });
78/// assert!(oc.render().contains("offcanvas"));
79/// ```
80#[must_use]
81pub fn offcanvas<F>(
82    id: &str,
83    placement: OffcanvasPlacement,
84    title: &str,
85    body_fn: F,
86) -> Element<Div>
87where
88    F: FnOnce(Element<Div>) -> Element<Div>,
89{
90    let class = format!("offcanvas {}", placement.class());
91
92    Element::<Div>::new()
93        .class(&class)
94        .attr("tabindex", "-1")
95        .attr("id", id)
96        .attr("aria-labelledby", format!("{id}Label"))
97        // Header
98        .child::<Div, _>(|header| {
99            header
100                .class("offcanvas-header")
101                .child::<H5, _>(|h| {
102                    h.class("offcanvas-title")
103                        .attr("id", format!("{id}Label"))
104                        .text(title)
105                })
106                .child::<Button, _>(|b| {
107                    b.attr("type", "button")
108                        .class("btn-close")
109                        .attr("data-bs-dismiss", "offcanvas")
110                        .attr("aria-label", "Close")
111                })
112        })
113        // Body
114        .child::<Div, _>(|body| body_fn(body.class("offcanvas-body")))
115}
116
117/// Create an offcanvas with static backdrop (doesn't close on outside click).
118#[must_use]
119pub fn offcanvas_static<F>(
120    id: &str,
121    placement: OffcanvasPlacement,
122    title: &str,
123    body_fn: F,
124) -> Element<Div>
125where
126    F: FnOnce(Element<Div>) -> Element<Div>,
127{
128    let class = format!("offcanvas {}", placement.class());
129
130    Element::<Div>::new()
131        .class(&class)
132        .attr("data-bs-backdrop", "static")
133        .attr("tabindex", "-1")
134        .attr("id", id)
135        .attr("aria-labelledby", format!("{id}Label"))
136        .child::<Div, _>(|header| {
137            header
138                .class("offcanvas-header")
139                .child::<H5, _>(|h| {
140                    h.class("offcanvas-title")
141                        .attr("id", format!("{id}Label"))
142                        .text(title)
143                })
144                .child::<Button, _>(|b| {
145                    b.attr("type", "button")
146                        .class("btn-close")
147                        .attr("data-bs-dismiss", "offcanvas")
148                        .attr("aria-label", "Close")
149                })
150        })
151        .child::<Div, _>(|body| body_fn(body.class("offcanvas-body")))
152}
153
154/// Create an offcanvas with body scrolling enabled.
155#[must_use]
156pub fn offcanvas_scroll<F>(
157    id: &str,
158    placement: OffcanvasPlacement,
159    title: &str,
160    body_fn: F,
161) -> Element<Div>
162where
163    F: FnOnce(Element<Div>) -> Element<Div>,
164{
165    let class = format!("offcanvas {}", placement.class());
166
167    Element::<Div>::new()
168        .class(&class)
169        .attr("data-bs-scroll", "true")
170        .attr("data-bs-backdrop", "false")
171        .attr("tabindex", "-1")
172        .attr("id", id)
173        .attr("aria-labelledby", format!("{id}Label"))
174        .child::<Div, _>(|header| {
175            header
176                .class("offcanvas-header")
177                .child::<H5, _>(|h| {
178                    h.class("offcanvas-title")
179                        .attr("id", format!("{id}Label"))
180                        .text(title)
181                })
182                .child::<Button, _>(|b| {
183                    b.attr("type", "button")
184                        .class("btn-close")
185                        .attr("data-bs-dismiss", "offcanvas")
186                        .attr("aria-label", "Close")
187                })
188        })
189        .child::<Div, _>(|body| body_fn(body.class("offcanvas-body")))
190}
191
192/// Create a dark-themed offcanvas.
193#[must_use]
194pub fn offcanvas_dark<F>(
195    id: &str,
196    placement: OffcanvasPlacement,
197    title: &str,
198    body_fn: F,
199) -> Element<Div>
200where
201    F: FnOnce(Element<Div>) -> Element<Div>,
202{
203    let class = format!("offcanvas {} text-bg-dark", placement.class());
204
205    Element::<Div>::new()
206        .class(&class)
207        .attr("tabindex", "-1")
208        .attr("id", id)
209        .attr("aria-labelledby", format!("{id}Label"))
210        .child::<Div, _>(|header| {
211            header
212                .class("offcanvas-header")
213                .child::<H5, _>(|h| {
214                    h.class("offcanvas-title")
215                        .attr("id", format!("{id}Label"))
216                        .text(title)
217                })
218                .child::<Button, _>(|b| {
219                    b.attr("type", "button")
220                        .class("btn-close btn-close-white")
221                        .attr("data-bs-dismiss", "offcanvas")
222                        .attr("aria-label", "Close")
223                })
224        })
225        .child::<Div, _>(|body| body_fn(body.class("offcanvas-body")))
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_offcanvas() {
234        let oc = offcanvas("test", OffcanvasPlacement::Start, "Title", |b| {
235            b.text("Content")
236        });
237        let html = oc.render();
238        assert!(html.contains("offcanvas"));
239        assert!(html.contains("offcanvas-start"));
240        assert!(html.contains("offcanvas-header"));
241        assert!(html.contains("offcanvas-body"));
242    }
243
244    #[test]
245    fn test_offcanvas_placements() {
246        let end = offcanvas("e", OffcanvasPlacement::End, "T", |b| b);
247        assert!(end.render().contains("offcanvas-end"));
248
249        let top = offcanvas("t", OffcanvasPlacement::Top, "T", |b| b);
250        assert!(top.render().contains("offcanvas-top"));
251
252        let bottom = offcanvas("b", OffcanvasPlacement::Bottom, "T", |b| b);
253        assert!(bottom.render().contains("offcanvas-bottom"));
254    }
255
256    #[test]
257    fn test_offcanvas_button() {
258        let btn = offcanvas_button("sidebar", crate::Color::Primary, "Open");
259        let html = btn.render();
260        assert!(html.contains(r#"data-bs-toggle="offcanvas"#));
261        assert!(html.contains(r##"data-bs-target="#sidebar""##));
262    }
263}