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 dioxus::prelude::*;
6use crate::theme::use_style;
7use crate::styles::Style;
8
9/// Card variants
10#[derive(Default, Clone, PartialEq)]
11pub enum CardVariant {
12    /// Default bordered card
13    #[default]
14    Default,
15    /// Card with subtle background
16    Muted,
17    /// Elevated card with shadow
18    Elevated,
19    /// Outlined card
20    Outlined,
21    /// Ghost card (no border/bg)
22    Ghost,
23}
24
25/// Card properties
26#[derive(Props, Clone, PartialEq)]
27pub struct CardProps {
28    /// Card content
29    pub children: Element,
30    /// Visual variant
31    #[props(default)]
32    pub variant: CardVariant,
33    /// Custom padding size
34    #[props(default)]
35    pub padding: Option<String>,
36    /// Full width
37    #[props(default)]
38    pub full_width: bool,
39    /// Click handler (makes card interactive)
40    #[props(default)]
41    pub onclick: Option<EventHandler<MouseEvent>>,
42    /// Custom inline styles
43    #[props(default)]
44    pub style: Option<String>,
45    /// Custom class name
46    #[props(default)]
47    pub class: Option<String>,
48}
49
50/// Card molecule component
51///
52/// # Example
53/// ```rust,ignore
54/// use dioxus_ui_system::molecules::{Card, CardVariant};
55///
56/// rsx! {
57///     Card {
58///         variant: CardVariant::Elevated,
59///         CardHeader { title: "Card Title" }
60///         CardContent { "Card content goes here" }
61///         CardFooter { "Footer content" }
62///     }
63/// }
64/// ```
65#[component]
66pub fn Card(props: CardProps) -> Element {
67    let variant = props.variant.clone();
68    let full_width = props.full_width;
69    let has_onclick = props.onclick.is_some();
70    
71    // Interactive states
72    let mut is_hovered = use_signal(|| false);
73    let mut is_pressed = use_signal(|| false);
74    
75    let style = use_style(move |t| {
76        let base = Style::new()
77            .rounded(&t.radius, "lg")
78            .bg(&t.colors.card)
79            .text_color(&t.colors.card_foreground)
80            .overflow_hidden()
81            .transition("all 150ms ease");
82            
83        // Full width
84        let base = if full_width {
85            base.w_full()
86        } else {
87            base
88        };
89        
90        // Apply variant styles
91        let styled = match variant {
92            CardVariant::Default => base
93                .border(1, &t.colors.border),
94            CardVariant::Muted => base
95                .bg(&t.colors.muted)
96                .border(1, &t.colors.border),
97            CardVariant::Elevated => base
98                .shadow(&t.shadows.md)
99                .border(1, &t.colors.border),
100            CardVariant::Outlined => base
101                .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            .flex()
195            .flex_col()
196            .p(&t.spacing, "lg")
197            .gap(&t.spacing, "xs")
198            .build()
199    });
200    
201    let content = if let Some(title) = props.title {
202        let subtitle_element = props.subtitle.map(|s| {
203            rsx! {
204                crate::atoms::Label {
205                    size: crate::atoms::TextSize::Small,
206                    color: crate::atoms::TextColor::Muted,
207                    "{s}"
208                }
209            }
210        });
211        
212        let action_element = props.action.clone();
213        
214        rsx! {
215            div {
216                style: "display: flex; justify-content: space-between; align-items: flex-start;",
217                div {
218                    style: "display: flex; flex-direction: column; gap: 4px;",
219                    crate::atoms::Heading {
220                        level: crate::atoms::HeadingLevel::H4,
221                        "{title}"
222                    }
223                    {subtitle_element}
224                }
225                {action_element}
226            }
227        }
228    } else {
229        props.children
230    };
231    
232    rsx! {
233        div { style: "{style}", {content} }
234    }
235}
236
237/// Card Content properties
238#[derive(Props, Clone, PartialEq)]
239pub struct CardContentProps {
240    /// Content
241    pub children: Element,
242    /// Custom padding
243    #[props(default)]
244    pub padding: Option<String>,
245}
246
247/// Card Content component
248#[component]
249pub fn CardContent(props: CardContentProps) -> Element {
250    let style = use_style(|t| {
251        Style::new()
252            .p(&t.spacing, "lg")
253            .pt_px(0) // Remove top padding if following header
254            .flex()
255            .flex_col()
256            .gap(&t.spacing, "md")
257            .build()
258    });
259    
260    let final_style = if let Some(padding) = props.padding {
261        format!("padding: {};", padding)
262    } else {
263        style()
264    };
265    
266    rsx! {
267        div { style: "{final_style}", {props.children} }
268    }
269}
270
271/// Card Footer properties
272#[derive(Props, Clone, PartialEq)]
273pub struct CardFooterProps {
274    /// Footer content
275    pub children: Element,
276    /// Justify content: start, center, end, between
277    #[props(default)]
278    pub justify: CardFooterJustify,
279}
280
281/// Card footer justify options
282#[derive(Default, Clone, PartialEq)]
283pub enum CardFooterJustify {
284    #[default]
285    Start,
286    Center,
287    End,
288    Between,
289}
290
291/// Card Footer component
292#[component]
293pub fn CardFooter(props: CardFooterProps) -> Element {
294    let justify = match props.justify {
295        CardFooterJustify::Start => "flex-start",
296        CardFooterJustify::Center => "center",
297        CardFooterJustify::End => "flex-end",
298        CardFooterJustify::Between => "space-between",
299    };
300    
301    let style = use_style(|t| {
302        Style::new()
303            .flex()
304            .items_center()
305            .p(&t.spacing, "lg")
306            .pt_px(0)
307            .gap(&t.spacing, "sm")
308            .build()
309    });
310    
311    rsx! {
312        div {
313            style: "{style} justify-content: {justify};",
314            {props.children}
315        }
316    }
317}