Skip to main content

dioxus_ui_system/molecules/
card.rs

1//! Card molecule component
2//!
3//! A flexible container for grouping related content.
4
5use crate::atoms::{AlignItems, HStack, JustifyContent, SpacingSize, VStack};
6use crate::styles::Style;
7use crate::theme::use_style;
8use dioxus::prelude::*;
9
10/// Card variants
11#[derive(Default, Clone, PartialEq)]
12pub enum CardVariant {
13    /// Default bordered card
14    #[default]
15    Default,
16    /// Card with subtle background
17    Muted,
18    /// Elevated card with shadow
19    Elevated,
20    /// Outlined card
21    Outlined,
22    /// Ghost card (no border/bg)
23    Ghost,
24}
25
26/// Card properties
27#[derive(Props, Clone, PartialEq)]
28pub struct CardProps {
29    /// Card content
30    pub children: Element,
31    /// Visual variant
32    #[props(default)]
33    pub variant: CardVariant,
34    /// Custom padding size
35    #[props(default)]
36    pub padding: Option<String>,
37    /// Full width
38    #[props(default)]
39    pub full_width: bool,
40    /// Click handler (makes card interactive)
41    #[props(default)]
42    pub onclick: Option<EventHandler<MouseEvent>>,
43    /// Custom inline styles
44    #[props(default)]
45    pub style: Option<String>,
46    /// Custom class name
47    #[props(default)]
48    pub class: Option<String>,
49    /// Whether to hide overflow (default: true)
50    #[props(default = true)]
51    pub overflow_hidden: bool,
52}
53
54/// Card molecule component
55///
56/// # Example
57/// ```rust,ignore
58/// use dioxus_ui_system::molecules::{Card, CardVariant};
59///
60/// rsx! {
61///     Card {
62///         variant: CardVariant::Elevated,
63///         CardHeader { title: "Card Title" }
64///         CardContent { "Card content goes here" }
65///         CardFooter { "Footer content" }
66///     }
67/// }
68/// ```
69#[component]
70pub fn Card(props: CardProps) -> Element {
71    let variant = props.variant.clone();
72    let full_width = props.full_width;
73    let has_onclick = props.onclick.is_some();
74
75    // Interactive states
76    let mut is_hovered = use_signal(|| false);
77    let mut is_pressed = use_signal(|| false);
78
79    let overflow_hidden = props.overflow_hidden;
80    let style = use_style(move |t| {
81        let base = Style::new()
82            .rounded(&t.radius, "lg")
83            .bg(&t.colors.card)
84            .text_color(&t.colors.card_foreground)
85            .transition("all 150ms ease");
86
87        let base = if overflow_hidden {
88            base.overflow_hidden()
89        } else {
90            base
91        };
92
93        // Full width
94        let base = if full_width { base.w_full() } else { base };
95
96        // Apply variant styles
97        let styled = match variant {
98            CardVariant::Default => base.border(1, &t.colors.border),
99            CardVariant::Muted => base.bg(&t.colors.muted).border(1, &t.colors.border),
100            CardVariant::Elevated => base.shadow(&t.shadows.md).border(1, &t.colors.border),
101            CardVariant::Outlined => base.border(2, &t.colors.border),
102            CardVariant::Ghost => base,
103        };
104
105        // Interactive states
106        let styled = if has_onclick && !is_pressed() && is_hovered() {
107            match variant {
108                CardVariant::Elevated => Style {
109                    box_shadow: Some(t.shadows.lg.clone()),
110                    ..styled
111                },
112                _ => styled.border_color(&t.colors.foreground.darken(0.2)),
113            }
114        } else {
115            styled
116        };
117
118        // Apply padding if specified
119        let styled = if let Some(ref p) = props.padding {
120            Style {
121                padding: Some(p.clone()),
122                ..styled
123            }
124        } else {
125            styled
126        };
127
128        // Cursor
129        let styled = if has_onclick {
130            styled.cursor_pointer()
131        } else {
132            styled
133        };
134
135        styled.build()
136    });
137
138    // Transform for pressed state
139    let transform = if is_pressed() && has_onclick {
140        "transform: scale(0.99);"
141    } else {
142        ""
143    };
144
145    let final_style = if let Some(custom) = &props.style {
146        format!("{} {}{}", style(), custom, transform)
147    } else {
148        format!("{}{}", style(), transform)
149    };
150
151    let class = props.class.clone().unwrap_or_default();
152
153    let onclick_handler = props.onclick.clone();
154
155    rsx! {
156        div {
157            style: "{final_style}",
158            class: "{class}",
159            onmouseenter: move |_| if has_onclick { is_hovered.set(true) },
160            onmouseleave: move |_| { is_hovered.set(false); is_pressed.set(false); },
161            onmousedown: move |_| if has_onclick { is_pressed.set(true) },
162            onmouseup: move |_| if has_onclick { is_pressed.set(false) },
163            onclick: move |e| {
164                if let Some(handler) = &onclick_handler {
165                    handler.call(e);
166                }
167            },
168            {props.children}
169        }
170    }
171}
172
173/// Card Header properties
174#[derive(Props, Clone, PartialEq)]
175pub struct CardHeaderProps {
176    /// Header content
177    pub children: Element,
178    /// Optional title (convenience prop)
179    #[props(default)]
180    pub title: Option<String>,
181    /// Optional subtitle
182    #[props(default)]
183    pub subtitle: Option<String>,
184    /// Optional action element
185    #[props(default)]
186    pub action: Option<Element>,
187}
188
189/// Card Header component
190#[component]
191pub fn CardHeader(props: CardHeaderProps) -> Element {
192    let style = use_style(|t| {
193        Style::new()
194            .p(&t.spacing, "lg")
195            .gap(&t.spacing, "xs")
196            .build()
197    });
198
199    let content = if let Some(title) = props.title {
200        let subtitle_element = props.subtitle.map(|s| {
201            rsx! {
202                crate::atoms::Label {
203                    size: crate::atoms::TextSize::Small,
204                    color: crate::atoms::TextColor::Muted,
205                    "{s}"
206                }
207            }
208        });
209
210        let action_element = props.action.clone();
211
212        rsx! {
213            HStack {
214                justify: JustifyContent::SpaceBetween,
215                align: AlignItems::Start,
216                VStack {
217                    gap: SpacingSize::Xs,
218                    crate::atoms::Heading {
219                        level: crate::atoms::HeadingLevel::H4,
220                        "{title}"
221                    }
222                    {subtitle_element}
223                }
224                {action_element}
225            }
226        }
227    } else {
228        props.children
229    };
230
231    rsx! {
232        VStack { style: "{style}", {content} }
233    }
234}
235
236/// Card Content properties
237#[derive(Props, Clone, PartialEq)]
238pub struct CardContentProps {
239    /// Content
240    pub children: Element,
241    /// Custom padding
242    #[props(default)]
243    pub padding: Option<String>,
244}
245
246/// Card Content component
247#[component]
248pub fn CardContent(props: CardContentProps) -> Element {
249    let style = use_style(|t| {
250        Style::new()
251            .p(&t.spacing, "lg")
252            .pt_px(0)
253            .gap(&t.spacing, "md")
254            .build()
255    });
256
257    let final_style = if let Some(padding) = props.padding {
258        format!("padding: {};", padding)
259    } else {
260        style()
261    };
262
263    rsx! {
264        VStack { style: "{final_style}", {props.children} }
265    }
266}
267
268/// Card Footer properties
269#[derive(Props, Clone, PartialEq)]
270pub struct CardFooterProps {
271    /// Footer content
272    pub children: Element,
273    /// Justify content: start, center, end, between
274    #[props(default)]
275    pub justify: CardFooterJustify,
276}
277
278/// Card footer justify options
279#[derive(Default, Clone, PartialEq)]
280pub enum CardFooterJustify {
281    #[default]
282    Start,
283    Center,
284    End,
285    Between,
286}
287
288/// Card Footer component
289#[component]
290pub fn CardFooter(props: CardFooterProps) -> Element {
291    let justify = match props.justify {
292        CardFooterJustify::Start => JustifyContent::Start,
293        CardFooterJustify::Center => JustifyContent::Center,
294        CardFooterJustify::End => JustifyContent::End,
295        CardFooterJustify::Between => JustifyContent::SpaceBetween,
296    };
297
298    let style = use_style(|t| {
299        Style::new()
300            .p(&t.spacing, "lg")
301            .pt_px(0)
302            .gap(&t.spacing, "sm")
303            .build()
304    });
305
306    rsx! {
307        HStack {
308            style: "{style}",
309            justify: justify,
310            align: AlignItems::Center,
311            {props.children}
312        }
313    }
314}