Skip to main content

maud_ui/primitives/
skeleton.rs

1//! Skeleton component — loading placeholder with shimmer animation.
2
3use maud::{html, Markup};
4
5/// Skeleton variant
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Variant {
8    Text,
9    Circle,
10    Rect,
11}
12
13impl Variant {
14    fn class(&self) -> &'static str {
15        match self {
16            Self::Text => "mui-skeleton--text",
17            Self::Circle => "mui-skeleton--circle",
18            Self::Rect => "mui-skeleton--rect",
19        }
20    }
21}
22
23/// Skeleton rendering properties
24#[derive(Debug, Clone)]
25pub struct Props {
26    /// Visual variant
27    pub variant: Variant,
28    /// Optional width override
29    pub width: Option<String>,
30    /// Optional height override
31    pub height: Option<String>,
32}
33
34impl Default for Props {
35    fn default() -> Self {
36        Self {
37            variant: Variant::Rect,
38            width: None,
39            height: None,
40        }
41    }
42}
43
44/// Render a single skeleton with the given properties
45pub fn render(props: Props) -> Markup {
46    let width_style = props.width.as_ref().map(|w| format!("width:{};", w));
47    let height_style = props.height.as_ref().map(|h| format!("height:{};", h));
48    let style = format!(
49        "{}{}",
50        width_style.unwrap_or_default(),
51        height_style.unwrap_or_default()
52    );
53
54    if style.is_empty() {
55        html! {
56            div class={"mui-skeleton " (props.variant.class())} aria-hidden="true" {}
57        }
58    } else {
59        html! {
60            div class={"mui-skeleton " (props.variant.class())} style=(style) aria-hidden="true" {}
61        }
62    }
63}
64
65/// Showcase all skeleton variants and use cases
66pub fn showcase() -> Markup {
67    html! {
68        div.mui-showcase__grid {
69            // Loading tweet/post card
70            div {
71                p.mui-showcase__caption { "Loading post" }
72                div style="display:flex;gap:0.75rem;padding:1rem;max-width:24rem;border:1px solid var(--mui-border,#e5e7eb);border-radius:var(--mui-radius-lg);" {
73                    // Avatar
74                    (render(Props {
75                        variant: Variant::Circle,
76                        width: Some("2.75rem".into()),
77                        height: Some("2.75rem".into()),
78                    }))
79                    div.mui-showcase__column style="flex:1;gap:0.5rem;min-width:0;" {
80                        // Handle + timestamp row
81                        div style="display:flex;gap:0.5rem;align-items:center;" {
82                            (render(Props {
83                                variant: Variant::Text,
84                                width: Some("6rem".into()),
85                                height: Some("0.875rem".into()),
86                            }))
87                            (render(Props {
88                                variant: Variant::Text,
89                                width: Some("3rem".into()),
90                                height: Some("0.75rem".into()),
91                            }))
92                        }
93                        // Body lines
94                        (render(Props {
95                            variant: Variant::Text,
96                            width: Some("100%".into()),
97                            height: None,
98                        }))
99                        (render(Props {
100                            variant: Variant::Text,
101                            width: Some("92%".into()),
102                            height: None,
103                        }))
104                        (render(Props {
105                            variant: Variant::Text,
106                            width: Some("65%".into()),
107                            height: None,
108                        }))
109                        // Action row
110                        div style="display:flex;gap:1.5rem;margin-top:0.25rem;" {
111                            (render(Props {
112                                variant: Variant::Text,
113                                width: Some("2rem".into()),
114                                height: Some("0.75rem".into()),
115                            }))
116                            (render(Props {
117                                variant: Variant::Text,
118                                width: Some("2rem".into()),
119                                height: Some("0.75rem".into()),
120                            }))
121                            (render(Props {
122                                variant: Variant::Text,
123                                width: Some("2rem".into()),
124                                height: Some("0.75rem".into()),
125                            }))
126                        }
127                    }
128                }
129            }
130
131            // Loading table row
132            div {
133                p.mui-showcase__caption { "Loading table row" }
134                div style="display:flex;flex-direction:column;gap:0;max-width:32rem;border:1px solid var(--mui-border);border-radius:var(--mui-radius-lg);overflow:hidden;" {
135                    // Header row (real, so the skeleton has context)
136                    div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:1rem;padding:0.625rem 0.875rem;background:var(--mui-bg-input);font-size:0.75rem;font-weight:600;color:var(--mui-text);text-transform:uppercase;letter-spacing:0.04em;" {
137                        span { "Customer" }
138                        span { "Plan" }
139                        span { "Status" }
140                        span { "MRR" }
141                    }
142                    // Skeleton rows
143                    @for _ in 0..3 {
144                        div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:1rem;padding:0.75rem 0.875rem;align-items:center;border-top:1px solid var(--mui-border);" {
145                            div style="display:flex;align-items:center;gap:0.625rem;" {
146                                (render(Props {
147                                    variant: Variant::Circle,
148                                    width: Some("1.75rem".into()),
149                                    height: Some("1.75rem".into()),
150                                }))
151                                (render(Props {
152                                    variant: Variant::Text,
153                                    width: Some("8rem".into()),
154                                    height: Some("0.875rem".into()),
155                                }))
156                            }
157                            (render(Props {
158                                variant: Variant::Text,
159                                width: Some("4rem".into()),
160                                height: Some("0.875rem".into()),
161                            }))
162                            (render(Props {
163                                variant: Variant::Rect,
164                                width: Some("4.5rem".into()),
165                                height: Some("1.25rem".into()),
166                            }))
167                            (render(Props {
168                                variant: Variant::Text,
169                                width: Some("3rem".into()),
170                                height: Some("0.875rem".into()),
171                            }))
172                        }
173                    }
174                }
175            }
176
177            // Loading product card
178            div {
179                p.mui-showcase__caption { "Loading product card" }
180                div style="display:flex;flex-direction:column;gap:0.75rem;max-width:16rem;padding:0.75rem;border:1px solid var(--mui-border,#e5e7eb);border-radius:var(--mui-radius-lg);" {
181                    // Product image
182                    (render(Props {
183                        variant: Variant::Rect,
184                        width: Some("100%".into()),
185                        height: Some("11rem".into()),
186                    }))
187                    // Title
188                    (render(Props {
189                        variant: Variant::Text,
190                        width: Some("85%".into()),
191                        height: Some("1rem".into()),
192                    }))
193                    // Subtitle / color
194                    (render(Props {
195                        variant: Variant::Text,
196                        width: Some("55%".into()),
197                        height: Some("0.75rem".into()),
198                    }))
199                    // Price row
200                    div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.25rem;" {
201                        (render(Props {
202                            variant: Variant::Text,
203                            width: Some("4rem".into()),
204                            height: Some("1.125rem".into()),
205                        }))
206                        (render(Props {
207                            variant: Variant::Rect,
208                            width: Some("2rem".into()),
209                            height: Some("2rem".into()),
210                        }))
211                    }
212                }
213            }
214        }
215    }
216}