adui_dioxus/components/
avatar.rs

1use dioxus::prelude::*;
2
3/// Shape of the Avatar.
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum AvatarShape {
6    Circle,
7    Square,
8}
9
10impl AvatarShape {
11    fn as_class(&self) -> &'static str {
12        match self {
13            AvatarShape::Circle => "adui-avatar-circle",
14            AvatarShape::Square => "adui-avatar-square",
15        }
16    }
17}
18
19/// Size of the Avatar.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum AvatarSize {
22    Small,
23    Default,
24    Large,
25}
26
27impl AvatarSize {
28    fn as_class(&self) -> &'static str {
29        match self {
30            AvatarSize::Small => "adui-avatar-sm",
31            AvatarSize::Default => "adui-avatar-md",
32            AvatarSize::Large => "adui-avatar-lg",
33        }
34    }
35}
36
37/// Props for the Avatar component (MVP subset).
38#[derive(Props, Clone, PartialEq)]
39pub struct AvatarProps {
40    /// Image source URL. When present and load succeeds, the image will be
41    /// used as the avatar content.
42    #[props(optional)]
43    pub src: Option<String>,
44    /// Alt text for the image.
45    #[props(optional)]
46    pub alt: Option<String>,
47    /// Shape of the avatar (circle or square).
48    #[props(optional)]
49    pub shape: Option<AvatarShape>,
50    /// Size variant for the avatar.
51    #[props(optional)]
52    pub size: Option<AvatarSize>,
53    /// Optional icon content when no image src is provided.
54    pub icon: Option<Element>,
55    /// Extra class for the root element.
56    #[props(optional)]
57    pub class: Option<String>,
58    /// Inline style for the root element.
59    #[props(optional)]
60    pub style: Option<String>,
61    /// Text content for text avatar. Used when `src` is None; typically a
62    /// short string such as initials.
63    pub children: Option<Element>,
64}
65
66/// Simple Avatar component supporting image, icon and text content.
67#[component]
68pub fn Avatar(props: AvatarProps) -> Element {
69    let AvatarProps {
70        src,
71        alt,
72        shape,
73        size,
74        icon,
75        class,
76        style,
77        children,
78    } = props;
79
80    let shape_cls = shape.unwrap_or(AvatarShape::Circle).as_class();
81    let size_cls = size.unwrap_or(AvatarSize::Default).as_class();
82
83    let mut class_list = vec![
84        "adui-avatar".to_string(),
85        shape_cls.to_string(),
86        size_cls.to_string(),
87    ];
88    if let Some(extra) = class {
89        class_list.push(extra);
90    }
91    let class_attr = class_list.join(" ");
92    let style_attr = style.unwrap_or_default();
93
94    // For MVP we do not handle image load error state explicitly; the browser
95    // will render a broken image icon if the src fails. Callers can choose to
96    // omit src and rely on icon/text instead.
97
98    rsx! {
99        span { class: "{class_attr}", style: "{style_attr}",
100            if let Some(url) = src {
101                img {
102                    class: "adui-avatar-img",
103                    src: "{url}",
104                    alt: "{alt.clone().unwrap_or_default()}",
105                }
106            } else if let Some(node) = icon {
107                span { class: "adui-avatar-icon", {node} }
108            } else if let Some(node) = children {
109                span { class: "adui-avatar-text", {node} }
110            }
111        }
112    }
113}
114
115/// Props for AvatarGroup.
116#[derive(Props, Clone, PartialEq)]
117pub struct AvatarGroupProps {
118    /// Extra class name for the group.
119    #[props(optional)]
120    pub class: Option<String>,
121    /// Inline style for the group.
122    #[props(optional)]
123    pub style: Option<String>,
124    /// Avatars inside the group.
125    pub children: Element,
126}
127
128/// Simple horizontal Avatar group with overlapping avatars.
129#[component]
130pub fn AvatarGroup(props: AvatarGroupProps) -> Element {
131    let AvatarGroupProps {
132        class,
133        style,
134        children,
135    } = props;
136
137    let mut class_list = vec!["adui-avatar-group".to_string()];
138    if let Some(extra) = class {
139        class_list.push(extra);
140    }
141    let class_attr = class_list.join(" ");
142    let style_attr = style.unwrap_or_default();
143
144    rsx! {
145        div { class: "{class_attr}", style: "{style_attr}",
146            {children}
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn avatar_shape_and_size_class_mapping_is_stable() {
157        assert_eq!(AvatarShape::Circle.as_class(), "adui-avatar-circle");
158        assert_eq!(AvatarShape::Square.as_class(), "adui-avatar-square");
159
160        assert_eq!(AvatarSize::Small.as_class(), "adui-avatar-sm");
161        assert_eq!(AvatarSize::Default.as_class(), "adui-avatar-md");
162        assert_eq!(AvatarSize::Large.as_class(), "adui-avatar-lg");
163    }
164
165    #[test]
166    fn avatar_shape_all_variants() {
167        let variants = [AvatarShape::Circle, AvatarShape::Square];
168        for variant in variants.iter() {
169            let class = variant.as_class();
170            assert!(!class.is_empty());
171            assert!(class.starts_with("adui-avatar-"));
172        }
173    }
174
175    #[test]
176    fn avatar_size_all_variants() {
177        let variants = [AvatarSize::Small, AvatarSize::Default, AvatarSize::Large];
178        for variant in variants.iter() {
179            let class = variant.as_class();
180            assert!(!class.is_empty());
181            assert!(class.starts_with("adui-avatar-"));
182        }
183    }
184
185    #[test]
186    fn avatar_shape_equality() {
187        assert_eq!(AvatarShape::Circle, AvatarShape::Circle);
188        assert_eq!(AvatarShape::Square, AvatarShape::Square);
189        assert_ne!(AvatarShape::Circle, AvatarShape::Square);
190    }
191
192    #[test]
193    fn avatar_size_equality() {
194        assert_eq!(AvatarSize::Small, AvatarSize::Small);
195        assert_eq!(AvatarSize::Default, AvatarSize::Default);
196        assert_eq!(AvatarSize::Large, AvatarSize::Large);
197        assert_ne!(AvatarSize::Small, AvatarSize::Large);
198    }
199
200    #[test]
201    fn avatar_shape_clone() {
202        let original = AvatarShape::Circle;
203        let cloned = original;
204        assert_eq!(original, cloned);
205        assert_eq!(original.as_class(), cloned.as_class());
206    }
207
208    #[test]
209    fn avatar_size_clone() {
210        let original = AvatarSize::Large;
211        let cloned = original;
212        assert_eq!(original, cloned);
213        assert_eq!(original.as_class(), cloned.as_class());
214    }
215
216    #[test]
217    fn avatar_props_defaults() {
218        // AvatarProps doesn't require any fields
219        // shape defaults to Circle when None
220        // size defaults to Default when None
221        // All other fields are optional
222    }
223
224    #[test]
225    fn avatar_group_props_defaults() {
226        // AvatarGroupProps requires children
227        // class and style are optional
228    }
229
230    #[test]
231    fn avatar_shape_debug() {
232        let shape = AvatarShape::Square;
233        let debug_str = format!("{:?}", shape);
234        assert!(debug_str.contains("Square") || debug_str.contains("Circle"));
235    }
236
237    #[test]
238    fn avatar_size_debug() {
239        let size = AvatarSize::Small;
240        let debug_str = format!("{:?}", size);
241        assert!(
242            debug_str.contains("Small")
243                || debug_str.contains("Default")
244                || debug_str.contains("Large")
245        );
246    }
247
248    #[test]
249    fn avatar_shape_class_prefix() {
250        // All avatar shape classes should start with "adui-avatar-"
251        assert!(AvatarShape::Circle.as_class().starts_with("adui-avatar-"));
252        assert!(AvatarShape::Square.as_class().starts_with("adui-avatar-"));
253    }
254
255    #[test]
256    fn avatar_size_class_prefix() {
257        // All avatar size classes should start with "adui-avatar-"
258        assert!(AvatarSize::Small.as_class().starts_with("adui-avatar-"));
259        assert!(AvatarSize::Default.as_class().starts_with("adui-avatar-"));
260        assert!(AvatarSize::Large.as_class().starts_with("adui-avatar-"));
261    }
262
263    #[test]
264    fn avatar_shape_unique_classes() {
265        // All avatar shape classes should be unique
266        assert_ne!(
267            AvatarShape::Circle.as_class(),
268            AvatarShape::Square.as_class()
269        );
270    }
271
272    #[test]
273    fn avatar_size_unique_classes() {
274        // All avatar size classes should be unique
275        let small = AvatarSize::Small.as_class();
276        let default = AvatarSize::Default.as_class();
277        let large = AvatarSize::Large.as_class();
278        assert_ne!(small, default);
279        assert_ne!(default, large);
280        assert_ne!(small, large);
281    }
282
283    #[test]
284    fn avatar_shape_copy_semantics() {
285        // AvatarShape should be Copy
286        let shape = AvatarShape::Circle;
287        let class1 = shape.as_class();
288        let class2 = shape.as_class();
289        assert_eq!(class1, class2);
290    }
291
292    #[test]
293    fn avatar_size_copy_semantics() {
294        // AvatarSize should be Copy
295        let size = AvatarSize::Large;
296        let class1 = size.as_class();
297        let class2 = size.as_class();
298        assert_eq!(class1, class2);
299    }
300
301    #[test]
302    fn avatar_shape_all_variants_equality() {
303        let shapes = [AvatarShape::Circle, AvatarShape::Square];
304        for (i, shape1) in shapes.iter().enumerate() {
305            for (j, shape2) in shapes.iter().enumerate() {
306                if i == j {
307                    assert_eq!(shape1, shape2);
308                } else {
309                    assert_ne!(shape1, shape2);
310                }
311            }
312        }
313    }
314
315    #[test]
316    fn avatar_size_all_variants_equality() {
317        let sizes = [AvatarSize::Small, AvatarSize::Default, AvatarSize::Large];
318        for (i, size1) in sizes.iter().enumerate() {
319            for (j, size2) in sizes.iter().enumerate() {
320                if i == j {
321                    assert_eq!(size1, size2);
322                } else {
323                    assert_ne!(size1, size2);
324                }
325            }
326        }
327    }
328}