Skip to main content

dioxus_ui_system/atoms/
skeleton.rs

1//! Skeleton loader atom component
2//!
3//! Loading placeholders that mimic content structure.
4
5use crate::theme::use_theme;
6use dioxus::prelude::*;
7
8/// Skeleton shape variant
9#[derive(Default, Clone, PartialEq, Debug)]
10pub enum SkeletonShape {
11    #[default]
12    Rectangle,
13    Circle,
14    Text,
15    Rounded,
16}
17
18/// Skeleton properties
19#[derive(Props, Clone, PartialEq)]
20pub struct SkeletonProps {
21    /// Shape variant
22    #[props(default = SkeletonShape::Rectangle)]
23    pub shape: SkeletonShape,
24    /// Width (CSS value or "auto")
25    /// Width (CSS value or "auto")
26    #[props(default)]
27    pub width: Option<String>,
28    /// Height (CSS value or "auto")
29    #[props(default)]
30    pub height: Option<String>,
31    /// Enable animation
32    #[props(default = true)]
33    pub animated: bool,
34    /// Animation variant
35    #[props(default = SkeletonAnimation::Shimmer)]
36    pub animation: SkeletonAnimation,
37    /// Base color
38    pub color: Option<String>,
39    /// Highlight color for animation
40    pub highlight_color: Option<String>,
41    /// Additional CSS classes
42    #[props(default)]
43    pub class: Option<String>,
44}
45
46/// Skeleton animation type
47#[derive(Default, Clone, PartialEq, Debug)]
48pub enum SkeletonAnimation {
49    #[default]
50    Shimmer,
51    Pulse,
52    Wave,
53}
54
55/// Skeleton loader component
56#[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        // Slightly lighter version of base color
66        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/// Skeleton text properties (multiple lines)
112#[derive(Props, Clone, PartialEq)]
113pub struct SkeletonTextProps {
114    /// Number of lines
115    #[props(default = 3)]
116    pub lines: u8,
117    /// Line height
118    #[props(default = 1.5)]
119    pub line_height: f32,
120    /// Enable animation
121    #[props(default = true)]
122    pub animated: bool,
123    /// Last line width (as percentage)
124    #[props(default = 80)]
125    pub last_line_width: u8,
126    /// Additional CSS classes
127    #[props(default)]
128    pub class: Option<String>,
129}
130
131/// Skeleton text loader (multiple lines)
132#[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/// Skeleton card properties
166#[derive(Props, Clone, PartialEq)]
167pub struct SkeletonCardProps {
168    /// Show image area
169    #[props(default = true)]
170    pub show_image: bool,
171    /// Image height
172    #[props(default)]
173    pub image_height: Option<String>,
174    /// Number of text lines
175    #[props(default = 3)]
176    pub text_lines: u8,
177    /// Show action buttons
178    #[props(default = true)]
179    pub show_actions: bool,
180    /// Enable animation
181    #[props(default = true)]
182    pub animated: bool,
183    /// Additional CSS classes
184    #[props(default)]
185    pub class: Option<String>,
186}
187
188/// Skeleton card loader
189#[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/// Skeleton avatar properties
244#[derive(Props, Clone, PartialEq)]
245pub struct SkeletonAvatarProps {
246    /// Size
247    #[props(default = AvatarSize::Md)]
248    pub size: AvatarSize,
249    /// Enable animation
250    #[props(default = true)]
251    pub animated: bool,
252}
253
254/// Avatar size
255#[derive(Clone, PartialEq, Debug)]
256pub enum AvatarSize {
257    Xs,  // 24px
258    Sm,  // 32px
259    Md,  // 40px
260    Lg,  // 48px
261    Xl,  // 64px
262    Xxl, // 96px
263}
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/// Skeleton avatar loader
285#[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/// Skeleton list properties
300#[derive(Props, Clone, PartialEq)]
301pub struct SkeletonListProps {
302    /// Number of items
303    #[props(default = 5)]
304    pub items: u8,
305    /// Show avatar
306    #[props(default = true)]
307    pub show_avatar: bool,
308    /// Number of text lines per item
309    #[props(default = 2)]
310    pub lines: u8,
311    /// Enable animation
312    #[props(default = true)]
313    pub animated: bool,
314    /// Additional CSS classes
315    #[props(default)]
316    pub class: Option<String>,
317}
318
319/// Skeleton list loader
320#[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}