#![doc = include_str!("../YEW.md")]
use crate::common::{
Color, ImageLoadingStatus, Overlap, Size, Variant, base_container_style, base_fallback_style,
base_group_grid_style, base_group_style, child_overlap_style,
};
use image_rs::common::{
AriaLive, AriaPressed, CrossOrigin, Decoding, FetchPriority, Layout, Loading, ObjectFit,
Position, ReferrerPolicy,
};
use image_rs::yew::Image as ImageRS;
use std::time::Duration;
use yew::platform::spawn_local;
use yew::platform::time::sleep;
use yew::prelude::*;
pub type AvatarContext = UseStateHandle<ImageLoadingStatus>;
#[derive(Clone, Debug, PartialEq)]
pub struct GroupContext {
pub size: Size,
pub color: Color,
pub variant: Variant,
pub overlap: Overlap,
pub is_grid: bool,
pub overflow_count: usize,
}
#[derive(Properties, PartialEq, Clone)]
pub struct GroupProps {
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub max: Option<usize>,
#[prop_or_default]
pub total: Option<usize>,
#[prop_or_default]
pub size: Size,
#[prop_or_default]
pub color: Color,
#[prop_or_default]
pub variant: Variant,
#[prop_or_default]
pub overlap: Overlap,
#[prop_or_default]
pub is_grid: bool,
#[prop_or_default]
pub class: &'static str,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or("Avatar group")]
pub aria_label: &'static str,
#[prop_or("group")]
pub role: &'static str,
#[prop_or("0")]
pub tabindex: &'static str,
#[prop_or_default]
pub data_testid: &'static str,
}
#[function_component(Group)]
pub fn avatar_group(props: &GroupProps) -> Html {
let overflow_count = match (props.max, props.total) {
(Some(max), Some(total)) => total.saturating_sub(max),
_ => 0,
};
let visible_children: Vec<_> = props.children.iter().collect();
let ctx = GroupContext {
size: props.size,
color: props.color,
variant: props.variant,
overlap: props.overlap,
is_grid: props.is_grid,
overflow_count,
};
let base_style = if props.is_grid {
base_group_grid_style()
} else {
base_group_style()
};
let group_class = if props.is_grid {
"avatar-group avatar-group--grid"
} else {
match props.overlap {
Overlap::Clip => "avatar-group avatar-group--clip",
Overlap::Ring => "avatar-group avatar-group--ring",
}
};
let full_style = format!("{} {}", base_style, props.style);
html! {
<ContextProvider<GroupContext> context={ctx}>
<div
id={props.id}
class={format!("{} {}", group_class, props.class)}
style={full_style}
role={props.role}
aria-label={props.aria_label}
tabindex={props.tabindex}
data-testid={props.data_testid}
>
{ for visible_children }
</div>
</ContextProvider<GroupContext>>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct CountProps {
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub count: usize,
#[prop_or_default]
pub size: Option<Size>,
#[prop_or_default]
pub color: Option<Color>,
#[prop_or_default]
pub variant: Option<Variant>,
#[prop_or_default]
pub class: &'static str,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or("Additional members")]
pub aria_label: &'static str,
}
#[function_component(Count)]
pub fn avatar_group_count(props: &CountProps) -> Html {
let group = use_context::<GroupContext>();
let (group_size, group_color, group_variant, ctx_overflow) = group
.as_ref()
.map(|ctx| (ctx.size, ctx.color, ctx.variant, ctx.overflow_count))
.unwrap_or((Size::Md, Color::Default, Variant::Default, 0));
let size = props.size.unwrap_or(group_size);
let color = props.color.unwrap_or(group_color);
let variant = props.variant.unwrap_or(group_variant);
let effective_count = if props.count == 0 {
ctx_overflow
} else {
props.count
};
let label = format!("+{}", effective_count);
let content = if props.children.is_empty() {
html! { { label.clone() } }
} else {
html! { { for props.children.iter() } }
};
html! {
<Avatar
size={Some(size)}
class={classes!("avatar-group__count", props.class)}
style={props.style}
id={props.id}
aria_label={props.aria_label}
>
<Fallback color={Some(color)} variant={Some(variant)}>
{ content }
</Fallback>
</Avatar>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct AvatarProps {
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub size: Option<Size>,
#[prop_or_default]
pub class: Classes,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or("Avatar")]
pub aria_label: &'static str,
#[prop_or("0")]
pub tabindex: &'static str,
#[prop_or_default]
pub container_class: &'static str,
#[prop_or_default]
pub container_style: &'static str,
#[prop_or_default]
pub data_testid: &'static str,
}
#[function_component(Avatar)]
pub fn avatar(props: &AvatarProps) -> Html {
let group_ctx = use_context::<GroupContext>();
let status = use_state(|| ImageLoadingStatus::Idle);
let computed_size = props
.size
.unwrap_or_else(|| group_ctx.as_ref().map(|ctx| ctx.size).unwrap_or(Size::Md));
let overlap_style = match &group_ctx {
Some(ctx) if !ctx.is_grid => child_overlap_style(ctx.overlap, computed_size),
_ => "",
};
let full_style = format!(
"{} {} {} {} {}",
base_container_style(),
computed_size.to_style(),
computed_size.to_font_style(),
overlap_style,
props.style
);
html! {
<ContextProvider<AvatarContext> context={status}>
<span
id={props.id}
class={classes!("avatar", props.container_class, props.class.clone())}
style={format!("{} {}", full_style, props.container_style)}
role="img"
aria-label={props.aria_label}
tabindex={props.tabindex}
data-testid={props.data_testid}
>
{ for props.children.iter() }
</span>
</ContextProvider<AvatarContext>>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct ImageProps {
#[prop_or_default]
pub src: &'static str,
#[prop_or_default]
pub fallback_src: &'static str,
#[prop_or_default]
pub srcset: &'static str,
#[prop_or_default]
pub sizes: &'static str,
#[prop_or_default]
pub alt: &'static str,
#[prop_or("avatar__image")]
pub class: &'static str,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or_default]
pub loading: Loading,
#[prop_or_default]
pub crossorigin: CrossOrigin,
#[prop_or_default]
pub decoding: Decoding,
#[prop_or_default]
pub referrerpolicy: ReferrerPolicy,
#[prop_or_default]
pub fetchpriority: FetchPriority,
#[prop_or_default]
pub object_fit: ObjectFit,
#[prop_or_default]
pub object_position: Position,
#[prop_or_default]
pub layout: Layout,
#[prop_or_default]
pub quality: &'static str,
#[prop_or_default]
pub placeholder: &'static str,
#[prop_or_default]
pub blur_data_url: &'static str,
#[prop_or_default]
pub elementtiming: &'static str,
#[prop_or_default]
pub aria_live: AriaLive,
#[prop_or_default]
pub aria_pressed: AriaPressed,
#[prop_or_default]
pub aria_controls: &'static str,
#[prop_or_default]
pub aria_labelledby: &'static str,
#[prop_or_default]
pub aria_describedby: &'static str,
#[prop_or_default]
pub aria_expanded: &'static str,
#[prop_or_default]
pub width: &'static str,
#[prop_or_default]
pub height: &'static str,
#[prop_or_default]
pub on_load: Callback<()>,
#[prop_or_default]
pub on_error: Callback<String>,
}
#[function_component(Image)]
pub fn avatar_image(props: &ImageProps) -> Html {
let status = use_context::<AvatarContext>().expect("Image must be inside Avatar");
let on_load = {
let status = status.clone();
let user_cb = props.on_load.clone();
Callback::from(move |()| {
status.set(ImageLoadingStatus::Loaded);
user_cb.emit(());
})
};
let on_error = {
let status = status.clone();
let user_cb = props.on_error.clone();
Callback::from(move |err: String| {
status.set(ImageLoadingStatus::Error);
user_cb.emit(err);
})
};
let node_ref = use_node_ref();
{
let status = status.clone();
let node_ref = node_ref.clone();
use_effect_with(props.src, move |src| {
if !src.is_empty() {
let mut is_cached = false;
if let Some(img) = node_ref.cast::<web_sys::HtmlImageElement>()
&& img.complete()
{
is_cached = true;
}
if is_cached {
status.set(ImageLoadingStatus::Loaded);
} else {
status.set(ImageLoadingStatus::Loading);
}
}
|| ()
});
}
let is_loaded = *status == ImageLoadingStatus::Loaded;
let wrapper_style = if is_loaded {
"width: 100%; height: 100%; display: block;"
} else {
"display: none;"
};
html! {
<span
id={props.id}
class={props.class}
style={format!("{} {}", wrapper_style, props.style)}
aria-hidden={if is_loaded { "false" } else { "true" }}
>
<ImageRS
node_ref={node_ref}
src={props.src}
alt={props.alt}
width={props.width}
height={props.height}
layout={props.layout}
fallback_src={props.fallback_src}
srcset={props.srcset}
sizes={props.sizes}
style="width: 100%; height: 100%; object-fit: cover; border-radius: inherit;"
loading={props.loading.clone()}
crossorigin={props.crossorigin.clone()}
decoding={props.decoding}
referrerpolicy={props.referrerpolicy}
fetchpriority={props.fetchpriority.clone()}
object_fit={props.object_fit}
object_position={props.object_position}
quality={props.quality}
placeholder={props.placeholder}
blur_data_url={props.blur_data_url}
elementtiming={props.elementtiming}
aria_live={props.aria_live}
aria_pressed={props.aria_pressed}
aria_controls={props.aria_controls}
aria_labelledby={props.aria_labelledby}
aria_describedby={props.aria_describedby}
aria_expanded={props.aria_expanded}
on_load={on_load}
on_error={on_error}
/>
</span>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct FallbackProps {
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub delay_ms: u32,
#[prop_or_default]
pub color: Option<Color>,
#[prop_or_default]
pub variant: Option<Variant>,
#[prop_or_else(|| classes!("avatar__fallback"))]
pub class: Classes,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or_default]
pub data_testid: &'static str,
}
#[function_component(Fallback)]
pub fn avatar_fallback(props: &FallbackProps) -> Html {
let status = use_context::<AvatarContext>().expect("Fallback must be inside Avatar");
let group = use_context::<GroupContext>();
let visible = use_state(|| props.delay_ms == 0);
{
let visible = visible.clone();
let delay_ms = props.delay_ms;
use_effect_with(delay_ms, move |_| {
if delay_ms > 0 {
let visible = visible.clone();
spawn_local(async move {
sleep(Duration::from_millis(u64::from(delay_ms))).await;
visible.set(true);
});
}
|| ()
});
}
let image_loaded = *status == ImageLoadingStatus::Loaded;
let should_show = *visible && !image_loaded;
if !should_show {
return html! {};
}
let (group_color, group_variant) = group
.as_ref()
.map(|ctx| (ctx.color, ctx.variant))
.unwrap_or((Color::Default, Variant::Default));
let color = props.color.unwrap_or(group_color);
let variant = props.variant.unwrap_or(group_variant);
let color_style = match variant {
Variant::Soft => color.to_soft_style(),
Variant::Default => color.to_style(),
};
html! {
<span
id={props.id}
class={props.class.clone()}
style={format!("{} {} {}", base_fallback_style(), color_style, props.style)}
aria-hidden={if image_loaded { "true" } else { "false" }}
data-testid={props.data_testid}
>
{ for props.children.iter() }
</span>
}
}