maud-ui 0.2.1

64 headless, accessible UI components for Rust web apps — shadcn Base UI API parity. Plus block templates, a live theme customiser, and shell hooks for 15 third-party widgets (Monaco, xyflow, Excalidraw, Three.js, AG Grid, Leaflet, FullCalendar, SortableJS, and more). Built on maud + htmx, styled like shadcn/ui.
Documentation
//! Avatar component — maud-ui Wave 1

use maud::{html, Markup};

#[derive(Clone, Debug)]
pub struct Props {
    pub src: Option<String>,
    pub alt: String,
    pub fallback: String,
    pub size: Size,
}

impl Default for Props {
    fn default() -> Self {
        Self {
            src: None,
            alt: "Avatar".to_string(),
            fallback: "U".to_string(),
            size: Size::Md,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Size {
    Sm,
    Md,
    Lg,
}

impl Size {
    fn class_name(self) -> &'static str {
        match self {
            Size::Sm => "mui-avatar--sm",
            Size::Md => "mui-avatar--md",
            Size::Lg => "mui-avatar--lg",
        }
    }
}

pub fn render(props: Props) -> Markup {
    let size_class = props.size.class_name();
    let class = format!("mui-avatar {}", size_class);

    // A11y: avoid double-announcement when a real <img> is present.
    // - src Some: the native <img alt="…"> carries the accessible name; the outer
    //   span must NOT also carry `role="img"` + `aria-label`, else SRs announce twice.
    // - src None: there is no <img>, so the outer span becomes the image role and
    //   exposes `alt` as its accessible name.
    html! {
        @if let Some(src) = &props.src {
            span class=(class) {
                img class="mui-avatar__img" src=(src) alt=(props.alt) {}
            }
        } @else {
            span class=(class) role="img" aria-label=(props.alt) {
                span class="mui-avatar__fallback" aria-hidden="true" {
                    (props.fallback.to_uppercase())
                }
            }
        }
    }
}

/// Status pip rendered at the bottom-right of an [`render`]ed avatar.
///
/// Shadcn parity: `AvatarBadge`. Pair this with a relatively-positioned
/// wrapper (or rely on `.mui-avatar { position: relative; }`) so the
/// absolute badge anchors to the avatar surface.
pub fn badge(children: Markup) -> Markup {
    html! {
        span class="mui-avatar__badge" aria-hidden="true" {
            (children)
        }
    }
}

/// Wrap a set of [`render`]ed avatars in a horizontal, overlapping group.
///
/// Shadcn parity: `AvatarGroup`.
pub fn group(children: Markup) -> Markup {
    html! {
        div class="mui-avatar-group" role="group" {
            (children)
        }
    }
}

/// Tail marker for an [`group`] showing the number of additional members.
///
/// Shadcn parity: `AvatarGroupCount`. Renders `+{n}` with an accessible label.
pub fn group_count(n: usize) -> Markup {
    let label = format!("{n} more");
    html! {
        div class="mui-avatar mui-avatar-group__count" aria-label=(label) {
            (format!("+{n}"))
        }
    }
}

pub fn showcase() -> Markup {
    html! {
        div.mui-showcase__grid {
            // User card — avatar with name and email
            section {
                h2 { "User Card" }
                div style="display:flex;align-items:center;gap:0.75rem;" {
                    (render(Props {
                        src: Some("https://i.pravatar.cc/120?img=12".to_string()),
                        alt: "Sofia Davis".to_string(),
                        fallback: "SD".to_string(),
                        size: Size::Lg,
                    }))
                    div {
                        div style="font-size:0.875rem;font-weight:500;color:var(--mui-text);" {
                            "Sofia Davis"
                        }
                        div style="font-size:0.8125rem;color:var(--mui-text-muted);" {
                            "sofia@example.com"
                        }
                    }
                }
            }

            // Avatar with status indicator
            section {
                h2 { "Status Indicator" }
                div.mui-showcase__row {
                    // Online
                    span style="position:relative;display:inline-block;" {
                        (render(Props {
                            src: Some("https://i.pravatar.cc/120?img=20".to_string()),
                            alt: "Online user".to_string(),
                            fallback: "ON".to_string(),
                            size: Size::Md,
                        }))
                        span style="position:absolute;bottom:0;right:0;width:0.625rem;height:0.625rem;background:#22c55e;border:2px solid var(--mui-bg);border-radius:var(--mui-radius-full);" {}
                    }
                    // Away
                    span style="position:relative;display:inline-block;" {
                        (render(Props {
                            src: Some("https://i.pravatar.cc/120?img=33".to_string()),
                            alt: "Away user".to_string(),
                            fallback: "AW".to_string(),
                            size: Size::Md,
                        }))
                        span style="position:absolute;bottom:0;right:0;width:0.625rem;height:0.625rem;background:#eab308;border:2px solid var(--mui-bg);border-radius:var(--mui-radius-full);" {}
                    }
                    // Offline (fallback)
                    span style="position:relative;display:inline-block;" {
                        (render(Props {
                            src: None,
                            alt: "Offline user".to_string(),
                            fallback: "JD".to_string(),
                            size: Size::Md,
                        }))
                        span style="position:absolute;bottom:0;right:0;width:0.625rem;height:0.625rem;background:var(--mui-text-muted);border:2px solid var(--mui-bg);border-radius:var(--mui-radius-full);" {}
                    }
                }
            }

            // Avatar with status badge helper
            section {
                h2 { "Status Badge" }
                div.mui-showcase__row {
                    span style="position:relative;display:inline-block;" {
                        (render(Props {
                            src: Some("https://i.pravatar.cc/120?img=47".to_string()),
                            alt: "Online".to_string(),
                            fallback: "ON".to_string(),
                            size: Size::Lg,
                        }))
                        (badge(html! { }))
                    }
                }
            }

            // Team avatar group with group_count tail
            section {
                h2 { "Team" }
                (group(html! {
                    (render(Props {
                        src: Some("https://i.pravatar.cc/120?img=5".to_string()),
                        alt: "Alice".to_string(),
                        fallback: "A".to_string(),
                        size: Size::Md,
                    }))
                    (render(Props {
                        src: Some("https://i.pravatar.cc/120?img=8".to_string()),
                        alt: "Bob".to_string(),
                        fallback: "B".to_string(),
                        size: Size::Md,
                    }))
                    (render(Props {
                        src: Some("https://i.pravatar.cc/120?img=15".to_string()),
                        alt: "Carol".to_string(),
                        fallback: "C".to_string(),
                        size: Size::Md,
                    }))
                    (render(Props {
                        src: Some("https://i.pravatar.cc/120?img=22".to_string()),
                        alt: "Dan".to_string(),
                        fallback: "D".to_string(),
                        size: Size::Md,
                    }))
                    (group_count(3))
                }))
            }

            // Sizes and fallbacks
            section {
                h2 { "Sizes" }
                div.mui-showcase__row {
                    span.mui-showcase__label { "sm" }
                    (render(Props {
                        src: Some("https://i.pravatar.cc/120?img=12".to_string()),
                        alt: "Small avatar".to_string(),
                        fallback: "SM".to_string(),
                        size: Size::Sm,
                    }))
                    (render(Props {
                        src: None,
                        alt: "Small fallback".to_string(),
                        fallback: "SM".to_string(),
                        size: Size::Sm,
                    }))
                }
                div.mui-showcase__row {
                    span.mui-showcase__label { "md" }
                    (render(Props {
                        src: Some("https://i.pravatar.cc/120?img=20".to_string()),
                        alt: "Medium avatar".to_string(),
                        fallback: "MD".to_string(),
                        size: Size::Md,
                    }))
                    (render(Props {
                        src: None,
                        alt: "Medium fallback".to_string(),
                        fallback: "MD".to_string(),
                        size: Size::Md,
                    }))
                }
                div.mui-showcase__row {
                    span.mui-showcase__label { "lg" }
                    (render(Props {
                        src: Some("https://i.pravatar.cc/120?img=33".to_string()),
                        alt: "Large avatar".to_string(),
                        fallback: "LG".to_string(),
                        size: Size::Lg,
                    }))
                    (render(Props {
                        src: None,
                        alt: "Large fallback".to_string(),
                        fallback: "LG".to_string(),
                        size: Size::Lg,
                    }))
                }
            }
        }
    }
}