skeleton-rs 0.0.2

🦴 A highly customizable skeleton component for WASM frameworks like Yew, Dioxus, and Leptos.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
#![doc = include_str!("../YEW.md")]

use crate::common::{Animation, Direction, Theme, Variant};
use gloo_timers::callback::Timeout;
use web_sys::js_sys;
use web_sys::wasm_bindgen::JsCast;
use web_sys::wasm_bindgen::prelude::*;
use web_sys::window;
use web_sys::{HtmlElement, IntersectionObserver, IntersectionObserverEntry};
use yew::prelude::*;

/// Properties for the `Skeleton` component.
#[derive(Properties, PartialEq, Clone)]
pub struct SkeletonProps {
    /// Child elements to render inside the skeleton.
    ///
    /// If provided, the children will be wrapped with the skeleton styling and animation.
    #[prop_or_default]
    pub children: Children,

    /// The visual variant of the skeleton.
    ///
    /// Variants control the shape or type of the skeleton placeholder, such as text or circle.
    /// Defaults to `Variant::Text`.
    #[prop_or_default]
    pub variant: Variant,

    /// Animation style applied to the skeleton.
    ///
    /// Controls how the skeleton animates, e.g., pulse, wave, etc.
    /// Defaults to `Animation::Pulse`.
    #[prop_or_default]
    pub animation: Animation,

    /// Direction of the animation direction and background color gradient.
    #[prop_or_default]
    pub direction: Direction,

    /// The theme of the skeleton appearance.
    ///
    /// Allows switching between light or dark themes.
    /// Defaults to `Theme::Light`.
    #[prop_or_default]
    pub theme: Theme,

    /// The width of the skeleton.
    ///
    /// Accepts any valid CSS width value (e.g., `100%`, `200px`, `10rem`). Defaults to `"100%"`.
    #[prop_or("100%")]
    pub width: &'static str,

    /// The height of the skeleton.
    ///
    /// Accepts any valid CSS height value. Defaults to `"1em"`.
    #[prop_or("1em")]
    pub height: &'static str,

    /// Optional font size for the skeleton text.
    ///
    /// Used to size the placeholder in proportion to text elements. If not set, font size is not applied.
    #[prop_or(None)]
    pub font_size: Option<&'static str>,

    /// Border radius for the skeleton.
    ///
    /// Controls the rounding of the skeleton's corners. Accepts any valid CSS radius.
    /// Defaults to `"4px"`.
    #[prop_or("4px")]
    pub border_radius: &'static str,

    /// Display property for the skeleton.
    ///
    /// Determines the skeleton's display type (e.g., `inline-block`, `block`). Defaults to `"inline-block"`.
    #[prop_or("inline-block")]
    pub display: &'static str,

    /// Line height of the skeleton content.
    ///
    /// This affects vertical spacing in text-like skeletons. Defaults to `"1"`.
    #[prop_or("1")]
    pub line_height: &'static str,

    /// The CSS `position` property.
    ///
    /// Controls how the skeleton is positioned. Defaults to `"relative"`.
    #[prop_or("relative")]
    pub position: &'static str,

    /// Overflow behavior of the skeleton container.
    ///
    /// Accepts values like `hidden`, `visible`, etc. Defaults to `"hidden"`.
    #[prop_or("hidden")]
    pub overflow: &'static str,

    /// Margin applied to the skeleton.
    ///
    /// Accepts any valid CSS margin value. Defaults to `""`.
    #[prop_or_default]
    pub margin: &'static str,

    /// Additional inline styles.
    ///
    /// Allows you to append arbitrary CSS to the skeleton component. Useful for quick overrides.
    #[prop_or_default]
    pub custom_style: &'static str,

    /// Whether to automatically infer the size from children.
    ///
    /// If `true`, the skeleton will try to match the dimensions of its content.
    #[prop_or(false)]
    pub infer_size: bool,

    /// Whether the skeleton is currently visible.
    ///
    /// Controls whether the skeleton should be rendered or hidden.
    #[prop_or(false)]
    pub show: bool,

    /// Delay before the skeleton becomes visible, in milliseconds.
    ///
    /// Useful for preventing flicker on fast-loading content. Defaults to `0`.
    #[prop_or(0)]
    pub delay_ms: u32,

