herogpui-components 0.14.0

HeroUI-style component library for GPUI
Documentation
//! Skeleton — port of `@heroui/skeleton` (v3).
//!
//! `animationType` selects between the sweeping shimmer, a pulse, and no
//! motion; it defaults to the theme's `--skeleton-animation` token.

use std::time::Duration;

use gpui::{
    div, prelude::*, px, Animation, AnimationExt, AnyElement, App, ElementId, IntoElement,
    ParentElement, Pixels, RenderOnce, Styled, Window,
};
use herogpui_theme::{ActiveTheme, SkeletonAnimation};

/// Loading placeholder (`Skeleton`).
#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
#[derive(IntoElement)]
pub struct Skeleton {
    id: ElementId,
    w: Option<Pixels>,
    h: Option<Pixels>,
    /// `animationType`. `None` defers to `--skeleton-animation`.
    animation_type: Option<SkeletonAnimation>,
    /// The corner radius, in place of the owning `hairline_radius` helper.
    radius: Option<Pixels>,
    children: Vec<AnyElement>,
    /// The `sx` slot, refined over the root style at the end of render.
    sx: Option<Box<gpui::StyleRefinement>>,
}

impl Skeleton {
    /// Creates a skeleton.
    pub fn new() -> Self {
        Self {
            id: "skeleton".into(),
            w: None,
            // A leaf keeps the pinned 24px fallback. A composed Skeleton
            // sizes itself from its children unless the caller supplies an
            // explicit height, which is how the upstream parent shimmer can
            // cover a profile/card/list shape without collapsing it to one
            // line.
            h: None,
            animation_type: None,
            radius: None,
            children: Vec::new(),
            sx: None,
        }
    }

    /// Distinct id per skeleton; required when several animate on one page.
    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
        self.id = id.into();
        self
    }

    /// Sets the width.
    pub fn w(mut self, v: impl Into<Pixels>) -> Self {
        self.w = Some(v.into());
        self
    }

    /// Sets the height.
    pub fn h(mut self, v: impl Into<Pixels>) -> Self {
        self.h = Some(v.into());
        self
    }

    /// Sets the animation type.
    pub fn animation_type(mut self, animation: SkeletonAnimation) -> Self {
        self.animation_type = Some(animation);
        self
    }

    /// The corner radius, in place of the owning `hairline_radius` helper. Not
    /// a v3 prop; the removed v2 `radius` prop is prohibited and this is a
    /// per-component repository extension.
    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
        self.radius = Some(radius.into());
        self
    }

    /// The one slot for caller-owned low-level styling: GPUI's styling methods
    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
    /// applied to the skeleton's root element after every value the component
    /// and the active theme chose, so they win.
    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
        crate::util::refine_sx(&mut self.sx, style);
        self
    }
}

impl Default for Skeleton {
    fn default() -> Self {
        Self::new()
    }
}

impl ParentElement for Skeleton {
    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
        self.children.extend(elements);
    }
}

impl RenderOnce for Skeleton {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let colors = cx.colors();
        let has_children = !self.children.is_empty();
        // `.skeleton` is `bg-surface-tertiary/70`, not the solid token: the
        // placeholder is meant to read as a tint of whatever it sits on. This
        // painted it opaque, so every skeleton came out the full tertiary fill
        // -- (234,234,235) on the light page against v3's (237,237,238).
        let base_color = colors.surface_tertiary.alpha(0.7);
        // Reduced motion collapses every animation type to `None`.
        let animation = if ActiveTheme::reduce_motion(cx) {
            SkeletonAnimation::None
        } else {
            self.animation_type
                .unwrap_or(cx.layout().skeleton_animation)
        };

        // Hoisted so the shimmer band below can share the resolved value:
        // vanilla GPUI clips `overflow_hidden()` to the rectangle, so the
        // band carries this same radius (see `util::inner_fill_radius`).
        let base_radius = match self.radius {
            Some(radius) => radius,
            None => crate::util::hairline_radius(cx),
        };
        let base = div()
            .bg(base_color)
            .rounded(base_radius)
            .overflow_hidden()
            .when_some(self.w, |el, w| el.w(w))
            .when_some(
                self.h.or_else(|| (!has_children).then(|| px(24.))),
                |el, h| el.h(h),
            )
            // HeroUI keeps composed child skeletons visible and suppresses
            // their individual shimmer in the gallery with
            // `animationType="none"`; the parent owns the synchronized band.
            .when(has_children, |el| el.children(self.children));

        match animation {
            SkeletonAnimation::None => crate::util::apply_sx(base, &self.sx).into_any_element(),
            // `with_animation` hands back an `AnimationElement`, which has no
            // style of its own to refine, so the slot lands on the box the
            // animation wraps: the pulse keeps driving opacity, everything
            // else the caller set holds.
            SkeletonAnimation::Pulse => crate::util::apply_sx(base, &self.sx)
                .with_animation(
                    self.id,
                    Animation::new(Duration::from_millis(1600)).repeat(),
                    move |el, delta| {
                        let t = (delta * std::f32::consts::TAU).sin();
                        el.opacity(0.55 + 0.25 * t)
                    },
                )
                .into_any_element(),
            // A highlight band sweeping left to right, like v3's shimmer. The
            // band itself is animated, so its position moves rather than its
            // size.
            SkeletonAnimation::Shimmer => {
                let highlight = colors.background;
                // The band sweeps edge to edge, so at each end of its travel
                // it paints square pixels through the base's rounded corners
                // (vanilla GPUI clips `overflow_hidden()` to the rectangle).
                // Rounding the band to the base radius reproduces the CSS
                // clip v3 gets from `overflow: hidden` + `border-radius`.
                let band = div()
                    .absolute()
                    .top_0()
                    .bottom_0()
                    .w(gpui::relative(0.35))
                    .rounded(base_radius)
                    .bg(gpui::linear_gradient(
                        90.0,
                        gpui::linear_color_stop(highlight.alpha(0.0), 0.0),
                        gpui::linear_color_stop(highlight.alpha(0.7), 0.5),
                    ))
                    .with_animation(
                        self.id,
                        Animation::new(Duration::from_millis(1400)).repeat(),
                        |el, delta| el.left(gpui::relative(delta)),
                    );
                crate::util::apply_sx(base.child(band), &self.sx).into_any_element()
            }
        }
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn composed_skeletons_keep_children_visible_for_a_parent_shimmer() {
        let source = include_str!("skeleton.rs")
            .split("#[cfg(test)]")
            .next()
            .expect("the implementation section is always present");
        assert!(source.contains("self.h.or_else(|| (!has_children).then(|| px(24.)))"));
        assert!(source.contains(".when(has_children, |el| el.children(self.children))"));
        assert!(
            !source.contains("div().opacity(0.).children(self.children)"),
            "composed Skeleton children must remain visible beneath the parent shimmer"
        );
    }
}

crate::util::impl_component_styled!(Skeleton);