"use client" import * as React from "react" import { Avatar as AvatarPrimitive } from "radix-ui" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "../../lib/utils" import { Badge } from "./badge" /** Maps to Shadcn Studio avatar demos (shape / ring / stack). Uses theme tokens only. */ const avatarRootVariants = cva( "group/avatar relative flex shrink-0 overflow-hidden select-none", { variants: { size: { sm: "size-6", default: "size-8", lg: "size-10", }, shape: { circle: "rounded-full", square: "rounded-none", "rounded-sm": "rounded-sm", "rounded-md": "rounded-md", "rounded-lg": "rounded-lg", "rounded-xl": "rounded-xl", }, variant: { default: "", ring: "ring-2 ring-ring", "ring-offset": "ring-2 ring-ring ring-offset-2 ring-offset-background", group: "ring-2 ring-background", }, }, defaultVariants: { size: "default", shape: "circle", variant: "default", }, } ) const AVATAR_INSET_AFTER: Record["shape"]>, string> = { circle: "after:rounded-full", square: "after:rounded-none", "rounded-sm": "after:rounded-sm", "rounded-md": "after:rounded-md", "rounded-lg": "after:rounded-lg", "rounded-xl": "after:rounded-xl", } const avatarImageShape = cn( "aspect-square size-full object-cover", "group-data-[shape=circle]/avatar:rounded-full", "group-data-[shape=square]/avatar:rounded-none", "group-data-[shape=rounded-sm]/avatar:rounded-sm", "group-data-[shape=rounded-md]/avatar:rounded-md", "group-data-[shape=rounded-lg]/avatar:rounded-lg", "group-data-[shape=rounded-xl]/avatar:rounded-xl" ) const avatarFallbackShape = cn( "flex size-full items-center justify-center bg-muted text-sm text-muted-foreground", "group-data-[shape=circle]/avatar:rounded-full", "group-data-[shape=square]/avatar:rounded-none", "group-data-[shape=rounded-sm]/avatar:rounded-sm", "group-data-[shape=rounded-md]/avatar:rounded-md", "group-data-[shape=rounded-lg]/avatar:rounded-lg", "group-data-[shape=rounded-xl]/avatar:rounded-xl", "group-data-[size=sm]/avatar:text-xs" ) type AvatarProps = React.ComponentProps & VariantProps & { /** * Subtle inner hairline on the avatar edge (token `border-border`). * Default **false** — full-bleed circle for photos/logos (Shadcn Studio–style profile look). */ insetBorder?: boolean } function Avatar({ className, size = "default", shape = "circle", variant = "default", insetBorder = false, ...props }: AvatarProps) { const shapeKey = shape ?? "circle" const insetClass = insetBorder && cn( "after:pointer-events-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten", AVATAR_INSET_AFTER[shapeKey] ) return ( ) } function AvatarImage({ className, referrerPolicy = "no-referrer", ...props }: React.ComponentProps) { return ( ) } function AvatarFallback({ className, ...props }: React.ComponentProps) { return ( ) } function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { return ( i]:hidden", "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>i]:text-2xs", "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>i]:text-2xs", className )} {...props} /> ) } /** * A row of avatars with an overflow counter — **never overlapping**. * * Overlapping face piles (Slack / GitHub style) have known UX problems in * dense product chrome: ring-on-background contrast is fragile in dark mode, * click targets stack, and screen readers announce ambiguous groupings. * `AvatarGroup` therefore renders children side-by-side with a small inline * gap. See `.cursor/rules/exxat-person-identity-display.mdc` (MUST NOT — * overlapping avatars) and the `PageHeader` collaboration variant which * uses the same gapped row. */ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { return (
) } function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) { return (
i]:text-sm group-has-data-[size=sm]/avatar-group:[&>i]:text-xs", className )} {...props} /> ) } // ── Shadcn Studio–style compositions (tokens + a11y checklist: sr-only status, meaningful alt on image) ── type AvatarShape = NonNullable["shape"]> type AvatarVariant = NonNullable["variant"]> type AvatarSize = NonNullable["size"]> type AvatarInitialsProps = Omit & { initials: string /** When true, initials are hidden from accessibility tree (pair with visible name). Default true. */ decorative?: boolean /** Extra classes on `AvatarFallback` (e.g. font weight). */ fallbackClassName?: string } /** Initials-only chip using `--avatar-initials-bg` / `--avatar-initials-fg` (Exxat tokens). */ function AvatarInitials({ initials, decorative = true, fallbackClassName, className, ...avatarProps }: AvatarInitialsProps) { return ( {initials} ) } type AvatarStatusTone = "online" | "busy" | "away" const avatarStatusDot: Record = { online: "bg-emerald-600 dark:bg-emerald-400", busy: "bg-destructive", away: "bg-amber-600 dark:bg-amber-400", } /** Presence dot (Studio avatars 7–9). Wraps a single `Avatar`. */ function AvatarStatus({ children, status, label, className, position = "bottom-end", }: { children: React.ReactNode status: AvatarStatusTone /** Announced to screen readers (e.g. "Busy"). */ label: string className?: string /** `bottom-end` matches `AvatarBadge`; `top-end` matches some Studio away demos. */ position?: "bottom-end" | "top-end" }) { const pos = position === "top-end" ? "-top-0.5 -end-0.5 bottom-auto start-auto" : "-end-0.5 -bottom-0.5 top-auto start-auto" return ( {children} {label} ) } /** Numeric badge overlay (Studio avatar 11). */ function AvatarNotificationCount({ children, count, className, badgeClassName, }: { children: React.ReactNode count: number | string className?: string badgeClassName?: string }) { return ( {children} {count} ) } /** Verified check overlay (Studio avatar 12) — primary token fill. */ function AvatarVerified({ children, label = "Verified", className, }: { children: React.ReactNode label?: string className?: string }) { return ( {children}