Skip to main content

lbc/components/
card.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/// An all-around flexible and composable component; this is the card container.
16/// https://bulma.io/documentation/components/card/
17#[component]
18pub fn Card(
19    /// Extra classes to apply to the Bulma "card" container.
20    #[prop(optional, into)]
21    classes: Signal<String>,
22
23    /// Optional test attribute (renders as data-* attribute) on the root <div>.
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.
27    #[prop(optional, into)]
28    test_attr: Option<TestAttr>,
29
30    /// Optional theme attribute (renders as data-theme attribute) on the root <div>.
31    #[prop(optional, into)]
32    data_theme: Option<Signal<String>>,
33
34    /// Card body content (header, image, content, footer, etc.).
35    children: Children,
36) -> impl IntoView {
37    let class = {
38        let classes = classes.clone();
39        move || base_class("card", &classes.get())
40    };
41
42    let theme = move || data_theme.as_ref().map(|s| s.get());
43
44    let (data_testid, data_cy) = match &test_attr {
45        Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None),
46        Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())),
47        _ => (None, None),
48    };
49
50    view! {
51        <div
52            class=class
53            data-theme=theme
54            attr:data-testid=move || data_testid.clone()
55            attr:data-cy=move || data_cy.clone()
56        >
57            {children()}
58        </div>
59    }
60}
61
62/// A container for card header content; rendered as a horizontal bar with a shadow.
63/// https://bulma.io/documentation/components/card/
64#[component]
65pub fn CardHeader(
66    /// Extra classes for the "card-header".
67    #[prop(optional, into)]
68    classes: Signal<String>,
69
70    /// Optional test attribute (renders as data-* attribute) on the <header>.
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
77    /// Children rendered in the header (e.g., title, icons).
78    children: Children,
79) -> impl IntoView {
80    let class = {
81        let classes = classes.clone();
82        move || base_class("card-header", &classes.get())
83    };
84
85    let (data_testid, data_cy) = match &test_attr {
86        Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None),
87        Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())),
88        _ => (None, None),
89    };
90
91    view! {
92        <header
93            class=class
94            attr:data-testid=move || data_testid.clone()
95            attr:data-cy=move || data_cy.clone()
96        >
97            {children()}
98        </header>
99    }
100}
101
102/// A fullwidth container for a responsive image.
103/// https://bulma.io/documentation/components/card/
104#[component]
105pub fn CardImage(
106    /// Extra classes for the "card-image".
107    #[prop(optional, into)]
108    classes: Signal<String>,
109
110    /// Optional test attribute (renders as data-* attribute) on the <div>.
111    ///
112    /// When provided as a &str or String, this becomes `data-testid="value"`.
113    /// You can also pass a full `TestAttr` to override the attribute key.
114    #[prop(optional, into)]
115    test_attr: Option<TestAttr>,
116
117    /// Typically contains a Bulma "image" container.
118    children: Children,
119) -> impl IntoView {
120    let class = {
121        let classes = classes.clone();
122        move || base_class("card-image", &classes.get())
123    };
124
125    let (data_testid, data_cy) = match &test_attr {
126        Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None),
127        Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())),
128        _ => (None, None),
129    };
130
131    view! {
132        <div
133            class=class
134            attr:data-testid=move || data_testid.clone()
135            attr:data-cy=move || data_cy.clone()
136        >
137            {children()}
138        </div>
139    }
140}
141
142/// A container for any other content as the body of the card.
143/// https://bulma.io/documentation/components/card/
144#[component]
145pub fn CardContent(
146    /// Extra classes for the "card-content".
147    #[prop(optional, into)]
148    classes: Signal<String>,
149
150    /// Optional test attribute (renders as data-* attribute) on the <div>.
151    ///
152    /// When provided as a &str or String, this becomes `data-testid="value"`.
153    /// You can also pass a full `TestAttr` to override the attribute key.
154    #[prop(optional, into)]
155    test_attr: Option<TestAttr>,
156
157    /// Body content of the card.
158    children: Children,
159) -> impl IntoView {
160    let class = {
161        let classes = classes.clone();
162        move || base_class("card-content", &classes.get())
163    };
164
165    let (data_testid, data_cy) = match &test_attr {
166        Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None),
167        Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())),
168        _ => (None, None),
169    };
170
171    view! {
172        <div
173            class=class
174            attr:data-testid=move || data_testid.clone()
175            attr:data-cy=move || data_cy.clone()
176        >
177            {children()}
178        </div>
179    }
180}
181
182/// A container for card footer content; rendered as a horizontal list of controls.
183/// https://bulma.io/documentation/components/card/
184#[component]
185pub fn CardFooter(
186    /// Extra classes for the "card-footer".
187    #[prop(optional, into)]
188    classes: Signal<String>,
189
190    /// Optional test attribute (renders as data-* attribute) on the <footer>.
191    ///
192    /// When provided as a &str or String, this becomes `data-testid="value"`.
193    /// You can also pass a full `TestAttr` to override the attribute key.
194    #[prop(optional, into)]
195    test_attr: Option<TestAttr>,
196
197    /// Footer items (commonly multiple <a class="card-footer-item">).
198    children: Children,
199) -> impl IntoView {
200    let class = {
201        let classes = classes.clone();
202        move || base_class("card-footer", &classes.get())
203    };
204
205    let (data_testid, data_cy) = match &test_attr {
206        Some(attr) if attr.key == "data-testid" => (Some(attr.value.clone()), None),
207        Some(attr) if attr.key == "data-cy" => (None, Some(attr.value.clone())),
208        _ => (None, None),
209    };
210
211    view! {
212        <footer
213            class=class
214            attr:data-testid=move || data_testid.clone()
215            attr:data-cy=move || data_cy.clone()
216        >
217            {children()}
218        </footer>
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use leptos::prelude::RenderHtml;
226
227    #[test]
228    fn card_renders_container_and_children() {
229        let html = view! {
230            <Card>
231                <div>"X"</div>
232            </Card>
233        }
234        .to_html();
235
236        assert!(
237            html.contains(r#"class="card""#),
238            "expected base 'card' class; got: {}",
239            html
240        );
241        assert!(
242            html.contains(">X<"),
243            "expected child content; got: {}",
244            html
245        );
246    }
247
248    #[test]
249    fn card_sections_have_proper_classes() {
250        let html = view! {
251            <Card>
252                <CardHeader classes="has-background-light"><p>"Header"</p></CardHeader>
253                <CardImage><figure class="image is-4by3"><img src="#" alt=""/></figure></CardImage>
254                <CardContent><p>"Body"</p></CardContent>
255                <CardFooter>
256                    <a class="card-footer-item">"One"</a>
257                    <a class="card-footer-item">"Two"</a>
258                </CardFooter>
259            </Card>
260        }
261        .to_html();
262
263        assert!(
264            html.contains(r#"class="card-header has-background-light""#)
265                || html.contains("card-header has-background-light "),
266            "expected header classes; got: {}",
267            html
268        );
269        assert!(
270            html.contains(r#"class="card-image""#),
271            "expected card-image class; got: {}",
272            html
273        );
274        assert!(
275            html.contains(r#"class="card-content""#),
276            "expected card-content class; got: {}",
277            html
278        );
279        assert!(
280            html.contains(r#"class="card-footer""#),
281            "expected card-footer class; got: {}",
282            html
283        );
284        assert!(
285            html.contains("card-footer-item"),
286            "expected footer items; got: {}",
287            html
288        );
289    }
290}
291
292#[cfg(all(test, target_arch = "wasm32"))]
293mod wasm_tests {
294    use super::*;
295    use crate::util::TestAttr;
296    use leptos::prelude::*;
297    use wasm_bindgen_test::*;
298
299    wasm_bindgen_test_configure!(run_in_browser);
300
301    #[wasm_bindgen_test]
302    fn card_renders_test_attr_as_data_testid() {
303        let html = view! {
304            <Card classes="extra" test_attr="card-test">
305                <div>"X"</div>
306            </Card>
307        }
308        .to_html();
309
310        assert!(
311            html.contains(r#"data-testid="card-test""#),
312            "expected data-testid attribute on Card; got: {}",
313            html
314        );
315    }
316
317    #[wasm_bindgen_test]
318    fn card_no_test_attr_when_not_provided() {
319        let html = view! {
320            <Card>
321                <div>"X"</div>
322            </Card>
323        }
324        .to_html();
325
326        assert!(
327            !html.contains("data-testid") && !html.contains("data-cy"),
328            "expected no test attribute on Card when not provided; got: {}",
329            html
330        );
331    }
332
333    #[wasm_bindgen_test]
334    fn card_header_renders_test_attr_as_data_testid() {
335        let html = view! {
336            <CardHeader classes="extra" test_attr="card-header-test">
337                <p>"Header"</p>
338            </CardHeader>
339        }
340        .to_html();
341
342        assert!(
343            html.contains(r#"data-testid="card-header-test""#),
344            "expected data-testid on CardHeader; got: {}",
345            html
346        );
347    }
348
349    #[wasm_bindgen_test]
350    fn card_image_renders_test_attr_as_data_testid() {
351        let html = view! {
352            <CardImage test_attr="card-image-test">
353                <figure class="image is-4by3"><img src="#" alt=""/></figure>
354            </CardImage>
355        }
356        .to_html();
357
358        assert!(
359            html.contains(r#"data-testid="card-image-test""#),
360            "expected data-testid on CardImage; got: {}",
361            html
362        );
363    }
364
365    #[wasm_bindgen_test]
366    fn card_content_renders_test_attr_as_data_testid() {
367        let html = view! {
368            <CardContent test_attr="card-content-test">
369                <p>"Body"</p>
370            </CardContent>
371        }
372        .to_html();
373
374        assert!(
375            html.contains(r#"data-testid="card-content-test""#),
376            "expected data-testid on CardContent; got: {}",
377            html
378        );
379    }
380
381    #[wasm_bindgen_test]
382    fn card_footer_renders_test_attr_as_data_testid() {
383        let html = view! {
384            <CardFooter test_attr="card-footer-test">
385                <a class="card-footer-item">"One"</a>
386            </CardFooter>
387        }
388        .to_html();
389
390        assert!(
391            html.contains(r#"data-testid="card-footer-test""#),
392            "expected data-testid on CardFooter; got: {}",
393            html
394        );
395    }
396
397    #[wasm_bindgen_test]
398    fn card_accepts_custom_test_attr_key() {
399        let html = view! {
400            <Card
401                classes="extra"
402                test_attr=TestAttr::new("data-cy", "card-cy")
403            >
404                <div>"X"</div>
405            </Card>
406        }
407        .to_html();
408
409        assert!(
410            html.contains(r#"data-cy="card-cy""#),
411            "expected custom data-cy attribute on Card; got: {}",
412            html
413        );
414    }
415}