radix_leptos_primitives/components/
skeleton.rs

1use crate::utils::merge_classes;
2use leptos::children::Children;
3use leptos::prelude::*;
4
5/// Skeleton component - Loading placeholder component for better UX
6///
7/// The Skeleton component provides animated loading placeholders that give users
8/// visual feedback while content is loading, improving perceived performance.
9///
10/// # Features
11/// - Animated shimmer effect
12/// - Multiple variants (text, circular, rectangular)
13/// - Multiple sizes (sm, md, lg, xl)
14/// - Customizable dimensions
15/// - Accessibility-friendly
16/// - Smooth animations
17///
18/// # Example
19///
20/// ```rust
21/// use radix_leptos_primitives::*;
22///
23/// #[component]
24/// fn MyComponent() -> impl IntoView {
25///     let (loading, setloading) = create_signal(true);
26///
27///     view! {
28///         <div class="content">
29///             if loading.get() {
30///                 <div class="loading-state">
31///                     <Skeleton variant=SkeletonVariant::Circular size=SkeletonSize::Large />
32///                     <Skeleton variant=SkeletonVariant::Text lines=3 />
33///                     <Skeleton variant=SkeletonVariant::Rectangular width="200px" height="100px" />
34///                 </div>
35///         </div>
36///     }
37/// }
38/// ```
39
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub enum SkeletonVariant {
42    Text,
43    Circular,
44    Rectangular,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub enum SkeletonSize {
49    Small,
50    Medium,
51    Large,
52    ExtraLarge,
53}
54
55impl SkeletonVariant {
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            SkeletonVariant::Text => "text",
59            SkeletonVariant::Circular => "circular",
60            SkeletonVariant::Rectangular => "rectangular",
61        }
62    }
63}
64
65impl SkeletonSize {
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            SkeletonSize::Small => "sm",
69            SkeletonSize::Medium => "md",
70            SkeletonSize::Large => "lg",
71            SkeletonSize::ExtraLarge => "xl",
72        }
73    }
74}
75
76/// Skeleton component
77#[component]
78pub fn Skeleton(
79    #[prop(optional)] class: Option<String>,
80    #[prop(optional)] style: Option<String>,
81    #[prop(optional)] variant: Option<SkeletonVariant>,
82    #[prop(optional)] size: Option<SkeletonSize>,
83    #[prop(optional)] width: Option<String>,
84    #[prop(optional)] height: Option<String>,
85    #[prop(optional)] lines: Option<usize>,
86    #[prop(optional)] animated: Option<bool>,
87) -> impl IntoView {
88    let variant = variant.unwrap_or(SkeletonVariant::Rectangular);
89    let size = size.unwrap_or(SkeletonSize::Medium);
90    let lines = lines.unwrap_or(1);
91    let animated = animated.unwrap_or(true);
92
93    let class = merge_classes(vec!["skeleton", variant.as_str(), size.as_str()].to_vec());
94
95    let mut style_attr = String::new();
96    if let Some(h) = height {
97        style_attr = format!("{}height: {};", style_attr, h);
98    }
99
100    match variant {
101        SkeletonVariant::Text => view! {
102            <div class=class style=style_attr>
103                {if lines > 1 {
104                    (0..lines).map(|i| {
105                        let line_class = if i == lines - 1 {
106                            "skeleton-line skeleton-line-last"
107                        } else {
108                            "skeleton-line"
109                        };
110                        view! {
111                            <div class=line_class></div>
112                        }
113                    }).collect::<Vec<_>>()
114                } else {
115                    Vec::new()
116                }}
117            </div>
118        }
119        .into_any(),
120        SkeletonVariant::Circular => view! {
121            <div
122                class=class
123                style=style_attr
124                role="img"
125                aria-label="Loading"
126            ></div>
127        }
128        .into_any(),
129        SkeletonVariant::Rectangular => view! {
130            <div
131                class=class
132                style=style_attr
133                role="img"
134                aria-label="Loading"
135            ></div>
136        }
137        .into_any(),
138    }
139}
140
141/// Skeleton group component for multiple skeletons
142#[component]
143pub fn SkeletonGroup(
144    #[prop(optional)] class: Option<String>,
145    #[prop(optional)] style: Option<String>,
146    #[prop(optional)] children: Option<Children>,
147    #[prop(optional)] spacing: Option<String>,
148) -> impl IntoView {
149    let spacing = spacing.unwrap_or_else(|| "1rem".to_string());
150
151    let class = merge_classes(vec!["skeleton-group", class.as_deref().unwrap_or("")].to_vec());
152
153    let style_attr = format!("{}gap: {};", style.unwrap_or_default(), spacing);
154
155    view! {
156        <div
157            class=class
158            style=style_attr
159        >
160            {children.map(|c| c())}
161        </div>
162    }
163}
164
165/// Skeleton text component with multiple lines
166#[component]
167pub fn SkeletonText(
168    #[prop(optional)] class: Option<String>,
169    #[prop(optional)] style: Option<String>,
170    #[prop(optional)] lines: Option<usize>,
171    #[prop(optional)] animated: Option<bool>,
172) -> impl IntoView {
173    let lines = lines.unwrap_or(1);
174    let animated = animated.unwrap_or(true);
175
176    view! {
177        <Skeleton
178            class=class.unwrap_or_default()
179            style=style.unwrap_or_default()
180            variant=SkeletonVariant::Text
181            lines=lines
182            animated=animated
183        />
184    }
185}
186
187/// Skeleton avatar component
188#[component]
189pub fn SkeletonAvatar(
190    #[prop(optional)] class: Option<String>,
191    #[prop(optional)] style: Option<String>,
192    #[prop(optional)] size: Option<SkeletonSize>,
193    #[prop(optional)] animated: Option<bool>,
194) -> impl IntoView {
195    let size = size.unwrap_or(SkeletonSize::Medium);
196    let animated = animated.unwrap_or(true);
197
198    view! {
199        <Skeleton
200            class=class.unwrap_or_default()
201            style=style.unwrap_or_default()
202            variant=SkeletonVariant::Circular
203            size=size
204            animated=animated
205        />
206    }
207}
208
209/// Skeleton button component
210#[component]
211pub fn SkeletonButton(
212    #[prop(optional)] class: Option<String>,
213    #[prop(optional)] style: Option<String>,
214    #[prop(optional)] size: Option<SkeletonSize>,
215    #[prop(optional)] animated: Option<bool>,
216) -> impl IntoView {
217    let size = size.unwrap_or(SkeletonSize::Medium);
218    let animated = animated.unwrap_or(true);
219
220    view! {
221        <Skeleton
222            class=class.unwrap_or_default()
223            style=style.unwrap_or_default()
224            variant=SkeletonVariant::Rectangular
225            size=size
226            animated=animated
227        />
228    }
229}
230
231// Helper function to merge CSS classes
232
233#[cfg(test)]
234mod tests {
235    use proptest::prelude::*;
236
237    #[test]
238    fn test_skeleton_component_creation() {}
239
240    #[test]
241    fn test_skeleton_with_variant_component_creation() {}
242
243    proptest! {
244        #[test]
245        fn test_skeleton_props(___class in ".*", ___style in ".*") {
246
247        }
248
249        #[test]
250        fn test_skeleton_variants(___variant_index in 0..3usize, ___size_index in 0..4usize) {
251
252        }
253
254        #[test]
255        fn test_skeleton_sizes(___size_index in 0..4usize) {
256
257        }
258
259        #[test]
260        fn test_skeleton_dimensions(_width in ".*", _height in ".*") {
261
262        }
263
264        #[test]
265        fn test_skeleton_lines(___lines in 1..10usize) {
266
267        }
268
269        #[test]
270        fn test_skeleton_animation(___animated: bool) {
271
272        }
273
274        #[test]
275        fn test_skeleton_group_props(___class in ".*", ___style in ".*", _spacing in ".*") {
276
277        }
278
279        #[test]
280        fn test_skeleton_text_props(___class in ".*", ___style in ".*", ___lines in 1..5usize) {
281
282        }
283
284        #[test]
285        fn test_skeleton_avatar_props(___class in ".*", ___style in ".*", ___size_index in 0..4usize) {
286
287        }
288
289        #[test]
290        fn test_skeleton_button_props(___class in ".*", ___style in ".*", ___size_index in 0..4usize) {
291
292        }
293    }
294}