#![doc = include_str!("../LEPTOS.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::leptos::Image as ImageRS;
use leptos::callback::Callback;
use leptos::prelude::*;
use leptos::task::spawn_local;
use std::time::Duration;
#[derive(Clone, Debug, PartialEq, Copy)]
pub struct GroupContext {
pub size: Size,
pub color: Color,
pub variant: Variant,
pub overlap: Overlap,
pub is_grid: bool,
pub overflow_count: usize,
}
#[component]
pub fn Group(
children: Children,
#[prop(optional)]
max: Option<usize>,
#[prop(optional)]
total: Option<usize>,
#[prop(default = Size::Md)]
size: Size,
#[prop(default = Color::Default)]
color: Color,
#[prop(default = Variant::Default)]
variant: Variant,
#[prop(default = Overlap::Clip)]
overlap: Overlap,
#[prop(default = false)]
is_grid: bool,
#[prop(default = "")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[prop(default = "Avatar group")]
aria_label: &'static str,
#[prop(default = "group")]
role: &'static str,
#[prop(default = "0")]
tabindex: &'static str,
#[prop(default = "")]
data_testid: &'static str,
) -> impl IntoView {
let overflow_count = match (max, total) {
(Some(max), Some(total)) => total.saturating_sub(max),
_ => 0,
};
let ctx = GroupContext {
size,
color,
variant,
overlap,
is_grid,
overflow_count,
};
provide_context(ctx);
let base_style = if is_grid {
base_group_grid_style()
} else {
base_group_style()
};
let group_class = if is_grid {
"avatar-group avatar-group--grid"
} else {
match overlap {
Overlap::Clip => "avatar-group avatar-group--clip",
Overlap::Ring => "avatar-group avatar-group--ring",
}
};
let full_style = format!("{} {}", base_style, style);
view! {
<div
id=id
class=format!("{} {}", group_class, class)
style=full_style
role=role
aria-label=aria_label
tabindex=tabindex
data-testid=data_testid
>
{children()}
</div>
}
}
#[component]
pub fn Count(
#[prop(optional)]
children: Option<ChildrenFn>,
#[prop(default = 0)]
count: usize,
#[prop(optional)]
size: Option<Size>,
#[prop(optional)]
color: Option<Color>,
#[prop(optional)]
variant: Option<Variant>,
#[prop(default = "")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[prop(default = "Additional members")]
aria_label: &'static str,
) -> impl IntoView {
let group = use_context::<GroupContext>();
let (group_size, group_color, group_variant, ctx_overflow) = group
.map(|ctx| (ctx.size, ctx.color, ctx.variant, ctx.overflow_count))
.unwrap_or((Size::Md, Color::Default, Variant::Default, 0));
let final_size = size.unwrap_or(group_size);
let final_color = color.unwrap_or(group_color);
let final_variant = variant.unwrap_or(group_variant);
let effective_count = if count == 0 { ctx_overflow } else { count };
let content_to_store = match children {
Some(c) => leptos::either::Either::Left(c),
None => leptos::either::Either::Right(format!("+{effective_count}")),
};
let stored_content = StoredValue::new_local(content_to_store);
view! {
<Avatar
size=final_size
container_class="avatar-group__count"
class=class
style=style
id=id
aria_label=aria_label
>
<Fallback color=final_color variant=final_variant>
{
stored_content.with_value(|c| match c {
leptos::either::Either::Left(ch) => ch().into_any(),
leptos::either::Either::Right(s) => s.clone().into_any(),
})
}
</Fallback>
</Avatar>
}
}
#[component]
pub fn Avatar(
children: Children,
#[prop(optional)]
size: Option<Size>,
#[prop(default = "")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[prop(default = "Avatar")]
aria_label: &'static str,
#[prop(default = "0")]
tabindex: &'static str,
#[prop(default = "avatar")]
container_class: &'static str,
#[prop(default = "")]
container_style: &'static str,
#[prop(default = "")]
data_testid: &'static str,
) -> impl IntoView {
let group = use_context::<GroupContext>();
let status = RwSignal::new(ImageLoadingStatus::Idle);
provide_context(status);
let computed_size = size.unwrap_or_else(|| group.map(|ctx| ctx.size).unwrap_or(Size::Md));
let overlap_style = match group {
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,
style
);
view! {
<span
id=id
class=format!("{} {}", container_class, class)
style=format!("{} {}", full_style, container_style)
role="img"
aria-label=aria_label
tabindex=tabindex
data-testid=data_testid
>
{children()}
</span>
}
}
#[component]
pub fn Image(
#[prop(default = "")]
src: &'static str,
#[prop(default = "")]
fallback_src: &'static str,
#[prop(default = "")]
srcset: &'static str,
#[prop(default = "")]
sizes: &'static str,
#[prop(default = "")]
alt: &'static str,
#[prop(default = "avatar__image")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[prop(default = Loading::Eager)]
loading: Loading,
#[prop(default = CrossOrigin::None)]
crossorigin: CrossOrigin,
#[prop(default = Decoding::Auto)]
decoding: Decoding,
#[prop(default = ReferrerPolicy::NoReferrer)]
referrerpolicy: ReferrerPolicy,
#[prop(default = FetchPriority::Auto)]
fetchpriority: FetchPriority,
#[prop(default = ObjectFit::Cover)]
object_fit: ObjectFit,
#[prop(default = Position::Center)]
object_position: Position,
#[prop(default = Layout::Responsive)]
layout: Layout,
#[prop(default = "")]
quality: &'static str,
#[prop(default = "")]
placeholder: &'static str,
#[prop(default = "")]
blur_data_url: &'static str,
#[prop(default = "")]
elementtiming: &'static str,
#[prop(default = AriaLive::Off)]
aria_live: AriaLive,
#[prop(default = AriaPressed::Undefined)]
aria_pressed: AriaPressed,
#[prop(default = "")]
aria_controls: &'static str,
#[prop(default = "")]
aria_labelledby: &'static str,
#[prop(default = "")]
aria_describedby: &'static str,
#[prop(default = "")]
aria_expanded: &'static str,
#[prop(default = "")]
width: &'static str,
#[prop(default = "")]
height: &'static str,
#[prop(optional)]
on_load: Option<Callback<()>>,
#[prop(optional)]
on_error: Option<Callback<String>>,
) -> impl IntoView {
let _ = quality;
let _ = aria_live;
let _ = aria_pressed;
let _ = aria_controls;
let _ = aria_labelledby;
let _ = aria_describedby;
let _ = aria_expanded;
let status =
use_context::<RwSignal<ImageLoadingStatus>>().expect("Image must be used inside Avatar");
Effect::new(move |_| {
if !src.is_empty() {
status.set(ImageLoadingStatus::Loading);
}
});
let is_loaded = move || status.get() == ImageLoadingStatus::Loaded;
let wrapper_style = move || {
let base = "width: 100%; height: 100%; object-fit: cover; border-radius: inherit;";
if is_loaded() {
format!("{} {}", base, style)
} else {
format!("{} display: none; {}", base, style)
}
};
let load_cb = Callback::new(move |()| {
status.set(ImageLoadingStatus::Loaded);
if let Some(cb) = on_load {
cb.run(());
}
});
let error_cb = Callback::new(move |err: String| {
status.set(ImageLoadingStatus::Error);
if let Some(cb) = on_error {
cb.run(err);
}
});
view! {
<span
id=id
class=class
style=wrapper_style
aria-hidden=move || if is_loaded() { "false" } else { "true" }
>
<ImageRS
src=src
fallback_src=fallback_src
srcset=srcset
sizes=sizes
alt=alt
style="width: 100%; height: 100%; object-fit: cover; border-radius: inherit;"
loading=loading
crossorigin=crossorigin
decoding=decoding
referrerpolicy=referrerpolicy
fetchpriority=fetchpriority
object_fit=object_fit
object_position=object_position
layout=layout
placeholder=placeholder
blur_data_url=blur_data_url
elementtiming=elementtiming
width=width
height=height
on_load=load_cb
on_error=error_cb
/>
</span>
}
}
#[component]
pub fn Fallback(
children: ChildrenFn,
#[prop(default = 0u32)]
delay_ms: u32,
#[prop(optional)]
color: Option<Color>,
#[prop(optional)]
variant: Option<Variant>,
#[prop(default = "avatar__fallback")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[prop(default = "")]
data_testid: &'static str,
) -> impl IntoView {
let status =
use_context::<RwSignal<ImageLoadingStatus>>().expect("Fallback must be used inside Avatar");
let group = use_context::<GroupContext>();
let visible = RwSignal::new(delay_ms == 0);
Effect::new(move |_| {
if delay_ms > 0 {
spawn_local(async move {
gloo_timers::future::sleep(Duration::from_millis(u64::from(delay_ms))).await;
visible.set(true);
});
}
});
let (group_color, group_variant) = group
.map(|ctx| (ctx.color, ctx.variant))
.unwrap_or((Color::Default, Variant::Default));
let final_color = color.unwrap_or(group_color);
let final_variant = variant.unwrap_or(group_variant);
let color_style = match final_variant {
Variant::Soft => final_color.to_soft_style(),
Variant::Default => final_color.to_style(),
};
let full_style = format!("{} {} {}", base_fallback_style(), color_style, style);
let is_image_loaded = move || status.get() == ImageLoadingStatus::Loaded;
let should_show = move || visible.get() && !is_image_loaded();
view! {
<Show when=should_show>
<span
id=id
class=class
style=full_style.clone()
aria-hidden=move || if is_image_loaded() { "true" } else { "false" }
data-testid=data_testid
>
{children()}
</span>
</Show>
}
}