ironhtml_bootstrap/
dropdown.rs

1//! Bootstrap dropdown components.
2//!
3//! Provides type-safe Bootstrap dropdowns matching the
4//! [Bootstrap dropdown documentation](https://getbootstrap.com/docs/5.3/components/dropdowns/).
5
6use crate::Color;
7use ironhtml::typed::Element;
8use ironhtml_elements::{Button, Div, Hr, Li, Span, Ul, A};
9
10extern crate alloc;
11use alloc::format;
12use alloc::string::String;
13
14/// A dropdown menu item.
15pub enum DropdownItem {
16    /// A clickable link item.
17    Link { text: String, href: String },
18    /// An active (highlighted) link item.
19    Active { text: String, href: String },
20    /// A disabled link item.
21    Disabled { text: String, href: String },
22    /// A divider line.
23    Divider,
24    /// A non-interactive header.
25    Header(String),
26    /// Plain text (non-interactive).
27    Text(String),
28}
29
30impl DropdownItem {
31    /// Create a link item.
32    #[must_use]
33    pub fn link(text: impl Into<String>, href: impl Into<String>) -> Self {
34        Self::Link {
35            text: text.into(),
36            href: href.into(),
37        }
38    }
39
40    /// Create an active link item.
41    #[must_use]
42    pub fn active(text: impl Into<String>, href: impl Into<String>) -> Self {
43        Self::Active {
44            text: text.into(),
45            href: href.into(),
46        }
47    }
48
49    /// Create a disabled link item.
50    #[must_use]
51    pub fn disabled(text: impl Into<String>, href: impl Into<String>) -> Self {
52        Self::Disabled {
53            text: text.into(),
54            href: href.into(),
55        }
56    }
57
58    /// Create a divider.
59    #[must_use]
60    pub const fn divider() -> Self {
61        Self::Divider
62    }
63
64    /// Create a header.
65    #[must_use]
66    pub fn header(text: impl Into<String>) -> Self {
67        Self::Header(text.into())
68    }
69
70    /// Create plain text.
71    #[must_use]
72    pub fn text(text: impl Into<String>) -> Self {
73        Self::Text(text.into())
74    }
75}
76
77/// Create a Bootstrap dropdown.
78///
79/// ## Example
80///
81/// ```rust
82/// use ironhtml_bootstrap::dropdown::{dropdown, DropdownItem};
83/// use ironhtml_bootstrap::Color;
84///
85/// let items = vec![
86///     DropdownItem::link("Action", "#"),
87///     DropdownItem::link("Another action", "#"),
88///     DropdownItem::divider(),
89///     DropdownItem::link("Separated link", "#"),
90/// ];
91///
92/// let dd = dropdown(Color::Primary, "Dropdown button", &items);
93/// assert!(dd.render().contains("dropdown"));
94/// ```
95#[must_use]
96pub fn dropdown(color: Color, label: &str, items: &[DropdownItem]) -> Element<Div> {
97    let btn_class = format!("btn btn-{} dropdown-toggle", color.as_str());
98
99    Element::<Div>::new()
100        .class("dropdown")
101        .child::<Button, _>(|b| {
102            b.class(&btn_class)
103                .attr("type", "button")
104                .attr("data-bs-toggle", "dropdown")
105                .attr("aria-expanded", "false")
106                .text(label)
107        })
108        .child::<Ul, _>(|ul| dropdown_menu(ul, items))
109}
110
111/// Create a dropdown with a split button.
112#[must_use]
113pub fn dropdown_split(
114    color: Color,
115    label: &str,
116    href: &str,
117    items: &[DropdownItem],
118) -> Element<Div> {
119    let btn_class = format!("btn btn-{}", color.as_str());
120    let toggle_class = format!(
121        "btn btn-{} dropdown-toggle dropdown-toggle-split",
122        color.as_str()
123    );
124
125    Element::<Div>::new()
126        .class("btn-group")
127        .child::<A, _>(|a| a.class(&btn_class).attr("href", href).text(label))
128        .child::<Button, _>(|b| {
129            b.class(&toggle_class)
130                .attr("type", "button")
131                .attr("data-bs-toggle", "dropdown")
132                .attr("aria-expanded", "false")
133                .child::<Span, _>(|s| s.class("visually-hidden").text("Toggle Dropdown"))
134        })
135        .child::<Ul, _>(|ul| dropdown_menu(ul, items))
136}
137
138/// Create a dropup (opens upward).
139#[must_use]
140pub fn dropup(color: Color, label: &str, items: &[DropdownItem]) -> Element<Div> {
141    let btn_class = format!("btn btn-{} dropdown-toggle", color.as_str());
142
143    Element::<Div>::new()
144        .class("btn-group dropup")
145        .child::<Button, _>(|b| {
146            b.class(&btn_class)
147                .attr("type", "button")
148                .attr("data-bs-toggle", "dropdown")
149                .attr("aria-expanded", "false")
150                .text(label)
151        })
152        .child::<Ul, _>(|ul| dropdown_menu(ul, items))
153}
154
155/// Create a dropstart (opens to the left).
156#[must_use]
157pub fn dropstart(color: Color, label: &str, items: &[DropdownItem]) -> Element<Div> {
158    let btn_class = format!("btn btn-{} dropdown-toggle", color.as_str());
159
160    Element::<Div>::new()
161        .class("btn-group dropstart")
162        .child::<Button, _>(|b| {
163            b.class(&btn_class)
164                .attr("type", "button")
165                .attr("data-bs-toggle", "dropdown")
166                .attr("aria-expanded", "false")
167                .text(label)
168        })
169        .child::<Ul, _>(|ul| dropdown_menu(ul, items))
170}
171
172/// Create a dropend (opens to the right).
173#[must_use]
174pub fn dropend(color: Color, label: &str, items: &[DropdownItem]) -> Element<Div> {
175    let btn_class = format!("btn btn-{} dropdown-toggle", color.as_str());
176
177    Element::<Div>::new()
178        .class("btn-group dropend")
179        .child::<Button, _>(|b| {
180            b.class(&btn_class)
181                .attr("type", "button")
182                .attr("data-bs-toggle", "dropdown")
183                .attr("aria-expanded", "false")
184                .text(label)
185        })
186        .child::<Ul, _>(|ul| dropdown_menu(ul, items))
187}
188
189/// Create a dark dropdown menu.
190#[must_use]
191pub fn dropdown_dark(color: Color, label: &str, items: &[DropdownItem]) -> Element<Div> {
192    let btn_class = format!("btn btn-{} dropdown-toggle", color.as_str());
193
194    Element::<Div>::new()
195        .class("dropdown")
196        .child::<Button, _>(|b| {
197            b.class(&btn_class)
198                .attr("type", "button")
199                .attr("data-bs-toggle", "dropdown")
200                .attr("aria-expanded", "false")
201                .text(label)
202        })
203        .child::<Ul, _>(|ul| dropdown_menu_dark(ul, items))
204}
205
206/// Build a dropdown menu element.
207fn dropdown_menu(ul: Element<Ul>, items: &[DropdownItem]) -> Element<Ul> {
208    items
209        .iter()
210        .fold(ul.class("dropdown-menu"), |ul, item| match item {
211            DropdownItem::Link { text, href } => ul.child::<Li, _>(|li| {
212                li.child::<A, _>(|a| a.class("dropdown-item").attr("href", href).text(text))
213            }),
214            DropdownItem::Active { text, href } => ul.child::<Li, _>(|li| {
215                li.child::<A, _>(|a| {
216                    a.class("dropdown-item active")
217                        .attr("href", href)
218                        .attr("aria-current", "true")
219                        .text(text)
220                })
221            }),
222            DropdownItem::Disabled { text, href } => ul.child::<Li, _>(|li| {
223                li.child::<A, _>(|a| {
224                    a.class("dropdown-item disabled")
225                        .attr("href", href)
226                        .attr("aria-disabled", "true")
227                        .text(text)
228                })
229            }),
230            DropdownItem::Divider => {
231                ul.child::<Li, _>(|li| li.child::<Hr, _>(|hr| hr.class("dropdown-divider")))
232            }
233            DropdownItem::Header(text) => {
234                ul.child::<Li, _>(|li| li.child::<H6, _>(|h| h.class("dropdown-header").text(text)))
235            }
236            DropdownItem::Text(text) => ul.child::<Li, _>(|li| {
237                li.child::<Span, _>(|s| s.class("dropdown-item-text").text(text))
238            }),
239        })
240}
241
242use ironhtml_elements::H6;
243
244/// Build a dark dropdown menu element.
245fn dropdown_menu_dark(ul: Element<Ul>, items: &[DropdownItem]) -> Element<Ul> {
246    items.iter().fold(
247        ul.class("dropdown-menu dropdown-menu-dark"),
248        |ul, item| match item {
249            DropdownItem::Link { text, href } => ul.child::<Li, _>(|li| {
250                li.child::<A, _>(|a| a.class("dropdown-item").attr("href", href).text(text))
251            }),
252            DropdownItem::Active { text, href } => ul.child::<Li, _>(|li| {
253                li.child::<A, _>(|a| {
254                    a.class("dropdown-item active")
255                        .attr("href", href)
256                        .attr("aria-current", "true")
257                        .text(text)
258                })
259            }),
260            DropdownItem::Disabled { text, href } => ul.child::<Li, _>(|li| {
261                li.child::<A, _>(|a| {
262                    a.class("dropdown-item disabled")
263                        .attr("href", href)
264                        .attr("aria-disabled", "true")
265                        .text(text)
266                })
267            }),
268            DropdownItem::Divider => {
269                ul.child::<Li, _>(|li| li.child::<Hr, _>(|hr| hr.class("dropdown-divider")))
270            }
271            DropdownItem::Header(text) => {
272                ul.child::<Li, _>(|li| li.child::<H6, _>(|h| h.class("dropdown-header").text(text)))
273            }
274            DropdownItem::Text(text) => ul.child::<Li, _>(|li| {
275                li.child::<Span, _>(|s| s.class("dropdown-item-text").text(text))
276            }),
277        },
278    )
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use alloc::vec;
285
286    #[test]
287    fn test_dropdown() {
288        let items = vec![
289            DropdownItem::link("Action", "#"),
290            DropdownItem::divider(),
291            DropdownItem::link("Another", "#"),
292        ];
293        let dd = dropdown(Color::Primary, "Menu", &items);
294        let html = dd.render();
295        assert!(html.contains("dropdown"));
296        assert!(html.contains("dropdown-toggle"));
297        assert!(html.contains("dropdown-menu"));
298        assert!(html.contains("dropdown-item"));
299    }
300
301    #[test]
302    fn test_dropdown_split() {
303        let items = vec![DropdownItem::link("Action", "#")];
304        let dd = dropdown_split(Color::Success, "Action", "#", &items);
305        let html = dd.render();
306        assert!(html.contains("btn-group"));
307        assert!(html.contains("dropdown-toggle-split"));
308    }
309
310    #[test]
311    fn test_dropup() {
312        let items = vec![DropdownItem::link("Action", "#")];
313        let dd = dropup(Color::Primary, "Dropup", &items);
314        assert!(dd.render().contains("dropup"));
315    }
316
317    #[test]
318    fn test_dropdown_with_header() {
319        let items = vec![
320            DropdownItem::header("Dropdown header"),
321            DropdownItem::link("Action", "#"),
322        ];
323        let dd = dropdown(Color::Secondary, "Menu", &items);
324        assert!(dd.render().contains("dropdown-header"));
325    }
326}