skeleton_rs/
yew.rs

1#![doc = include_str!("../YEW.md")]
2
3use crate::common::{Animation, Direction, Theme, Variant};
4use gloo_timers::callback::Timeout;
5use web_sys::js_sys;
6use web_sys::wasm_bindgen::JsCast;
7use web_sys::wasm_bindgen::prelude::*;
8use web_sys::window;
9use web_sys::{HtmlElement, IntersectionObserver, IntersectionObserverEntry};
10use yew::prelude::*;
11
12/// Properties for the `Skeleton` component.
13#[derive(Properties, PartialEq, Clone)]
14pub struct SkeletonProps {
15    /// Child elements to render inside the skeleton.
16    ///
17    /// If provided, the children will be wrapped with the skeleton styling and animation.
18    #[prop_or_default]
19    pub children: Children,
20
21    /// The visual variant of the skeleton.
22    ///
23    /// Variants control the shape or type of the skeleton placeholder, such as text or circle.
24    /// Defaults to `Variant::Text`.
25    #[prop_or_default]
26    pub variant: Variant,
27
28    /// Animation style applied to the skeleton.
29    ///
30    /// Controls how the skeleton animates, e.g., pulse, wave, etc.
31    /// Defaults to `Animation::Pulse`.
32    #[prop_or_default]
33    pub animation: Animation,
34
35    /// Direction of the animation direction and background color gradient.
36    #[prop_or_default]
37    pub direction: Direction,
38
39    /// The theme of the skeleton appearance.
40    ///
41    /// Allows switching between light or dark themes.
42    /// Defaults to `Theme::Light`.
43    #[prop_or_default]
44    pub theme: Theme,
45
46    /// The width of the skeleton.
47    ///
48    /// Accepts any valid CSS width value (e.g., `100%`, `200px`, `10rem`). Defaults to `"100%"`.
49    #[prop_or("100%")]
50    pub width: &'static str,
51
52    /// The height of the skeleton.
53    ///
54    /// Accepts any valid CSS height value. Defaults to `"1em"`.
55    #[prop_or("1em")]
56    pub height: &'static str,
57
58    /// Optional font size for the skeleton text.
59    ///
60    /// Used to size the placeholder in proportion to text elements. If not set, font size is not applied.
61    #[prop_or(None)]
62    pub font_size: Option<&'static str>,
63
64    /// Border radius for the skeleton.
65    ///
66    /// Controls the rounding of the skeleton's corners. Accepts any valid CSS radius.
67    /// Defaults to `"4px"`.
68    #[prop_or("4px")]
69    pub border_radius: &'static str,
70
71    /// Display property for the skeleton.
72    ///
73    /// Determines the skeleton's display type (e.g., `inline-block`, `block`). Defaults to `"inline-block"`.
74    #[prop_or("inline-block")]
75    pub display: &'static str,
76
77    /// Line height of the skeleton content.
78    ///
79    /// This affects vertical spacing in text-like skeletons. Defaults to `"1"`.
80    #[prop_or("1")]
81    pub line_height: &'static str,
82
83    /// The CSS `position` property.
84    ///
85    /// Controls how the skeleton is positioned. Defaults to `"relative"`.
86    #[prop_or("relative")]
87    pub position: &'static str,
88
89    /// Overflow behavior of the skeleton container.
90    ///
91    /// Accepts values like `hidden`, `visible`, etc. Defaults to `"hidden"`.
92    #[prop_or("hidden")]
93    pub overflow: &'static str,
94
95    /// Margin applied to the skeleton.
96    ///
97    /// Accepts any valid CSS margin value. Defaults to `""`.
98    #[prop_or_default]
99    pub margin: &'static str,
100
101    /// Additional inline styles.
102    ///
103    /// Allows you to append arbitrary CSS to the skeleton component. Useful for quick overrides.
104    #[prop_or_default]
105    pub custom_style: &'static str,
106
107    /// Whether to automatically infer the size from children.
108    ///
109    /// If `true`, the skeleton will try to match the dimensions of its content.
110    #[prop_or(false)]
111    pub infer_size: bool,
112
113    /// Whether the skeleton is currently visible.
114    ///
115    /// Controls whether the skeleton should be rendered or hidden.
116    #[prop_or(false)]
117    pub show: bool,
118
119    /// Delay before the skeleton becomes visible, in milliseconds.
120    ///
121    /// Useful for preventing flicker on fast-loading content. Defaults to `0`.
122    #[prop_or(0)]
123    pub delay_ms: u32,
124
125    /// Whether the skeleton is responsive.
126    ///
127    /// Enables responsive resizing behavior based on the parent container or screen size.
128    #[prop_or(false)]
129    pub responsive: bool,
130
131    /// Optional maximum width of the skeleton.
132    ///
133    /// Accepts any valid CSS width value (e.g., `600px`, `100%`).
134    #[prop_or(None)]
135    pub max_width: Option<&'static str>,
136
137    /// Optional minimum width of the skeleton.
138    ///
139    /// Accepts any valid CSS width value.
140    #[prop_or(None)]
141    pub min_width: Option<&'static str>,
142
143    /// Optional maximum height of the skeleton.
144    ///
145    /// Accepts any valid CSS height value.
146    #[prop_or(None)]
147    pub max_height: Option<&'static str>,
148
149    /// Optional minimum height of the skeleton.
150    ///
151    /// Accepts any valid CSS height value.
152    #[prop_or(None)]
153    pub min_height: Option<&'static str>,
154
155    /// Whether the skeleton animates on hover.
156    ///
157    /// When enabled, an animation will be triggered when the user hovers over the skeleton.
158    #[prop_or(false)]
159    pub animate_on_hover: bool,
160
161    /// Whether the skeleton animates on focus.
162    ///
163    /// Useful for accessibility - triggers animation when the component receives focus.
164    #[prop_or(false)]
165    pub animate_on_focus: bool,
166
167    /// Whether the skeleton animates on active (click or tap).
168    ///
169    /// Triggers animation when the skeleton is actively clicked or touched.
170    #[prop_or(false)]
171    pub animate_on_active: bool,
172
173    /// Whether the skeleton animates when it becomes visible in the viewport.
174    ///
175    /// Uses `IntersectionObserver` to detect visibility and trigger animation.
176    #[prop_or(false)]
177    pub animate_on_visible: bool,
178}
179
180/// Skeleton Component
181///
182/// A flexible and customizable `Skeleton` component for Yew applications, ideal for
183/// rendering placeholder content during loading states. It provides support for
184/// animations, visibility-based rendering, and responsive behavior.
185///
186/// # Properties
187/// The component uses the `SkeletonProps` struct for its properties. Key properties include:
188///
189/// # Features
190/// - **Viewport-aware Animation**: When `animate_on_visible` is enabled, the component uses `IntersectionObserver` to trigger animation only when scrolled into view.
191///
192/// - **Delay Support**: Prevents immediate rendering using the `delay_ms` prop, useful for avoiding flash of skeletons for fast-loading content.
193///
194/// - **Responsive Layout**: With the `responsive` prop, skeletons scale naturally across screen sizes.
195///
196/// - **State-controlled Rendering**: You can explicitly show or hide the skeleton using the `show` prop or control visibility dynamically.
197///
198/// - **Slot Support**:
199///   You can pass children to be wrapped in the skeleton effect, especially useful for text or UI blocks.
200///
201/// # Examples
202///
203/// ## Basic Usage
204/// ```rust
205/// use yew::prelude::*;
206/// use skeleton_rs::yew::{Skeleton, SkeletonGroup};
207/// use skeleton_rs::{Animation, Theme, Variant};
208///
209/// #[function_component(App)]
210/// pub fn app() -> Html {
211///     html! {
212///         <Skeleton width="200px" height="1.5em" />
213///     }
214/// }
215/// ```
216///
217/// ## Text Placeholder
218/// ```rust
219/// use yew::prelude::*;
220/// use skeleton_rs::yew::{Skeleton, SkeletonGroup};
221/// use skeleton_rs::{Animation, Theme, Variant};
222///
223/// #[function_component(App)]
224/// pub fn app() -> Html {
225///     html! {
226///         <Skeleton variant={Variant::Text} width="100%" height="1.2em" />
227///     }
228/// }
229/// ```
230///
231/// ## Responsive with Inferred Size
232/// ```rust
233/// use yew::prelude::*;
234/// use skeleton_rs::yew::{Skeleton, SkeletonGroup};
235/// use skeleton_rs::{Animation, Theme, Variant};
236///
237/// #[function_component(App)]
238/// pub fn app() -> Html {
239///     html! {
240///         <Skeleton infer_size={true} responsive={true}>
241///             <p>{ "Loading text..." }</p>
242///         </Skeleton>
243///     }
244/// }
245/// ```
246///
247/// ## Animate When Visible
248/// ```rust
249/// use yew::prelude::*;
250/// use skeleton_rs::yew::{Skeleton, SkeletonGroup};
251/// use skeleton_rs::{Animation, Theme, Variant};
252///
253/// #[function_component(App)]
254/// pub fn app() -> Html {
255///     html! {
256///         <Skeleton
257///             variant={Variant::Text}
258///             animate_on_visible={true}
259///             height="2em"
260///             width="80%"
261///         />
262///     }
263/// }
264/// ```
265///
266/// # Behavior
267/// - When `animate_on_visible` is enabled, animation starts only once the component enters the viewport.
268/// - If `show` is set to `false`, the component initializes hidden and reveals itself based on internal or external logic.
269/// - You can customize almost all styles using props.
270///
271/// # Accessibility
272/// - Skeletons typically represent non-interactive placeholders and do not interfere with screen readers.
273/// - Consider pairing them with appropriate ARIA `aria-busy`, `aria-hidden`, or live regions on the parent container for accessibility.
274///
275/// # Notes
276/// - The component uses `NodeRef` internally to observe visibility changes.
277/// - The `children` prop allows rendering actual elements inside the skeleton, which get masked by the animation.
278///
279/// # See Also
280/// - [MDN IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
281#[function_component(Skeleton)]
282pub fn skeleton(props: &SkeletonProps) -> Html {
283    let node_ref = use_node_ref();
284    let visible = use_state(|| !props.show);
285    let direction = props.direction.clone();
286
287    let props_clone = props.clone();
288    let visible_clone = visible.clone();
289
290    {
291        let visible = visible.clone();
292        use_effect_with((props_clone.show,), move |_| {
293            if props_clone.show {
294                visible.set(false);
295            } else if props_clone.delay_ms > 0 {
296                let timeout = Timeout::new(props_clone.delay_ms, move || {
297                    visible_clone.set(true);
298                });
299                timeout.forget();
300            } else {
301                visible.set(true);
302            }
303            || ()
304        });
305    }
306
307    {
308        let node_ref = node_ref.clone();
309        let visible = visible.clone();
310
311        use_effect_with(
312            (node_ref.clone(), props.animate_on_visible),
313            move |(node_ref, animate_on_visible)| {
314                if !*animate_on_visible {
315                    return;
316                }
317
318                let element = node_ref.cast::<HtmlElement>();
319                if let Some(element) = element {
320                    let cb = Closure::wrap(Box::new(
321                        move |entries: js_sys::Array, _observer: IntersectionObserver| {
322                            for entry in entries.iter() {
323                                let entry = entry.unchecked_into::<IntersectionObserverEntry>();
324                                if entry.is_intersecting() {
325                                    visible.set(true);
326                                }
327                            }
328                        },
329                    )
330                        as Box<dyn FnMut(js_sys::Array, IntersectionObserver)>);
331
332                    let observer = IntersectionObserver::new(cb.as_ref().unchecked_ref()).unwrap();
333                    observer.observe(&element);
334
335                    cb.forget();
336                }
337            },
338        );
339    }
340
341    let background_color = match props.theme {
342        Theme::Light => "#e0e0e0",
343        Theme::Dark => "#444444",
344        Theme::Custom(color) => color,
345    };
346
347    let effective_radius = match props.variant {
348        Variant::Circular | Variant::Avatar => "50%",
349        Variant::Rectangular => "0",
350        Variant::Rounded => "8px",
351        Variant::Button => "6px",
352        Variant::Text | Variant::Image => props.border_radius,
353    };
354    let (keyframes_name, wave_keyframes) = match direction {
355        Direction::LeftToRight => (
356            "skeleton-wave-ltr",
357            r#"
358            @keyframes skeleton-wave-ltr {
359                0%   { background-position: 200% 0; }
360                100% { background-position: -200% 0; }
361            }
362            "#,
363        ),
364        Direction::RightToLeft => (
365            "skeleton-wave-rtl",
366            r#"
367            @keyframes skeleton-wave-rtl {
368                0% { background-position: -200% 0; }
369                100% { background-position: 200% 0; }
370            }
371            "#,
372        ),
373        Direction::TopToBottom => (
374            "skeleton-wave-ttb",
375            r#"
376            @keyframes skeleton-wave-ttb {
377                0%   { background-position: 0 -200%; }
378                100% { background-position: 0 200%; }
379            }
380            "#,
381        ),
382        Direction::BottomToTop => (
383            "skeleton-wave-btt",
384            r#"
385            @keyframes skeleton-wave-btt {
386                0%   { background-position: 0 200%; }
387                100% { background-position: 0 -200%; }
388            }
389            "#,
390        ),
391        Direction::CustomAngle(_) => (
392            "skeleton-wave-custom",
393            r#"
394            @keyframes skeleton-wave-custom {
395                0%   { background-position: 200% 0; }
396                100% { background-position: -200% 0; }
397            }
398            "#,
399        ),
400    };
401
402    let base_animation = match props.animation {
403        Animation::Pulse => "animation: skeleton-rs-pulse 1.5s ease-in-out infinite;".to_string(),
404
405        Animation::Wave => {
406            let angle = match direction {
407                Direction::LeftToRight => 90,
408                Direction::RightToLeft => 90,
409                Direction::TopToBottom => 90,
410                Direction::BottomToTop => 90,
411                Direction::CustomAngle(deg) => deg,
412            };
413
414            format!(
415                "background: linear-gradient({}deg, #e0e0e0 25%, #f5f5f5 50%, #e0e0e0 75%);
416                 background-size: 200% 100%;
417                 animation: {} 1.6s linear infinite;",
418                angle, keyframes_name
419            )
420        }
421
422        Animation::None => "".to_string(),
423    };
424
425    let mut style = String::new();
426
427    if props.infer_size {
428        style.push_str(&format!(
429            "background-color: {background_color}; border-radius: {effective_radius}; display: {}; position: {}; overflow: {}; margin: {};",
430            props.display, props.position, props.overflow, props.margin
431        ));
432    } else {
433        style.push_str(&format!(
434            "width: {}; height: {}; background-color: {background_color}; border-radius: {effective_radius}; display: {}; position: {}; overflow: {}; margin: {}; line-height: {};",
435            props.width, props.height, props.display, props.position, props.overflow, props.margin, props.line_height
436        ));
437    }
438
439    if let Some(size) = props.font_size {
440        style.push_str(&format!(" font-size: {size};"));
441    }
442
443    if let Some(max_w) = props.max_width {
444        style.push_str(&format!(" max-width: {max_w};"));
445    }
446    if let Some(min_w) = props.min_width {
447        style.push_str(&format!(" min-width: {min_w};"));
448    }
449    if let Some(max_h) = props.max_height {
450        style.push_str(&format!(" max-height: {max_h};"));
451    }
452    if let Some(min_h) = props.min_height {
453        style.push_str(&format!(" min-height: {min_h};"));
454    }
455
456    style.push_str(&base_animation);
457    style.push_str(props.custom_style);
458
459    let mut class_names = String::from("skeleton-rs");
460    if props.animate_on_hover {
461        class_names.push_str(" skeleton-hover");
462    }
463    if props.animate_on_focus {
464        class_names.push_str(" skeleton-focus");
465    }
466    if props.animate_on_active {
467        class_names.push_str(" skeleton-active");
468    }
469    use_effect_with((), move |_| {
470        if let Some(doc) = window().and_then(|w| w.document()) {
471            if doc.get_element_by_id("skeleton-rs-style").is_none() {
472                let style_elem = doc.create_element("style").unwrap();
473                style_elem.set_id("skeleton-rs-style");
474                let style_css = format!(
475                    r#"
476                    @keyframes skeleton-rs-pulse {{
477                        0% {{ opacity: 1; }}
478                        25% {{ opacity: 0.7; }}
479                        50% {{ opacity: 0.4; }}
480                        75% {{ opacity: 0.7; }}
481                        100% {{ opacity: 1; }}
482                    }}
483
484                    {}
485
486                    .skeleton-hover:hover {{
487                        filter: brightness(0.95);
488                    }}
489
490                    .skeleton-focus:focus {{
491                        outline: 2px solid #999;
492                    }}
493
494                    .skeleton-active:active {{
495                        transform: scale(0.98);
496                    }}
497                    "#,
498                    wave_keyframes
499                );
500                style_elem.set_inner_html(&style_css);
501                if let Some(head) = doc.head() {
502                    head.append_child(&style_elem).unwrap();
503                }
504            }
505        }
506    });
507
508    if *visible {
509        html! {
510            <div
511                ref={node_ref}
512                class={class_names}
513                style={style}
514                role="presentation"
515                aria-hidden="true"
516            />
517        }
518    } else {
519        html! { <>{ for props.children.iter() }</> }
520    }
521}
522
523#[derive(Properties, PartialEq)]
524pub struct SkeletonGroupProps {
525    #[prop_or_default]
526    pub children: ChildrenWithProps<Skeleton>,
527
528    #[prop_or_default]
529    pub style: &'static str,
530
531    #[prop_or_default]
532    pub class: &'static str,
533}
534
535#[function_component(SkeletonGroup)]
536pub fn skeleton_group(props: &SkeletonGroupProps) -> Html {
537    html! { <div style={props.style} class={props.class}>{ for props.children.iter() }</div> }
538}