1use crate::theme::use_theme;
6use dioxus::prelude::*;
7
8#[derive(Default, Clone, PartialEq, Debug)]
10pub enum SkeletonShape {
11 #[default]
12 Rectangle,
13 Circle,
14 Text,
15 Rounded,
16}
17
18#[derive(Props, Clone, PartialEq)]
20pub struct SkeletonProps {
21 #[props(default = SkeletonShape::Rectangle)]
23 pub shape: SkeletonShape,
24 #[props(default)]
27 pub width: Option<String>,
28 #[props(default)]
30 pub height: Option<String>,
31 #[props(default = true)]
33 pub animated: bool,
34 #[props(default = SkeletonAnimation::Shimmer)]
36 pub animation: SkeletonAnimation,
37 pub color: Option<String>,
39 pub highlight_color: Option<String>,
41 #[props(default)]
43 pub class: Option<String>,
44}
45
46#[derive(Default, Clone, PartialEq, Debug)]
48pub enum SkeletonAnimation {
49 #[default]
50 Shimmer,
51 Pulse,
52 Wave,
53}
54
55#[component]
57pub fn Skeleton(props: SkeletonProps) -> Element {
58 let theme = use_theme();
59
60 let base_color = props
61 .color
62 .unwrap_or_else(|| theme.tokens.read().colors.muted.to_rgba());
63
64 let highlight = props.highlight_color.unwrap_or_else(|| {
65 theme.tokens.read().colors.background.to_rgba()
67 });
68
69 let class_css = props
70 .class
71 .as_ref()
72 .map(|c| format!(" {}", c))
73 .unwrap_or_default();
74
75 let border_radius = match props.shape {
76 SkeletonShape::Circle => "50%",
77 SkeletonShape::Rounded => "8px",
78 SkeletonShape::Text => "4px",
79 SkeletonShape::Rectangle => "0px",
80 };
81
82 let animation_css = if props.animated {
83 match props.animation {
84 SkeletonAnimation::Shimmer => format!(
85 "background: linear-gradient(90deg, {base_color} 25%, {highlight} 50%, {base_color} 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite;",
86 ),
87 SkeletonAnimation::Pulse => format!(
88 "background: {base_color}; animation: pulse 1.5s ease-in-out infinite;",
89 ),
90 SkeletonAnimation::Wave => format!(
91 "background: {base_color}; animation: wave 1.5s ease-in-out infinite;",
92 ),
93 }
94 } else {
95 format!("background: {base_color};")
96 };
97
98 let width = props.width.as_deref().unwrap_or("100%");
99 let height = props.height.as_deref().unwrap_or("16px");
100
101 rsx! {
102 div {
103 class: "skeleton{class_css}",
104 style: "width: {width}; height: {height}; border-radius: {border_radius}; {animation_css}",
105 }
106
107
108 }
109}
110
111#[derive(Props, Clone, PartialEq)]
113pub struct SkeletonTextProps {
114 #[props(default = 3)]
116 pub lines: u8,
117 #[props(default = 1.5)]
119 pub line_height: f32,
120 #[props(default = true)]
122 pub animated: bool,
123 #[props(default = 80)]
125 pub last_line_width: u8,
126 #[props(default)]
128 pub class: Option<String>,
129}
130
131#[component]
133pub fn SkeletonText(props: SkeletonTextProps) -> Element {
134 let class_css = props
135 .class
136 .as_ref()
137 .map(|c| format!(" {}", c))
138 .unwrap_or_default();
139
140 rsx! {
141 div {
142 class: "skeleton-text{class_css}",
143 style: "display: flex; flex-direction: column; gap: 8px;",
144
145 for i in 0..props.lines {
146 {
147 let is_last = i == props.lines - 1;
148 let width = if is_last { format!("{}%", props.last_line_width) } else { "100%".to_string() };
149
150 rsx! {
151 Skeleton {
152 key: "{i}",
153 shape: SkeletonShape::Text,
154 width: width,
155 height: "1em",
156 animated: props.animated,
157 }
158 }
159 }
160 }
161 }
162 }
163}
164
165#[derive(Props, Clone, PartialEq)]
167pub struct SkeletonCardProps {
168 #[props(default = true)]
170 pub show_image: bool,
171 #[props(default)]
173 pub image_height: Option<String>,
174 #[props(default = 3)]
176 pub text_lines: u8,
177 #[props(default = true)]
179 pub show_actions: bool,
180 #[props(default = true)]
182 pub animated: bool,
183 #[props(default)]
185 pub class: Option<String>,
186}
187
188#[component]
190pub fn SkeletonCard(props: SkeletonCardProps) -> Element {
191 let class_css = props
192 .class
193 .as_ref()
194 .map(|c| format!(" {}", c))
195 .unwrap_or_default();
196
197 rsx! {
198 div {
199 class: "skeleton-card{class_css}",
200 style: "border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; background: white;",
201
202 if props.show_image {
203 Skeleton {
204 shape: SkeletonShape::Rectangle,
205 width: Some("100%".to_string()),
206 height: Some(props.image_height.clone().unwrap_or_else(|| "200px".to_string())),
207 animated: props.animated,
208 }
209 }
210
211 div {
212 style: "padding: 16px;",
213
214 SkeletonText {
215 lines: props.text_lines,
216 animated: props.animated,
217 }
218
219 if props.show_actions {
220 div {
221 style: "display: flex; gap: 8px; margin-top: 16px;",
222
223 Skeleton {
224 shape: SkeletonShape::Rounded,
225 width: "80px",
226 height: "36px",
227 animated: props.animated,
228 }
229
230 Skeleton {
231 shape: SkeletonShape::Rounded,
232 width: "80px",
233 height: "36px",
234 animated: props.animated,
235 }
236 }
237 }
238 }
239 }
240 }
241}
242
243#[derive(Props, Clone, PartialEq)]
245pub struct SkeletonAvatarProps {
246 #[props(default = AvatarSize::Md)]
248 pub size: AvatarSize,
249 #[props(default = true)]
251 pub animated: bool,
252}
253
254#[derive(Clone, PartialEq, Debug)]
256pub enum AvatarSize {
257 Xs, Sm, Md, Lg, Xl, Xxl, }
264
265impl Default for AvatarSize {
266 fn default() -> Self {
267 AvatarSize::Md
268 }
269}
270
271impl AvatarSize {
272 fn to_px(&self) -> u16 {
273 match self {
274 AvatarSize::Xs => 24,
275 AvatarSize::Sm => 32,
276 AvatarSize::Md => 40,
277 AvatarSize::Lg => 48,
278 AvatarSize::Xl => 64,
279 AvatarSize::Xxl => 96,
280 }
281 }
282}
283
284#[component]
286pub fn SkeletonAvatar(props: SkeletonAvatarProps) -> Element {
287 let size = props.size.to_px();
288
289 rsx! {
290 Skeleton {
291 shape: SkeletonShape::Circle,
292 width: "{size}px",
293 height: "{size}px",
294 animated: props.animated,
295 }
296 }
297}
298
299#[derive(Props, Clone, PartialEq)]
301pub struct SkeletonListProps {
302 #[props(default = 5)]
304 pub items: u8,
305 #[props(default = true)]
307 pub show_avatar: bool,
308 #[props(default = 2)]
310 pub lines: u8,
311 #[props(default = true)]
313 pub animated: bool,
314 #[props(default)]
316 pub class: Option<String>,
317}
318
319#[component]
321pub fn SkeletonList(props: SkeletonListProps) -> Element {
322 let class_css = props
323 .class
324 .as_ref()
325 .map(|c| format!(" {}", c))
326 .unwrap_or_default();
327
328 rsx! {
329 div {
330 class: "skeleton-list{class_css}",
331 style: "display: flex; flex-direction: column;",
332
333 for i in 0..props.items {
334 div {
335 key: "{i}",
336 style: "display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid #e2e8f0;",
337
338 if props.show_avatar {
339 SkeletonAvatar {
340 size: AvatarSize::Md,
341 animated: props.animated,
342 }
343 }
344
345 div {
346 style: "flex: 1;",
347
348 SkeletonText {
349 lines: props.lines,
350 animated: props.animated,
351 }
352 }
353 }
354 }
355 }
356 }
357}