orbital_base_components/avatar/
base.rs1use leptos::{either::Either, prelude::*};
2
3use super::color::AvatarColor;
4
5#[derive(Clone, Copy, Default, PartialEq, Eq)]
6pub enum AvatarShape {
7 #[default]
8 Circular,
9 Square,
10}
11
12impl AvatarShape {
13 pub fn as_str(&self) -> &'static str {
14 match self {
15 Self::Circular => "circular",
16 Self::Square => "square",
17 }
18 }
19}
20
21pub fn initials_from_name(name: &str) -> String {
23 let initials: Vec<_> = name
24 .split_whitespace()
25 .filter_map(|word| word.chars().next().and_then(|c| c.to_uppercase().next()))
26 .collect();
27
28 match initials.as_slice() {
29 [first, .., last] => format!("{first}{last}"),
30 [first] => first.to_string(),
31 [] => String::new(),
32 }
33}
34
35fn avatar_font_size_token(size: u8) -> Option<&'static str> {
36 match size {
37 0..=24 => Some("var(--orb-type-size-2xs)"),
38 25..=28 => Some("var(--orb-type-size-xs)"),
39 29..=40 => None,
40 41..=56 => Some("var(--orb-type-size-md)"),
41 57..=96 => Some("var(--orb-type-size-lg)"),
42 97..=128 => Some("var(--orb-type-size-xl)"),
43 _ => Some("var(--orb-type-size-xl)"),
44 }
45}
46
47#[component]
48pub fn BaseAvatar(
49 #[prop(optional, into)] class: MaybeProp<String>,
50 #[prop(optional, into)] src: MaybeProp<String>,
51 #[prop(optional, into)] name: MaybeProp<String>,
52 #[prop(optional, into)] initials: MaybeProp<String>,
53 #[prop(optional, into)] shape: Signal<AvatarShape>,
54 #[prop(optional, into)] size: MaybeProp<u8>,
55 #[prop(optional, into)] color: Signal<AvatarColor>,
56 #[prop(optional, into)] id_for_color: MaybeProp<String>,
57) -> impl IntoView {
58 let style = move || {
59 let size = size.get()?;
60 let mut style = format!("width: {size}px; height: {size}px;");
61 if let Some(font_size) = avatar_font_size_token(size) {
62 style.push_str(&format!("font-size: {font_size};"));
63 }
64 Some(style)
65 };
66
67 let image_hidden = RwSignal::new(false);
68 let is_show_default_icon = Memo::new(move |_| {
69 let has_name = name.with(|n| n.is_some());
70 let has_visible_image = src.with(|s| s.is_some()) && !image_hidden.get();
71 let has_initials = initials.with(|i| i.is_some());
72 !has_name && !has_visible_image && !has_initials
73 });
74
75 let on_load = move |_| {
76 image_hidden.maybe_update(|hidden| {
77 if *hidden {
78 *hidden = false;
79 }
80 true
81 });
82 };
83
84 let on_error = move |_| {
85 image_hidden.set(true);
86 };
87
88 view! {
89 <span
90 class=move || {
91 let resolved = color.get().resolve(
92 name.get().as_deref(),
93 initials.get().as_deref(),
94 id_for_color.get().as_deref(),
95 );
96 let mut parts = vec![
97 "orbital-avatar".to_string(),
98 format!("orbital-avatar--{}", shape.get().as_str()),
99 format!("orbital-avatar--color-{}", resolved.as_str()),
100 ];
101 if let Some(extra) = class.get() {
102 if !extra.is_empty() {
103 parts.push(extra);
104 }
105 }
106 parts.join(" ")
107 }
108 style=style
109 role="img"
110 aria-label=move || name.get()
111 >
112 {move || {
113 let mut display_initials = initials.get();
114 if display_initials.is_none() {
115 if let Some(name) = name.get() {
116 display_initials = Some(initials_from_name(&name));
117 }
118 }
119 display_initials.map(|text| {
120 view! { <span class="orbital-avatar__initials">{text}</span> }
121 })
122 }}
123 {move || {
124 src.get().map(|src| {
125 view! {
126 <img
127 src=src
128 class="orbital-avatar__image"
129 role="presentation"
130 aria-hidden="true"
131 hidden=move || image_hidden.get()
132 on:load=on_load
133 on:error=on_error
134 />
135 }
136 })
137 }}
138 {move || {
139 if is_show_default_icon.get() {
140 Either::Left(view! {
141 <span aria-hidden="true" class="orbital-avatar__icon">
142 <svg
143 fill="currentColor"
144 aria-hidden="true"
145 width="1em"
146 height="1em"
147 viewBox="0 0 20 20"
148 xmlns="http://www.w3.org/2000/svg"
149 >
150 <path
151 d="M10 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM7 6a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm-2 5a2 2 0 0 0-2 2c0 1.7.83 2.97 2.13 3.8A9.14 9.14 0 0 0 10 18c1.85 0 3.58-.39 4.87-1.2A4.35 4.35 0 0 0 17 13a2 2 0 0 0-2-2H5Zm-1 2a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1c0 1.3-.62 2.28-1.67 2.95A8.16 8.16 0 0 1 10 17a8.16 8.16 0 0 1-4.33-1.05A3.36 3.36 0 0 1 4 13Z"
152 fill="currentColor"
153 />
154 </svg>
155 </span>
156 })
157 } else {
158 Either::Right(())
159 }
160 }}
161 </span>
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::initials_from_name;
168
169 #[test]
170 fn initials_from_name_cases() {
171 assert_eq!(initials_from_name("Jane Doe"), "JD");
172 assert_eq!(initials_from_name("Ben"), "B");
173 assert_eq!(initials_from_name(""), "");
174 assert_eq!(initials_from_name("山"), "山");
175 }
176}