Skip to main content

lbc/components/
menu.rs

1use leptos::prelude::{
2    Children, ClassAttribute, CustomAttribute, ElementChild, Get, IntoView, Signal, component, view,
3};
4
5use crate::util::TestAttr;
6
7fn base_class(root: &str, extra: &str) -> String {
8    if extra.trim().is_empty() {
9        root.to_string()
10    } else {
11        format!("{root} {extra}")
12    }
13}
14
15/// A simple menu, for any type of vertical navigation.
16/// https://bulma.io/documentation/components/menu/
17#[component]
18pub fn Menu(
19    /// Extra classes to apply to the Bulma "menu" container.
20    #[prop(optional, into)]
21    classes: Signal<String>,
22
23    /// Optional test attribute (renders as data-* attribute) on the root <aside>.
24    ///
25    /// When provided as a &str or String, this becomes `data-testid="value"`.
26    /// You can also pass a full `TestAttr` to override the attribute key (e.g., `data-cy`).
27    #[prop(optional, into)]
28    test_attr: Option<TestAttr>,
29
30    /// Child content of the menu (MenuLabel, MenuList, etc.).
31    children: Children,
32) -> impl IntoView {
33    let class = {
34        let classes = classes.clone();
35        move || base_class("menu", &classes.get())
36    };
37
38    let (data_testid, data_cy) = match &test_attr {
39        Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None),
40        Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())),
41        _ => (None, None),
42    };
43
44    view! {
45        <aside
46            class=class
47            attr:data-testid=move || data_testid.clone()
48            attr:data-cy=move || data_cy.clone()
49        >
50            {children()}
51        </aside>
52    }
53}
54
55/// A container for menu list `li` elements.
56/// https://bulma.io/documentation/components/menu/
57#[component]
58pub fn MenuList(
59    /// The child `li` elements of this list.
60    children: Children,
61
62    /// Extra classes for the "menu-list" container.
63    #[prop(optional, into)]
64    classes: Signal<String>,
65
66    /// Optional test attribute (renders as data-* attribute) on the <ul>.
67    ///
68    /// When provided as a &str or String, this becomes `data-testid="value"`.
69    /// You can also pass a full `TestAttr` to override the attribute key.
70    #[prop(optional, into)]
71    test_attr: Option<TestAttr>,
72) -> impl IntoView {
73    let class = {
74        let classes = classes.clone();
75        move || base_class("menu-list", &classes.get())
76    };
77
78    let (data_testid, data_cy) = match &test_attr {
79        Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None),
80        Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())),
81        _ => (None, None),
82    };
83
84    view! {
85        <ul
86            class=class
87            attr:data-testid=move || data_testid.clone()
88            attr:data-cy=move || data_cy.clone()
89        >
90            {children()}
91        </ul>
92    }
93}
94
95/// A label for a section of the menu.
96/// https://bulma.io/documentation/components/menu/
97#[component]
98pub fn MenuLabel(
99    /// Extra classes for the "menu-label" element.
100    #[prop(optional, into)]
101    classes: Signal<String>,
102
103    /// The text of the label.
104    #[prop(optional, into)]
105    text: Signal<String>,
106
107    /// Optional test attribute (renders as data-* attribute) on the <p>.
108    ///
109    /// When provided as a &str or String, this becomes `data-testid="value"`.
110    /// You can also pass a full `TestAttr` to override the attribute key.
111    #[prop(optional, into)]
112    test_attr: Option<TestAttr>,
113) -> impl IntoView {
114    let class = {
115        let classes = classes.clone();
116        move || base_class("menu-label", &classes.get())
117    };
118
119    let (data_testid, data_cy) = match &test_attr {
120        Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None),
121        Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())),
122        _ => (None, None),
123    };
124
125    view! {
126        <p
127            class=class
128            attr:data-testid=move || data_testid.clone()
129            attr:data-cy=move || data_cy.clone()
130        >
131            {text.get()}
132        </p>
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use leptos::prelude::RenderHtml;
140
141    #[test]
142    fn menu_renders_base_class_and_children() {
143        let html = view! { <Menu><div>"X"</div></Menu> }.to_html();
144        assert!(
145            html.contains(r#"class="menu""#),
146            "expected base 'menu' class; got: {}",
147            html
148        );
149        assert!(
150            html.contains("X"),
151            "expected children rendered; got: {}",
152            html
153        );
154    }
155
156    #[test]
157    fn menu_list_renders_container() {
158        let html = view! { <MenuList><li><a>"Item"</a></li></MenuList> }.to_html();
159        assert!(
160            html.contains(r#"class="menu-list""#),
161            "expected 'menu-list' class; got: {}",
162            html
163        );
164        assert!(html.contains("Item"), "expected list child; got: {}", html);
165    }
166
167    #[test]
168    fn menu_label_renders_text() {
169        let html = view! { <MenuLabel text="General" /> }.to_html();
170        assert!(
171            html.contains(r#"class="menu-label""#),
172            "expected 'menu-label' class; got: {}",
173            html
174        );
175        assert!(
176            html.contains("General"),
177            "expected label text; got: {}",
178            html
179        );
180    }
181}
182
183#[cfg(all(test, target_arch = "wasm32"))]
184mod wasm_tests {
185    use super::*;
186    use crate::util::TestAttr;
187    use leptos::prelude::*;
188    use wasm_bindgen_test::*;
189
190    wasm_bindgen_test_configure!(run_in_browser);
191
192    #[wasm_bindgen_test]
193    fn menu_renders_test_attr_as_data_testid() {
194        let html = view! {
195            <Menu classes="extra" test_attr="menu-test">
196                <div>"X"</div>
197            </Menu>
198        }
199        .to_html();
200
201        assert!(
202            html.contains(r#"data-testid="menu-test""#),
203            "expected data-testid attribute on Menu; got: {}",
204            html
205        );
206    }
207
208    #[wasm_bindgen_test]
209    fn menu_no_test_attr_when_not_provided() {
210        let html = view! {
211            <Menu>
212                <div>"X"</div>
213            </Menu>
214        }
215        .to_html();
216
217        assert!(
218            !html.contains("data-testid") && !html.contains("data-cy"),
219            "expected no test attribute on Menu when not provided; got: {}",
220            html
221        );
222    }
223
224    #[wasm_bindgen_test]
225    fn menu_list_renders_test_attr_as_data_testid() {
226        let html = view! {
227            <MenuList classes="extra" test_attr="menu-list-test">
228                <li><a>"Item"</a></li>
229            </MenuList>
230        }
231        .to_html();
232
233        assert!(
234            html.contains(r#"data-testid="menu-list-test""#),
235            "expected data-testid attribute on MenuList; got: {}",
236            html
237        );
238    }
239
240    #[wasm_bindgen_test]
241    fn menu_label_renders_test_attr_as_data_testid() {
242        let html = view! {
243            <MenuLabel text="General" test_attr="menu-label-test" />
244        }
245        .to_html();
246
247        assert!(
248            html.contains(r#"data-testid="menu-label-test""#),
249            "expected data-testid attribute on MenuLabel; got: {}",
250            html
251        );
252    }
253
254    #[wasm_bindgen_test]
255    fn menu_accepts_custom_test_attr_key() {
256        let html = view! {
257            <Menu
258                classes="extra"
259                test_attr=TestAttr::new("data-cy", "menu-cy")
260            >
261                <div>"X"</div>
262            </Menu>
263        }
264        .to_html();
265
266        assert!(
267            html.contains(r#"data-cy="menu-cy""#),
268            "expected custom data-cy attribute on Menu; got: {}",
269            html
270        );
271    }
272}