dioxus_ui_system/molecules/
card.rs1use dioxus::prelude::*;
6use crate::theme::use_style;
7use crate::styles::Style;
8
9#[derive(Default, Clone, PartialEq)]
11pub enum CardVariant {
12 #[default]
14 Default,
15 Muted,
17 Elevated,
19 Outlined,
21 Ghost,
23}
24
25#[derive(Props, Clone, PartialEq)]
27pub struct CardProps {
28 pub children: Element,
30 #[props(default)]
32 pub variant: CardVariant,
33 #[props(default)]
35 pub padding: Option<String>,
36 #[props(default)]
38 pub full_width: bool,
39 #[props(default)]
41 pub onclick: Option<EventHandler<MouseEvent>>,
42 #[props(default)]
44 pub style: Option<String>,
45 #[props(default)]
47 pub class: Option<String>,
48}
49
50#[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 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 let base = if full_width {
85 base.w_full()
86 } else {
87 base
88 };
89
90 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 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 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 let styled = if has_onclick {
130 styled.cursor_pointer()
131 } else {
132 styled
133 };
134
135 styled.build()
136 });
137
138 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#[derive(Props, Clone, PartialEq)]
175pub struct CardHeaderProps {
176 pub children: Element,
178 #[props(default)]
180 pub title: Option<String>,
181 #[props(default)]
183 pub subtitle: Option<String>,
184 #[props(default)]
186 pub action: Option<Element>,
187}
188
189#[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#[derive(Props, Clone, PartialEq)]
239pub struct CardContentProps {
240 pub children: Element,
242 #[props(default)]
244 pub padding: Option<String>,
245}
246
247#[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) .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#[derive(Props, Clone, PartialEq)]
273pub struct CardFooterProps {
274 pub children: Element,
276 #[props(default)]
278 pub justify: CardFooterJustify,
279}
280
281#[derive(Default, Clone, PartialEq)]
283pub enum CardFooterJustify {
284 #[default]
285 Start,
286 Center,
287 End,
288 Between,
289}
290
291#[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}