Skip to main content

fc_dioxus/components/
avatar.rs

1//! Initials or a picture standing in for a person, from avatar.css.
2use crate::{
3    core::{assets, attrs::component_attributes},
4    prelude::*,
5    theme::use_stylesheets,
6};
7const BEM: Bem = Bem::new("fc-avatar");
8const GROUP: Bem = Bem::new("fc-avatar-group");
9fc_modifier! {
10    /// Avatar sizes.
11    pub enum AvatarSize for BEM {
12        /// The default 2rem.
13        Md => None,
14        /// Extra small; shows a single initial.
15        Xs => "xs",
16        /// Small.
17        Sm => "sm",
18        /// Large.
19        Lg => "lg",
20        /// Extra large.
21        Xl => "xl",
22    }
23}
24fc_modifier! { enum AvatarShape for BEM { Round => None, Square => "square" } }
25fc_modifier! { enum AvatarTone for BEM { Accent => None, Neutral => "neutral" } }
26
27/// Initials from the first and last words, at most `count` of them.
28///
29/// Splits on characters, never bytes, so a name in any script cannot be cut mid-character.
30/// A combining mark that follows the first character is dropped; the initial stays legible.
31#[must_use]
32pub fn initials(name: &str, count: usize) -> String {
33    let words: Vec<&str> = name.split_whitespace().collect();
34    let first = words.first().and_then(|w| w.chars().next());
35    let last = if words.len() > 1 {
36        words.last().and_then(|w| w.chars().next())
37    } else {
38        None
39    };
40    [first, last]
41        .into_iter()
42        .flatten()
43        .take(count)
44        .flat_map(char::to_uppercase)
45        .collect()
46}
47
48/// A person's avatar.
49///
50/// `alt` defaults to the name: the avatar is announced as that person. Pass
51/// `Alt::Decorative` when the name is already visible beside it, as in a message header,
52/// so it is not read twice.
53#[component]
54pub fn Avatar(
55    name: String,
56    #[props(default)] src: Option<String>,
57    #[props(default)] alt: Option<Alt>,
58    /// Replaces the derived initials, e.g. a symbol for a bot.
59    #[props(default)]
60    initials: Option<String>,
61    #[props(default)] size: AvatarSize,
62    #[props(default)] square: bool,
63    #[props(default)] neutral: bool,
64    #[props(default)] class: Option<String>,
65    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
66) -> Element {
67    let classes = ClassList::new(BEM.block())
68        .with_modifier(BEM, size)
69        .with_modifier(
70            BEM,
71            if square {
72                AvatarShape::Square
73            } else {
74                AvatarShape::Round
75            },
76        )
77        .with_modifier(
78            BEM,
79            if neutral {
80                AvatarTone::Neutral
81            } else {
82                AvatarTone::Accent
83            },
84        )
85        .extend(class.as_deref());
86    let mut attributes =
87        component_attributes(attributes, classes, &["role", "aria-label", "aria-hidden"]);
88    let label = match alt {
89        Some(Alt::Text(name)) => Some(name.to_string()),
90        Some(Alt::Decorative) => None,
91        None => AccessibleName::try_from(name.as_str())
92            .ok()
93            .map(|n| n.to_string()),
94    };
95    let text = initials
96        .unwrap_or_else(|| self::initials(&name, if size == AvatarSize::Xs { 1 } else { 2 }));
97    match (&src, &label) {
98        // The image carries the name itself; the wrapper adds nothing.
99        (Some(_), _) => {}
100        (None, Some(label)) => {
101            attributes.push(Attribute::new("role", "img", None, false));
102            attributes.push(Attribute::new("aria-label", label.clone(), None, false));
103        }
104        (None, None) => attributes.push(Attribute::new("aria-hidden", "true", None, false)),
105    }
106    rsx! {
107        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_AVATAR_CSS } }
108        span { ..attributes,
109            if let Some(src) = src {
110                img { class: BEM.element("image"), src, alt: label.unwrap_or_default() }
111            } else {
112                "{text}"
113            }
114        }
115    }
116}
117
118/// Overlapping avatars, with an optional "+n" for the people not shown.
119///
120/// The "+n" is decoration, as in upstream's reference markup: say who and how many in
121/// visible text beside the group, or in its `label`.
122#[component]
123pub fn AvatarGroup(
124    /// Names the group, e.g. "Reviewers". Without one, the group is presentational.
125    #[props(default)]
126    label: Option<AccessibleName>,
127    /// How many more people there are than avatars shown.
128    #[props(default)]
129    overflow: u32,
130    /// Size of the overflow avatar, to match its siblings.
131    #[props(default)]
132    size: AvatarSize,
133    #[props(default)] class: Option<String>,
134    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
135    children: Element,
136) -> Element {
137    let mut attributes = component_attributes(
138        attributes,
139        ClassList::new(GROUP.block()).extend(class.as_deref()),
140        &["role", "aria-label"],
141    );
142    if let Some(label) = label {
143        attributes.push(Attribute::new("role", "group", None, false));
144        attributes.push(Attribute::new("aria-label", label.to_string(), None, false));
145    }
146    let more = format!("{overflow} more");
147    rsx! {
148        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_AVATAR_CSS } }
149        span { ..attributes,
150            {children}
151            if overflow > 0 {
152                Avatar { name: more, alt: Alt::Decorative, initials: format!("+{overflow}"), size, neutral: true }
153            }
154        }
155    }
156}