dioxus_ui_system/molecules/
card.rs1use crate::atoms::{AlignItems, HStack, JustifyContent, SpacingSize, VStack};
6use crate::styles::Style;
7use crate::theme::use_style;
8use dioxus::prelude::*;
9
10#[derive(Default, Clone, PartialEq)]
12pub enum CardVariant {
13 #[default]
15 Default,
16 Muted,
18 Elevated,
20 Outlined,
22 Ghost,
24}
25
26#[derive(Props, Clone, PartialEq)]
28pub struct CardProps {
29 pub children: Element,
31 #[props(default)]
33 pub variant: CardVariant,
34 #[props(default)]
36 pub padding: Option<String>,
37 #[props(default)]
39 pub full_width: bool,
40 #[props(default)]
42 pub onclick: Option<EventHandler<MouseEvent>>,
43 #[props(default)]
45 pub style: Option<String>,
46 #[props(default)]
48 pub class: Option<String>,
49 #[props(default = true)]
51 pub overflow_hidden: bool,
52}
53
54#[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 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 let base = if full_width { base.w_full() } else { base };
95
96 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 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 .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#[derive(Props, Clone, PartialEq)]
238pub struct CardContentProps {
239 pub children: Element,
241 #[props(default)]
243 pub padding: Option<String>,
244}
245
246#[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#[derive(Props, Clone, PartialEq)]
270pub struct CardFooterProps {
271 pub children: Element,
273 #[props(default)]
275 pub justify: CardFooterJustify,
276}
277
278#[derive(Default, Clone, PartialEq)]
280pub enum CardFooterJustify {
281 #[default]
282 Start,
283 Center,
284 End,
285 Between,
286}
287
288#[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}