#![doc = include_str!("../DIOXUS.md")]
use crate::common::{Animation, Direction, Theme, Variant};
use dioxus::prelude::*;
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::{IntersectionObserver, IntersectionObserverEntry};
#[derive(Props, PartialEq, Clone)]
pub struct SkeletonProps {
#[props(default)]
pub children: Element,
#[props(default)]
pub variant: Variant,
#[props(default)]
pub animation: Animation,
#[props(default)]
pub direction: Direction,
#[props(default)]
pub theme: Theme,
#[props(default = "100%")]
pub width: &'static str,
#[props(default = "1em")]
pub height: &'static str,
#[props(default)]
pub font_size: Option<&'static str>,
#[props(default = "4px")]
pub border_radius: &'static str,
#[props(default = "inline-block")]
pub display: &'static str,
#[props(default = "1")]
pub line_height: &'static str,
#[props(default = "relative")]
pub position: &'static str,
#[props(default = "hidden")]
pub overflow: &'static str,
#[props(default)]
pub margin: &'static str,
#[props(default)]
pub custom_style: &'static str,
#[props(default)]
pub infer_size: bool,
#[props(default)]
pub show: bool,
#[props(default = 0)]
pub delay_ms: u32,
#[props(default)]
pub responsive: bool,
#[props(default)]
pub max_width: Option<&'static str>,
#[props(default)]
pub min_width: Option<&'static str>,
#[props(default)]
pub max_height: Option<&'static str>,
#[props(default)]
pub min_height: Option<&'static str>,
#[props(default)]
pub animate_on_hover: bool,
#[props(default)]
pub animate_on_focus: bool,
#[props(default)]
pub animate_on_active: bool,
#[props(default)]
pub animate_on_visible: bool,
}
#[component]
pub fn Skeleton(props: SkeletonProps) -> Element {
let mut visible = use_signal(|| !props.show);
let id = "skeleton-rs";
use_effect(move || {
if props.show {
visible.set(false);
} else if props.delay_ms > 0 {
Timeout::new(props.delay_ms, move || {
visible.set(true);
})
.forget();
} else {
visible.set(true);
}
});
if props.animate_on_visible {
use_effect(move || {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
if let Some(element) = document.get_element_by_id(id) {
let closure = Closure::wrap(Box::new(
move |entries: js_sys::Array, _obs: IntersectionObserver| {
for entry in entries.iter() {
let entry: IntersectionObserverEntry = entry.unchecked_into();
if entry.is_intersecting() {
visible.set(true);
}
}
},
)
as Box<dyn FnMut(js_sys::Array, IntersectionObserver)>);
let observer = IntersectionObserver::new(closure.as_ref().unchecked_ref()).unwrap();
observer.observe(&element);
closure.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 animation_style = match props.animation {
Animation::Pulse => "animation: skeleton-rs-pulse 1.5s ease-in-out infinite;".to_string(),
Animation::Wave => {
let angle = match props.direction {
Direction::LeftToRight => 90,
Direction::RightToLeft => 270,
Direction::TopToBottom => 180,
Direction::BottomToTop => 0,
Direction::CustomAngle(deg) => deg,
};
format!(
"background: linear-gradient({}deg, #e0e0e0 25%, #f5f5f5 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: skeleton-rs-wave 1.6s linear infinite;",
angle
)
}
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(&animation_style);
style.push_str(props.custom_style);
let mut class_names = "skeleton-rs".to_string();
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");
}
let direction = props.direction.clone();
use_effect(move || {
let window = window().unwrap();
let document = window.document().unwrap();
if document.get_element_by_id("skeleton-rs-style").is_none() {
let style_elem = document.create_element("style").unwrap();
style_elem.set_id("skeleton-rs-style");
let wave_keyframes = match direction {
Direction::LeftToRight => {
r#"
@keyframes skeleton-rs-wave {
0% { background-position: 200% 0; }
25% { background-position: 100% 0; }
50% { background-position: 0% 0; }
75% { background-position: -100% 0; }
100% { background-position: -200% 0; }
}"#
}
Direction::RightToLeft => {
r#"
@keyframes skeleton-rs-wave {
0% { background-position: -200% 0; }
25% { background-position: -100% 0; }
50% { background-position: 0% 0; }
75% { background-position: 100% 0; }
100% { background-position: 200% 0; }
}"#
}
Direction::TopToBottom => {
r#"
@keyframes skeleton-rs-wave {
0% { background-position: 0 -200%; }
25% { background-position: 0 -100%; }
50% { background-position: 0 0%; }
75% { background-position: 0 100%; }
100% { background-position: 0 200%; }
}"#
}
Direction::BottomToTop => {
r#"
@keyframes skeleton-rs-wave {
0% { background-position: 0 200%; }
25% { background-position: 0 100%; }
50% { background-position: 0 0%; }
75% { background-position: 0 -100%; }
100% { background-position: 0 -200%; }
}"#
}
Direction::CustomAngle(_) => {
r#"
@keyframes skeleton-rs-wave {
0% { background-position: 200% 0; }
25% { background-position: 100% 0; }
50% { background-position: 0% 0; }
75% { background-position: -100% 0; }
100% { background-position: -200% 0; }
}"#
}
};
let 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(&css);
if let Some(head) = document.head() {
head.append_child(&style_elem).unwrap();
}
}
});
if visible() {
rsx! {
div {
id: "{id}",
class: "{class_names}",
style: "{style}",
role: "presentation",
aria_hidden: "true"
}
}
} else {
rsx! {
{props.children}
}
}
}
#[derive(Props, PartialEq, Clone)]
pub struct SkeletonGroupProps {
#[props(default)]
pub children: Element,
#[props(default)]
pub style: &'static str,
#[props(default)]
pub class: &'static str,
}
#[component]
pub fn SkeletonGroup(props: SkeletonGroupProps) -> Element {
rsx! {
div {
class: "{props.class}",
style: "{props.style}",
{props.children}
}
}
}