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}