Skip to main content

lbc/components/
breadcrumb.rs

1use crate::components::tabs::Alignment;
2use crate::util::TestAttr;
3use leptos::prelude::{
4    AriaAttributes, Children, ClassAttribute, CustomAttribute, ElementChild, Get, IntoView, Signal,
5    component, view,
6};
7
8/// The 3 sizes available for a breadcrumb.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum BreadcrumbSize {
11    Small,
12    Medium,
13    Large,
14}
15
16impl BreadcrumbSize {
17    fn bulma(self) -> &'static str {
18        match self {
19            BreadcrumbSize::Small => "is-small",
20            BreadcrumbSize::Medium => "is-medium",
21            BreadcrumbSize::Large => "is-large",
22        }
23    }
24}
25
26/// The 4 additional separators for a breadcrumb.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum BreadcrumbSeparator {
29    Arrow,
30    Bullet,
31    Dot,
32    Succeeds,
33}
34
35impl BreadcrumbSeparator {
36    fn bulma(self) -> &'static str {
37        match self {
38            BreadcrumbSeparator::Arrow => "has-arrow-separator",
39            BreadcrumbSeparator::Bullet => "has-bullet-separator",
40            BreadcrumbSeparator::Dot => "has-dot-separator",
41            BreadcrumbSeparator::Succeeds => "has-succeeds-separator",
42        }
43    }
44}
45
46/// A simple breadcrumb component to improve your navigation experience.
47///
48/// https://bulma.io/documentation/components/breadcrumb/
49#[component]
50pub fn Breadcrumb(
51    /// The `li` child elements of this breadcrumb.
52    children: Children,
53
54    /// Extra classes to apply to the root "breadcrumb" container.
55    #[prop(optional, into)]
56    classes: Signal<String>,
57
58    /// The size of this component.
59    #[prop(optional, into)]
60    size: Signal<Option<BreadcrumbSize>>,
61
62    /// The alignment of this component.
63    #[prop(optional, into)]
64    alignment: Signal<Option<Alignment>>,
65
66    /// The separator type to use between breadcrumb segments.
67    #[prop(optional, into)]
68    separator: Signal<Option<BreadcrumbSeparator>>,
69
70    /// Optional test attribute (renders as data-* attribute) on the root <nav>.
71    ///
72    /// When provided as a &str or String, this becomes `data-testid="value"`.
73    /// You can also pass a full `TestAttr` to override the attribute key.
74    #[prop(optional, into)]
75    test_attr: Option<TestAttr>,
76) -> impl IntoView {
77    let class = {
78        let classes = classes.clone();
79        let size = size.clone();
80        let alignment = alignment.clone();
81        let separator = separator.clone();
82        move || {
83            let mut parts = vec!["breadcrumb".to_string()];
84            let extra = classes.get();
85            if !extra.trim().is_empty() {
86                parts.push(extra);
87            }
88            if let Some(sz) = size.get() {
89                parts.push(sz.bulma().to_string());
90            }
91            if let Some(align) = alignment.get() {
92                parts.push(match align {
93                    Alignment::Centered => "is-centered".to_string(),
94                    Alignment::Right => "is-right".to_string(),
95                });
96            }
97            if let Some(sep) = separator.get() {
98                parts.push(sep.bulma().to_string());
99            }
100            parts.join(" ")
101        }
102    };
103
104    let (data_testid, data_cy) = match &test_attr {
105        Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None),
106        Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())),
107        _ => (None, None),
108    };
109
110    view! {
111        <nav
112            class=move || class()
113            aria-label="breadcrumbs"
114            attr:data-testid=move || data_testid.clone()
115            attr:data-cy=move || data_cy.clone()
116        >
117            <ul>
118                {children()}
119            </ul>
120        </nav>
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use leptos::prelude::RenderHtml;
128
129    #[test]
130    fn breadcrumb_renders_base_and_children() {
131        let html = view! {
132            <Breadcrumb>
133                <li><a href="#">"Bulma"</a></li>
134                <li class="is-active"><a href="#" aria-current="page">"Breadcrumb"</a></li>
135            </Breadcrumb>
136        }
137        .to_html();
138
139        assert!(
140            html.contains(r#"class="breadcrumb""#),
141            "expected base 'breadcrumb' class; got: {}",
142            html
143        );
144        assert!(html.contains("<ul"), "expected inner list; got: {}", html);
145        assert!(
146            html.contains("Bulma") && html.contains("Breadcrumb"),
147            "expected children; got: {}",
148            html
149        );
150    }
151
152    #[test]
153    fn breadcrumb_size_alignment_separator_classes() {
154        let html = view! {
155            <Breadcrumb
156                size=leptos::prelude::Signal::derive(|| Some(BreadcrumbSize::Small))
157                alignment=leptos::prelude::Signal::derive(|| Some(Alignment::Right))
158                separator=leptos::prelude::Signal::derive(|| Some(BreadcrumbSeparator::Dot))
159                classes="extra"
160            >
161                <li><a href="#">"A"</a></li>
162                <li class="is-active"><a href="#" aria-current="page">"B"</a></li>
163            </Breadcrumb>
164        }
165        .to_html();
166
167        assert!(
168            html.contains("breadcrumb extra"),
169            "expected extra classes; got: {}",
170            html
171        );
172        assert!(
173            html.contains("is-small"),
174            "expected size class; got: {}",
175            html
176        );
177        assert!(
178            html.contains("is-right"),
179            "expected alignment class; got: {}",
180            html
181        );
182        assert!(
183            html.contains("has-dot-separator"),
184            "expected separator class; got: {}",
185            html
186        );
187    }
188}
189
190#[cfg(all(test, target_arch = "wasm32"))]
191mod wasm_tests {
192    use super::*;
193    use crate::components::tabs::Alignment;
194    use crate::util::TestAttr;
195    use leptos::prelude::*;
196    use wasm_bindgen_test::*;
197
198    wasm_bindgen_test_configure!(run_in_browser);
199
200    #[wasm_bindgen_test]
201    fn breadcrumb_renders_test_attr_as_data_testid() {
202        let html = view! {
203            <Breadcrumb
204                classes="extra"
205                size=Signal::derive(|| Some(BreadcrumbSize::Small))
206                alignment=Signal::derive(|| Some(Alignment::Centered))
207                separator=Signal::derive(|| Some(BreadcrumbSeparator::Arrow))
208                test_attr="breadcrumb-test"
209            >
210                <li><a href="#">"A"</a></li>
211            </Breadcrumb>
212        }
213        .to_html();
214
215        assert!(
216            html.contains(r#"data-testid="breadcrumb-test""#),
217            "expected data-testid attribute; got: {}",
218            html
219        );
220    }
221
222    #[wasm_bindgen_test]
223    fn breadcrumb_no_test_attr_when_not_provided() {
224        let html = view! {
225            <Breadcrumb>
226                <li><a href="#">"A"</a></li>
227            </Breadcrumb>
228        }
229        .to_html();
230
231        assert!(
232            !html.contains("data-testid") && !html.contains("data-cy"),
233            "expected no test attribute; got: {}",
234            html
235        );
236    }
237}