herogpui_components/skeleton.rs
1//! Skeleton — port of `@heroui/skeleton` (v3).
2//!
3//! `animationType` selects between the sweeping shimmer, a pulse, and no
4//! motion; it defaults to the theme's `--skeleton-animation` token.
5
6use std::time::Duration;
7
8use gpui::{
9 div, prelude::*, px, Animation, AnimationExt, AnyElement, App, ElementId, IntoElement,
10 ParentElement, Pixels, RenderOnce, Styled, Window,
11};
12use herogpui_theme::{ActiveTheme, SkeletonAnimation};
13
14/// Loading placeholder (`Skeleton`).
15#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
16#[derive(IntoElement)]
17pub struct Skeleton {
18 id: ElementId,
19 w: Option<Pixels>,
20 h: Option<Pixels>,
21 /// `animationType`. `None` defers to `--skeleton-animation`.
22 animation_type: Option<SkeletonAnimation>,
23 /// The corner radius, in place of the owning `hairline_radius` helper.
24 radius: Option<Pixels>,
25 children: Vec<AnyElement>,
26 /// The `sx` slot, refined over the root style at the end of render.
27 sx: Option<Box<gpui::StyleRefinement>>,
28}
29
30impl Skeleton {
31 /// Creates a skeleton.
32 pub fn new() -> Self {
33 Self {
34 id: "skeleton".into(),
35 w: None,
36 // A leaf keeps the pinned 24px fallback. A composed Skeleton
37 // sizes itself from its children unless the caller supplies an
38 // explicit height, which is how the upstream parent shimmer can
39 // cover a profile/card/list shape without collapsing it to one
40 // line.
41 h: None,
42 animation_type: None,
43 radius: None,
44 children: Vec::new(),
45 sx: None,
46 }
47 }
48
49 /// Distinct id per skeleton; required when several animate on one page.
50 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
51 self.id = id.into();
52 self
53 }
54
55 /// Sets the width.
56 pub fn w(mut self, v: impl Into<Pixels>) -> Self {
57 self.w = Some(v.into());
58 self
59 }
60
61 /// Sets the height.
62 pub fn h(mut self, v: impl Into<Pixels>) -> Self {
63 self.h = Some(v.into());
64 self
65 }
66
67 /// Sets the animation type.
68 pub fn animation_type(mut self, animation: SkeletonAnimation) -> Self {
69 self.animation_type = Some(animation);
70 self
71 }
72
73 /// The corner radius, in place of the owning `hairline_radius` helper. Not
74 /// a v3 prop; the removed v2 `radius` prop is prohibited and this is a
75 /// per-component repository extension.
76 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
77 self.radius = Some(radius.into());
78 self
79 }
80
81 /// The one slot for caller-owned low-level styling: GPUI's styling methods
82 /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
83 /// applied to the skeleton's root element after every value the component
84 /// and the active theme chose, so they win.
85 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
86 crate::util::refine_sx(&mut self.sx, style);
87 self
88 }
89}
90
91impl Default for Skeleton {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97impl ParentElement for Skeleton {
98 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
99 self.children.extend(elements);
100 }
101}
102
103impl RenderOnce for Skeleton {
104 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
105 let colors = cx.colors();
106 let has_children = !self.children.is_empty();
107 // `.skeleton` is `bg-surface-tertiary/70`, not the solid token: the
108 // placeholder is meant to read as a tint of whatever it sits on. This
109 // painted it opaque, so every skeleton came out the full tertiary fill
110 // -- (234,234,235) on the light page against v3's (237,237,238).
111 let base_color = colors.surface_tertiary.alpha(0.7);
112 // Reduced motion collapses every animation type to `None`.
113 let animation = if ActiveTheme::reduce_motion(cx) {
114 SkeletonAnimation::None
115 } else {
116 self.animation_type
117 .unwrap_or(cx.layout().skeleton_animation)
118 };
119
120 // Hoisted so the shimmer band below can share the resolved value:
121 // vanilla GPUI clips `overflow_hidden()` to the rectangle, so the
122 // band carries this same radius (see `util::inner_fill_radius`).
123 let base_radius = match self.radius {
124 Some(radius) => radius,
125 None => crate::util::hairline_radius(cx),
126 };
127 let base = div()
128 .bg(base_color)
129 .rounded(base_radius)
130 .overflow_hidden()
131 .when_some(self.w, |el, w| el.w(w))
132 .when_some(
133 self.h.or_else(|| (!has_children).then(|| px(24.))),
134 |el, h| el.h(h),
135 )
136 // HeroUI keeps composed child skeletons visible and suppresses
137 // their individual shimmer in the gallery with
138 // `animationType="none"`; the parent owns the synchronized band.
139 .when(has_children, |el| el.children(self.children));
140
141 match animation {
142 SkeletonAnimation::None => crate::util::apply_sx(base, &self.sx).into_any_element(),
143 // `with_animation` hands back an `AnimationElement`, which has no
144 // style of its own to refine, so the slot lands on the box the
145 // animation wraps: the pulse keeps driving opacity, everything
146 // else the caller set holds.
147 SkeletonAnimation::Pulse => crate::util::apply_sx(base, &self.sx)
148 .with_animation(
149 self.id,
150 Animation::new(Duration::from_millis(1600)).repeat(),
151 move |el, delta| {
152 let t = (delta * std::f32::consts::TAU).sin();
153 el.opacity(0.55 + 0.25 * t)
154 },
155 )
156 .into_any_element(),
157 // A highlight band sweeping left to right, like v3's shimmer. The
158 // band itself is animated, so its position moves rather than its
159 // size.
160 SkeletonAnimation::Shimmer => {
161 let highlight = colors.background;
162 // The band sweeps edge to edge, so at each end of its travel
163 // it paints square pixels through the base's rounded corners
164 // (vanilla GPUI clips `overflow_hidden()` to the rectangle).
165 // Rounding the band to the base radius reproduces the CSS
166 // clip v3 gets from `overflow: hidden` + `border-radius`.
167 let band = div()
168 .absolute()
169 .top_0()
170 .bottom_0()
171 .w(gpui::relative(0.35))
172 .rounded(base_radius)
173 .bg(gpui::linear_gradient(
174 90.0,
175 gpui::linear_color_stop(highlight.alpha(0.0), 0.0),
176 gpui::linear_color_stop(highlight.alpha(0.7), 0.5),
177 ))
178 .with_animation(
179 self.id,
180 Animation::new(Duration::from_millis(1400)).repeat(),
181 |el, delta| el.left(gpui::relative(delta)),
182 );
183 crate::util::apply_sx(base.child(band), &self.sx).into_any_element()
184 }
185 }
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 #[test]
192 fn composed_skeletons_keep_children_visible_for_a_parent_shimmer() {
193 let source = include_str!("skeleton.rs")
194 .split("#[cfg(test)]")
195 .next()
196 .expect("the implementation section is always present");
197 assert!(source.contains("self.h.or_else(|| (!has_children).then(|| px(24.)))"));
198 assert!(source.contains(".when(has_children, |el| el.children(self.children))"));
199 assert!(
200 !source.contains("div().opacity(0.).children(self.children)"),
201 "composed Skeleton children must remain visible beneath the parent shimmer"
202 );
203 }
204}
205
206crate::util::impl_component_styled!(Skeleton);