dioxus_bootstrap_css/card.rs
1use dioxus::prelude::*;
2
3/// Bootstrap Card component with optional header, body, and footer slots.
4///
5/// Renders a `<div class="card">` by default. When `href` is set, renders an
6/// `<a class="card" href=...>` instead, so the whole card is a single link — the
7/// standard Bootstrap clickable-card pattern. `target` applies only in that mode.
8/// This mirrors `Button` (`button.btn` / `a.btn`) and `DropdownItem`, which switch
9/// the same way; both render paths carry identical classes via `card_class`.
10///
11/// # Bootstrap HTML → Dioxus
12///
13/// ```html
14/// <!-- Bootstrap HTML -->
15/// <div class="card">
16/// <div class="card-header">Title</div>
17/// <div class="card-body"><p>Content</p></div>
18/// <div class="card-footer">Footer</div>
19/// </div>
20/// <!-- Clickable card (the whole card is a link) -->
21/// <a class="card text-decoration-none text-reset" href="/page">
22/// <div class="card-body"><h5>Title</h5><p>Text</p></div>
23/// </a>
24/// ```
25///
26/// ```rust,no_run
27/// # use dioxus::prelude::*;
28/// # use dioxus_bootstrap_css::prelude::*;
29/// # fn _doctest() -> Element {
30/// // Dioxus equivalent
31/// rsx! {
32/// Card {
33/// header: rsx! { "Card Title" },
34/// body: rsx! { p { "Card content goes here." } },
35/// footer: rsx! { "Last updated 3 mins ago" },
36/// }
37/// // Body-only card
38/// Card { body: rsx! { "Simple card" } }
39/// // Clickable card — the whole card is a link
40/// Card {
41/// href: "/page",
42/// class: "text-decoration-none text-reset",
43/// body: rsx! { h5 { "Title" } p { "Text" } },
44/// }
45/// // Card with custom header styling (e.g., flex layout with action buttons)
46/// Card {
47/// class: "mb-3",
48/// header_class: "d-flex justify-content-between align-items-center py-2",
49/// body_class: "py-2",
50/// header: rsx! {
51/// span { class: "small", "Server" }
52/// button { class: "btn btn-sm btn-outline-secondary py-0 px-1",
53/// i { class: "bi bi-arrow-clockwise small" }
54/// }
55/// },
56/// body: rsx! { p { "Stats here" } },
57/// }
58/// // Custom layout (children go inside card, outside body)
59/// Card { class: "text-center",
60/// img { class: "card-img-top", src: "/photo.jpg" }
61/// div { class: "card-body", h5 { "Title" } p { "Text" } }
62/// }
63/// }
64/// # }
65/// ```
66#[derive(Clone, PartialEq, Props)]
67pub struct CardProps {
68 /// Card header content.
69 #[props(default)]
70 pub header: Option<Element>,
71 /// Card body content.
72 #[props(default)]
73 pub body: Option<Element>,
74 /// Card footer content.
75 #[props(default)]
76 pub footer: Option<Element>,
77 /// When set, render an `<a class="card" href=...>` anchor instead of a
78 /// `<div class="card">`, so the whole card is a link.
79 #[props(default)]
80 pub href: Option<String>,
81 /// Anchor `target` (e.g. `"_blank"` to open in a new tab). Only applies when
82 /// `href` is set.
83 #[props(default)]
84 pub target: Option<String>,
85 /// Additional CSS classes for the card container.
86 #[props(default)]
87 pub class: String,
88 /// Additional CSS classes for the card-header div.
89 #[props(default)]
90 pub header_class: String,
91 /// Additional CSS classes for the card body.
92 #[props(default)]
93 pub body_class: String,
94 /// Additional CSS classes for the card-footer div.
95 #[props(default)]
96 pub footer_class: String,
97 /// Any additional HTML attributes.
98 #[props(extends = GlobalAttributes)]
99 attributes: Vec<Attribute>,
100 /// Child elements (rendered inside card, outside body — for custom layouts).
101 #[props(default)]
102 pub children: Element,
103}
104
105/// Root class for the card container. Shared by the `<div>` and `<a>` render
106/// paths so both carry identical classes.
107fn card_class(class: &str) -> String {
108 if class.is_empty() {
109 "card".to_string()
110 } else {
111 format!("card {class}")
112 }
113}
114
115#[component]
116pub fn Card(props: CardProps) -> Element {
117 let full_class = card_class(&props.class);
118
119 let header_class = if props.header_class.is_empty() {
120 "card-header".to_string()
121 } else {
122 format!("card-header {}", props.header_class)
123 };
124
125 let body_class = if props.body_class.is_empty() {
126 "card-body".to_string()
127 } else {
128 format!("card-body {}", props.body_class)
129 };
130
131 let footer_class = if props.footer_class.is_empty() {
132 "card-footer".to_string()
133 } else {
134 format!("card-footer {}", props.footer_class)
135 };
136
137 // Build the slot content once so both render paths share it verbatim.
138 let inner = rsx! {
139 if let Some(header) = props.header {
140 div { class: "{header_class}", {header} }
141 }
142 if let Some(body) = props.body {
143 div { class: "{body_class}", {body} }
144 }
145 {props.children}
146 if let Some(footer) = props.footer {
147 div { class: "{footer_class}", {footer} }
148 }
149 };
150
151 // Anchor form: the whole card is a single link. Mirrors `Button`/`DropdownItem`
152 // — the early return keeps `props.attributes` unmoved on the `<div>` path.
153 if let Some(href) = props.href.clone() {
154 let target = props.target.clone();
155 return rsx! {
156 a { class: "{full_class}", href: "{href}", target: target,
157 ..props.attributes,
158 {inner}
159 }
160 };
161 }
162
163 rsx! {
164 div { class: "{full_class}",
165 ..props.attributes,
166 {inner}
167 }
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn card_class_base() {
177 assert_eq!(card_class(""), "card");
178 }
179
180 #[test]
181 fn card_class_with_extra() {
182 // Identical class output for the `<div>` and `<a href>` render paths.
183 assert_eq!(
184 card_class("h-100 text-decoration-none text-reset"),
185 "card h-100 text-decoration-none text-reset"
186 );
187 }
188}