#![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::*;
#[derive(Properties, PartialEq, Clone)]
pub struct SkeletonProps {
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub variant: Variant,
#[prop_or_default]
pub animation: Animation,
#[prop_or_default]
pub direction: Direction,
#[prop_or_default]
pub theme: Theme,
#[prop_or("100%")]
pub width: &'static str,
#[prop_or("1em")]
pub height: &'static str,
#[prop_or(None)]
pub font_size: Option<&'static str>,
#[prop_or("4px")]
pub border_radius: &'static str,
#[prop_or("inline-block")]
pub display: &'static str,
#[prop_or("1")]
pub line_height: &'static str,
#[prop_or("relative")]
pub position: &'static str,
#[prop_or("hidden")]
pub overflow: &'static str,
#[prop_or_default]
pub margin: &'static str,
#[prop_or_default]
pub custom_style: &'static str,
#[prop_or(false)]
pub infer_size: bool,
#[prop_or(false)]
pub show: bool,
#[prop_or(0)]
pub delay_ms: u32,
#[prop_or(false)]
pub responsive: bool,
#[prop_or(None)]
pub max_width: Option<&'static str>,
#[prop_or(None)]
pub min_width: Option<&'static str>,
#[prop_or(None)]
pub max_height: Option<&'static str>,
#[prop_or(None)]
pub min_height: Option<&'static str>,
#[prop_or(false)]
pub animate_on_hover: bool,
#[prop_or(false)]
pub animate_on_focus: bool,
#[prop_or(false)]
pub animate_on_active: bool,
#[prop_or(false)]
pub animate_on_visible: bool,
}
#[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> }
}