Skip to main content

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    /// `id` for the card-body div. A scroll container or a scrollspy target
95    /// needs to be addressable, and the body is the element that scrolls — the
96    /// alternative, hand-writing `div { class: "card-body" }` in `children`, is
97    /// the raw Bootstrap this component exists to replace.
98    #[props(default)]
99    pub body_id: Option<String>,
100    /// Inline `style` for the card-body div — the inline-style sibling of
101    /// `body_class`, for the case where the value is computed rather than named
102    /// (a `max-height` a class cannot express). Without it the panel grows
103    /// unbounded instead of scrolling, which is a visible failure rather than a
104    /// dropped attribute.
105    #[props(default)]
106    pub body_style: Option<String>,
107    /// Additional CSS classes for the card-footer div.
108    #[props(default)]
109    pub footer_class: String,
110    /// Click handler for the whole card. A selectable card is a Bootstrap
111    /// pattern (the docs' clickable card examples); without a handler the only
112    /// way to get one is the `href` form, which navigates.
113    #[props(default)]
114    pub onclick: Option<EventHandler<MouseEvent>>,
115    /// Right-click / context-menu handler, for a per-card menu.
116    #[props(default)]
117    pub oncontextmenu: Option<EventHandler<MouseEvent>>,
118    /// Any additional HTML attributes.
119    #[props(extends = GlobalAttributes)]
120    attributes: Vec<Attribute>,
121    /// Child elements (rendered inside card, outside body — for custom layouts).
122    #[props(default)]
123    pub children: Element,
124}
125
126/// Root class for the card container. Shared by the `<div>` and `<a>` render
127/// paths so both carry identical classes.
128fn card_class(class: &str) -> String {
129    if class.is_empty() {
130        "card".to_string()
131    } else {
132        format!("card {class}")
133    }
134}
135
136#[component]
137pub fn Card(props: CardProps) -> Element {
138    let full_class = card_class(&props.class);
139
140    let header_class = if props.header_class.is_empty() {
141        "card-header".to_string()
142    } else {
143        format!("card-header {}", props.header_class)
144    };
145
146    let body_class = if props.body_class.is_empty() {
147        "card-body".to_string()
148    } else {
149        format!("card-body {}", props.body_class)
150    };
151
152    let footer_class = if props.footer_class.is_empty() {
153        "card-footer".to_string()
154    } else {
155        format!("card-footer {}", props.footer_class)
156    };
157
158    // Build the slot content once so both render paths share it verbatim.
159    let inner = rsx! {
160        if let Some(header) = props.header {
161            div { class: "{header_class}", {header} }
162        }
163        if let Some(body) = props.body {
164            div {
165                class: "{body_class}",
166                id: props.body_id.clone(),
167                style: props.body_style.clone(),
168                {body}
169            }
170        }
171        {props.children}
172        if let Some(footer) = props.footer {
173            div { class: "{footer_class}", {footer} }
174        }
175    };
176
177    // Anchor form: the whole card is a single link. Mirrors `Button`/`DropdownItem`
178    // — the early return keeps `props.attributes` unmoved on the `<div>` path.
179    if let Some(href) = props.href.clone() {
180        let target = props.target.clone();
181        return rsx! {
182            a {
183                class: "{full_class}",
184                href: "{href}",
185                target: target,
186                onclick: move |evt| {
187                    if let Some(handler) = &props.onclick {
188                        handler.call(evt);
189                    }
190                },
191                oncontextmenu: move |evt| {
192                    if let Some(handler) = &props.oncontextmenu {
193                        handler.call(evt);
194                    }
195                },
196                ..props.attributes,
197                {inner}
198            }
199        };
200    }
201
202    rsx! {
203        div {
204            class: "{full_class}",
205            onclick: move |evt| {
206                if let Some(handler) = &props.onclick {
207                    handler.call(evt);
208                }
209            },
210            oncontextmenu: move |evt| {
211                if let Some(handler) = &props.oncontextmenu {
212                    handler.call(evt);
213                }
214            },
215            ..props.attributes,
216            {inner}
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn card_class_base() {
227        assert_eq!(card_class(""), "card");
228    }
229
230    #[test]
231    fn card_class_with_extra() {
232        // Identical class output for the `<div>` and `<a href>` render paths.
233        assert_eq!(
234            card_class("h-100 text-decoration-none text-reset"),
235            "card h-100 text-decoration-none text-reset"
236        );
237    }
238}