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#[component]
18pub fn Card(
19 #[prop(optional, into)]
21 classes: Signal<String>,
22
23 #[prop(optional, into)]
28 test_attr: Option<TestAttr>,
29
30 #[prop(optional, into)]
32 data_theme: Option<Signal<String>>,
33
34 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#[component]
65pub fn CardHeader(
66 #[prop(optional, into)]
68 classes: Signal<String>,
69
70 #[prop(optional, into)]
75 test_attr: Option<TestAttr>,
76
77 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#[component]
105pub fn CardImage(
106 #[prop(optional, into)]
108 classes: Signal<String>,
109
110 #[prop(optional, into)]
115 test_attr: Option<TestAttr>,
116
117 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#[component]
145pub fn CardContent(
146 #[prop(optional, into)]
148 classes: Signal<String>,
149
150 #[prop(optional, into)]
155 test_attr: Option<TestAttr>,
156
157 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#[component]
185pub fn CardFooter(
186 #[prop(optional, into)]
188 classes: Signal<String>,
189
190 #[prop(optional, into)]
195 test_attr: Option<TestAttr>,
196
197 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}