    /// Whether the skeleton is responsive.
    ///
    /// Enables responsive resizing behavior based on the parent container or screen size.
    #[prop_or(false)]
    pub responsive: bool,

    /// Optional maximum width of the skeleton.
    ///
    /// Accepts any valid CSS width value (e.g., `600px`, `100%`).
    #[prop_or(None)]
    pub max_width: Option<&'static str>,

    /// Optional minimum width of the skeleton.
    ///
    /// Accepts any valid CSS width value.
    #[prop_or(None)]
    pub min_width: Option<&'static str>,

    /// Optional maximum height of the skeleton.
    ///
    /// Accepts any valid CSS height value.
    #[prop_or(None)]
    pub max_height: Option<&'static str>,

    /// Optional minimum height of the skeleton.
    ///
    /// Accepts any valid CSS height value.
    #[prop_or(None)]
    pub min_height: Option<&'static str>,

    /// Whether the skeleton animates on hover.
    ///
    /// When enabled, an animation will be triggered when the user hovers over the skeleton.
    #[prop_or(false)]
    pub animate_on_hover: bool,

    /// Whether the skeleton animates on focus.
    ///
    /// Useful for accessibility - triggers animation when the component receives focus.
    #[prop_or(false)]
    pub animate_on_focus: bool,

    /// Whether the skeleton animates on active (click or tap).
    ///
    /// Triggers animation when the skeleton is actively clicked or touched.
    #[prop_or(false)]
    pub animate_on_active: bool,

    /// Whether the skeleton animates when it becomes visible in the viewport.
    ///
    /// Uses `IntersectionObserver` to detect visibility and trigger animation.
    #[prop_or(false)]
    pub animate_on_visible: bool,
}

