import { cva } from "class-variance-authority"; import React, { useState } from "react"; import { Tooltip } from "@sparkle/components"; import { UserIcon } from "@sparkle/icons/app"; import { getEmojiAndBackgroundFromUrl } from "@sparkle/lib/avatar/utils"; import { cn } from "@sparkle/lib/utils"; export const AVATAR_SIZES = [ "xxs", "xs", "sm", "md", "lg", "xl", "2xl", "auto", ] as const; export type AvatarSizeType = (typeof AVATAR_SIZES)[number]; export const AVATAR_VARIANTS = ["default", "clickable", "disabled"] as const; export type AvatarVariantType = (typeof AVATAR_VARIANTS)[number]; const avatarVariants = cva( "s-flex s-flex-shrink-0 s-items-center s-justify-center s-overflow-hidden", { variants: { size: { xxs: "s-h-5 s-w-5", xs: "s-h-6 s-w-6", sm: "s-h-8 s-w-8", md: "s-h-10 s-w-10", lg: "s-h-16 s-w-16", xl: "s-h-20 s-w-20", "2xl": "s-h-36 s-w-36", auto: "s-w-full s-relative", }, variant: { default: "", clickable: "s-cursor-pointer hover:s-filter group-hover:s-filter group-hover:s-brightness-110 hover:s-brightness-110 group-active:s-brightness-90 active:s-brightness-90 s-transition s-duration-200 s-ease-out", disabled: "s-opacity-50", }, rounded: { true: "s-rounded-full s-ring-[1px] s-ring-border-dark/50 dark:s-ring-border-dark-night/50", false: "", }, }, compoundVariants: [ { rounded: false, size: "xxs", className: "s-rounded", }, { rounded: false, size: "xs", className: "s-rounded-md", }, { rounded: false, size: "sm", className: "s-rounded-lg", }, { rounded: false, size: "md", className: "s-rounded-xl", }, { rounded: false, size: "lg", className: "s-rounded-2xl", }, { rounded: false, size: "xl", className: "s-rounded-[22px]", }, { rounded: false, size: "2xl", className: "s-rounded-[32px]", }, { rounded: false, size: "auto", className: "s-rounded-[24%]", }, ], defaultVariants: { size: "md", variant: "default", rounded: false, }, } ); const textVariants = cva("s-select-none s-font-semibold", { variants: { size: { xxs: "s-text-[10px]", xs: "s-text-xs", sm: "s-text-sm", md: "s-text-base", lg: "s-text-3xl", xl: "s-text-5xl", "2xl": "s-text-7xl", auto: "s-text-xl", }, }, defaultVariants: { size: "md", }, }); const getColor = (name: string) => { if (/\+/.test(name)) { return "s-bg-primary-300"; } let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } const colors = [ "s-bg-blue-300", "s-bg-violet-300", "s-bg-pink-300", "s-bg-red-300", "s-bg-orange-300", "s-bg-golden-300", "s-bg-lime-300", "s-bg-emerald-300", ]; return colors[Math.abs(hash) % colors.length]; }; const getTextVariant = (name: string) => { if (/\+/.test(name)) { return "s-text-muted-foreground"; } let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } const txtColors = [ "s-text-blue-700", "s-text-violet-700", "s-text-pink-700", "s-text-red-700", "s-text-orange-700", "s-text-golden-700", "s-text-lime-700", "s-text-emerald-700", ]; return txtColors[Math.abs(hash) % txtColors.length]; }; interface AvatarProps { size?: AvatarSizeType; name?: string; emoji?: string; visual?: string | React.ReactNode; onClick?: () => void; clickable?: boolean; busy?: boolean; isRounded?: boolean; backgroundColor?: string; hexBgColor?: string; className?: string; disabled?: boolean; icon?: React.ComponentType<{ className?: string }>; iconColor?: string; } export function Avatar({ size, name, emoji, visual, onClick, clickable = false, busy = false, isRounded = false, backgroundColor, hexBgColor, disabled = false, className, icon, iconColor = "s-text-foreground", }: AvatarProps) { const normalizedVisual = visual === "" ? null : visual; const emojiInfos = typeof normalizedVisual === "string" && getEmojiAndBackgroundFromUrl(normalizedVisual); const backgroundColorToUse = emojiInfos ? emojiInfos.backgroundColor : backgroundColor; const emojiToUse = emojiInfos ? emojiInfos.skinEmoji : emoji; const visualToUse = emojiInfos ? null : normalizedVisual; const variant: AvatarVariantType = disabled ? "disabled" : (onClick || clickable) && !busy ? "clickable" : "default"; return (
{size === "auto" &&
} {typeof visualToUse === "string" ? ( {name} ) : visualToUse ? ( visualToUse ) : icon ? ( React.createElement(icon, { className: cn("s-h-1/2 s-w-1/2", iconColor), }) ) : emojiToUse ? ( {emojiToUse} ) : name ? ( {/\+/.test(name) ? name : name[0].toUpperCase()} ) : ( )}
); } const AVATAR_STACK_SIZES = ["xs", "sm", "md"] as const; type AvatarStackSizeType = (typeof AVATAR_STACK_SIZES)[number]; interface AvatarStackProps { avatars: AvatarProps[]; nbVisibleItems?: number; size?: AvatarStackSizeType; hasMagnifier?: boolean; tooltipTriggerAsChild?: boolean; orientation?: "horizontal" | "vertical"; onTop?: "first" | "last"; } const sizeClassesPx: Record = { xs: 24, sm: 32, md: 40, }; Avatar.Stack = function ({ avatars, nbVisibleItems, size = "sm", hasMagnifier = true, tooltipTriggerAsChild = false, orientation = "horizontal", onTop = "last", }: AvatarStackProps) { const [isHovered, setIsHovered] = useState(false); // Get visible avatars and calculate remaining count const shouldShowAll = !nbVisibleItems || avatars.length <= nbVisibleItems; const isFirstOnTop = onTop === "first"; const maxVisible = shouldShowAll ? avatars.length : isFirstOnTop ? nbVisibleItems : nbVisibleItems - 1; const visibleAvatars = isFirstOnTop ? avatars.slice(0, maxVisible).reverse() : avatars.slice(0, maxVisible); const remainingCount = shouldShowAll || isFirstOnTop ? 0 : avatars.length - maxVisible; // Get all names for tooltip const avatarNames = avatars .filter((avatar) => avatar.name) .map((avatar) => avatar.name); const tooltipLabel = avatarNames.join(", "); const sizeSetting = { marginLeft: 0, widthHovered: sizeClassesPx[size] * 0.6, width: sizeClassesPx[size] * 0.25, heightHovered: sizeClassesPx[size] * 0.6, height: sizeClassesPx[size] * 0.25, }; const collapsedWidth = sizeSetting.width * (visibleAvatars.length + Number(Boolean(remainingCount))) + (sizeClassesPx[size] - sizeSetting.width); const openedWidth = sizeSetting.widthHovered * (visibleAvatars.length + Number(Boolean(remainingCount))) + (sizeClassesPx[size] - sizeSetting.widthHovered); const collapsedHeight = sizeSetting.height * (visibleAvatars.length + Number(Boolean(remainingCount))) + (sizeClassesPx[size] - sizeSetting.height); const openedHeight = sizeSetting.heightHovered * (visibleAvatars.length + Number(Boolean(remainingCount))) + (sizeClassesPx[size] - sizeSetting.heightHovered); const transitionSettings = orientation === "vertical" ? "height 200ms ease-out" : "width 200ms ease-out"; return (
visibleAvatars.length > 1 && setIsHovered(true)} onMouseLeave={() => visibleAvatars.length > 1 && setIsHovered(false) } style={{ [orientation === "vertical" ? "height" : "width"]: `${ isHovered ? orientation === "vertical" ? openedHeight : openedWidth : orientation === "vertical" ? collapsedHeight : collapsedWidth }px`, transition: transitionSettings, }} > {visibleAvatars.map((avatarProps, i) => (
{hasMagnifier ? (
) : ( )}
))} {remainingCount > 0 && (
)}
} /> ); };