/// Skeleton Component
///
/// A flexible and customizable `Skeleton` component for Yew applications, ideal for
/// rendering placeholder content during loading states. It provides support for
/// animations, visibility-based rendering, and responsive behavior.
///
/// # Properties
/// The component uses the `SkeletonProps` struct for its properties. Key properties include:
///
/// # Features
/// - **Viewport-aware Animation**: When `animate_on_visible` is enabled, the component uses `IntersectionObserver` to trigger animation only when scrolled into view.
///
/// - **Delay Support**: Prevents immediate rendering using the `delay_ms` prop, useful for avoiding flash of skeletons for fast-loading content.
///
/// - **Responsive Layout**: With the `responsive` prop, skeletons scale naturally across screen sizes.
///
/// - **State-controlled Rendering**: You can explicitly show or hide the skeleton using the `show` prop or control visibility dynamically.
///
/// - **Slot Support**:
///   You can pass children to be wrapped in the skeleton effect, especially useful for text or UI blocks.
///
/// # Examples
///
/// ## Basic Usage
/// ```rust
/// use yew::prelude::*;
/// use skeleton_rs::yew::{Skeleton, SkeletonGroup};
/// use skeleton_rs::{Animation, Theme, Variant};
///
/// #[function_component(App)]
/// pub fn app() -> Html {
///     html! {
///         <Skeleton width="200px" height="1.5em" />
///     }
/// }
/// ```
///
/// ## Text Placeholder
/// ```rust
/// use yew::prelude::*;
/// use skeleton_rs::yew::{Skeleton, SkeletonGroup};
/// use skeleton_rs::{Animation, Theme, Variant};
///
/// #[function_component(App)]
/// pub fn app() -> Html {
///     html! {
///         <Skeleton variant={Variant::Text} width="100%" height="1.2em" />
///     }
/// }
/// ```
///
/// ## Responsive with Inferred Size
/// ```rust
/// use yew::prelude::*;
/// use skeleton_rs::yew::{Skeleton, SkeletonGroup};
/// use skeleton_rs::{Animation, Theme, Variant};
///
/// #[function_component(App)]
/// pub fn app() -> Html {
///     html! {
///         <Skeleton infer_size={true} responsive={true}>
///             <p>{ "Loading text..." }</p>
///         </Skeleton>
///     }
/// }
/// ```
///
/// ## Animate When Visible
/// ```rust
/// use yew::prelude::*;
/// use skeleton_rs::yew::{Skeleton, SkeletonGroup};
/// use skeleton_rs::{Animation, Theme, Variant};
///
/// #[function_component(App)]
/// pub fn app() -> Html {
///     html! {
///         <Skeleton
///             variant={Variant::Text}
///             animate_on_visible={true}
///             height="2em"
///             width="80%"
///         />
///     }
/// }
/// ```
///
/// # Behavior
/// - When `animate_on_visible` is enabled, animation starts only once the component enters the viewport.
/// - If `show` is set to `false`, the component initializes hidden and reveals itself based on internal or external logic.
/// - You can customize almost all styles using props.
///
/// # Accessibility
/// - Skeletons typically represent non-interactive placeholders and do not interfere with screen readers.
/// - Consider pairing them with appropriate ARIA `aria-busy`, `aria-hidden`, or live regions on the parent container for accessibility.
///
/// # Notes
/// - The component uses `NodeRef` internally to observe visibility changes.
/// - The `children` prop allows rendering actual elements inside the skeleton, which get masked by the animation.
///
/// # See Also
/// - [MDN IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
#[function_component(Skeleton)]
pub fn skeleton(props: &SkeletonProps) -> Html {
    let node_ref = use_node_ref();
    let visible = use_state(|| !props.show);
    let direction = props.direction.clone();

    let props_clone = props.clone();
    let visible_clone = visible.clone();

    {
        let visible = visible.clone();
        use_effect_with((props_clone.show,), move |_| {
            if props_clone.show {
                visible.set(false);
            } else if props_clone.delay_ms > 0 {
                let timeout = Timeout::new(props_clone.delay_ms, move || {
                    visible_clone.set(true);
                });
                timeout.forget();
            } else {
                visible.set(true);
            }
            || ()
        });
    }

    {
        let node_ref = node_ref.clone();
        let visible = visible.clone();

        use_effect_with(
            (node_ref.clone(), props.animate_on_visible),
            move |(node_ref, animate_on_visible)| {
                if !*animate_on_visible {
                    return;
                }

                let element = node_ref.cast::<HtmlElement>();
                if let Some(element) = element {
                    let cb = Closure::wrap(Box::new(
                        move |entries: js_sys::Array, _observer: IntersectionObserver| {
                            for entry in entries.iter() {
                                let entry = entry.unchecked_into::<IntersectionObserverEntry>();
                                if entry.is_intersecting() {
                                    visible.set(true);
                                }
                            }
                        },
                    )
                        as Box<dyn FnMut(js_sys::Array, IntersectionObserver)>);

                    let observer = IntersectionObserver::new(cb.as_ref().unchecked_ref()).unwrap();
                    observer.observe(&element);

                    cb.forget();
                }
            },
        );
    }

    let background_color = match props.theme {
        Theme::Light => "#e0e0e0",
        Theme::Dark => "#444444",
        Theme::Custom(color) => color,
    };

    let effective_radius = match props.variant {
        Variant::Circular | Variant::Avatar => "50%",
        Variant::Rectangular => "0",
        Variant::Rounded => "8px",
        Variant::Button => "6px",
        Variant::Text | Variant::Image => props.border_radius,
    };
    let (keyframes_name, wave_keyframes) = match direction {
        Direction::LeftToRight => (
            "skeleton-wave-ltr",
            r#"
            @keyframes skeleton-wave-ltr {
                0%   { background-position: 200% 0; }
                100% { background-position: -200% 0; }
            }
            "#,
        ),
        Direction::RightToLeft => (
            "skeleton-wave-rtl",
            r#"
            @keyframes skeleton-wave-rtl {
                0% { background-position: -200% 0; }
                100% { background-position: 200% 0; }
            }
            "#,
        ),
        Direction::TopToBottom => (
            "skeleton-wave-ttb",
            r#"
            @keyframes skeleton-wave-ttb {
                0%   { background-position: 0 -200%; }
                100% { background-position: 0 200%; }
            }
            "#,
        ),
        Direction::BottomToTop => (
            "skeleton-wave-btt",
            r#"
            @keyframes skeleton-wave-btt {
                0%   { background-position: 0 200%; }
                100% { background-position: 0 -200%; }
            }
            "#,
        ),
        Direction::CustomAngle(_) => (
            "skeleton-wave-custom",
            r#"
            @keyframes skeleton-wave-custom {
                0%   { background-position: 200% 0; }
                100% { background-position: -200% 0; }
            }
            "#,
        ),
    };

    let base_animation = match props.animation {
        Animation::Pulse => "animation: skeleton-rs-pulse 1.5s ease-in-out infinite;".to_string(),

        Animation::Wave => {
            let angle = match direction {
                Direction::LeftToRight => 90,
                Direction::RightToLeft => 90,
                Direction::TopToBottom => 90,
                Direction::BottomToTop => 90,
                Direction::CustomAngle(deg) => deg,
            };

            format!(
                "background: linear-gradient({}deg, #e0e0e0 25%, #f5f5f5 50%, #e0e0e0 75%);
                 background-size: 200% 100%;
                 animation: {} 1.6s linear infinite;",
                angle, keyframes_name
            )
        }

        Animation::None => "".to_string(),
    };

    let mut style = String::new();

    if props.infer_size {
        style.push_str(&format!(
            "background-color: {background_color}; border-radius: {effective_radius}; display: {}; position: {}; overflow: {}; margin: {};",
            props.display, props.position, props.overflow, props.margin
        ));
    } else {
        style.push_str(&format!(
            "width: {}; height: {}; background-color: {background_color}; border-radius: {effective_radius}; display: {}; position: {}; overflow: {}; margin: {}; line-height: {};",
            props.width, props.height, props.display, props.position, props.overflow, props.margin, props.line_height
        ));
    }

    if let Some(size) = props.font_size {
        style.push_str(&format!(" font-size: {size};"));
    }

    if let Some(max_w) = props.max_width {
        style.push_str(&format!(" max-width: {max_w};"));
    }
    if let Some(min_w) = props.min_width {
        style.push_str(&format!(" min-width: {min_w};"));
    }
    if let Some(max_h) = props.max_height {
        style.push_str(&format!(" max-height: {max_h};"));
    }
    if let Some(min_h) = props.min_height {
        style.push_str(&format!(" min-height: {min_h};"));
    }

    style.push_str(&base_animation);
    style.push_str(props.custom_style);

    let mut class_names = String::from("skeleton-rs");
    if props.animate_on_hover {
        class_names.push_str(" skeleton-hover");
    }
    if props.animate_on_focus {
        class_names.push_str(" skeleton-focus");
    }
    if props.animate_on_active {
        class_names.push_str(" skeleton-active");
    }
    use_effect_with((), move |_| {
        if let Some(doc) = window().and_then(|w| w.document()) {
            if doc.get_element_by_id("skeleton-rs-style").is_none() {
                let style_elem = doc.create_element("style").unwrap();
                style_elem.set_id("skeleton-rs-style");
                let style_css = format!(
                    r#"
                    @keyframes skeleton-rs-pulse {{
                        0% {{ opacity: 1; }}
                        25% {{ opacity: 0.7; }}
                        50% {{ opacity: 0.4; }}
                        75% {{ opacity: 0.7; }}
                        100% {{ opacity: 1; }}
                    }}

                    {}

                    .skeleton-hover:hover {{
                        filter: brightness(0.95);
                    }}

                    .skeleton-focus:focus {{
                        outline: 2px solid #999;
                    }}

                    .skeleton-active:active {{
                        transform: scale(0.98);
                    }}
                    "#,
                    wave_keyframes
                );
                style_elem.set_inner_html(&style_css);
                if let Some(head) = doc.head() {
                    head.append_child(&style_elem).unwrap();
                }
            }
        }
    });

    if *visible {
        html! {
            <div
                ref={node_ref}
                class={class_names}
                style={style}
                role="presentation"
                aria-hidden="true"
            />
        }
    } else {
        html! { <>{ for props.children.iter() }</> }
    }
}

#[derive(Properties, PartialEq)]
pub struct SkeletonGroupProps {
    #[prop_or_default]
    pub children: ChildrenWithProps<Skeleton>,

    #[prop_or_default]
    pub style: &'static str,

    #[prop_or_default]
    pub class: &'static str,
}

#[function_component(SkeletonGroup)]
pub fn skeleton_group(props: &SkeletonGroupProps) -> Html {
    html! { <div style={props.style} class={props.class}>{ for props.children.iter() }</div> }
}