"use client";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { designRadius } from "@/lib/design-system";
import { useKeyboardClick } from "@/lib/hooks";
import { cn } from "@/lib/utils";
type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl";
type AvatarColor =
| "default"
| "primary"
| "secondary"
| "accent"
| "success"
| "warning"
| "danger";
type StatusPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
interface AvatarContextValue {
color: AvatarColor;
isInGroup?: boolean;
}
const AvatarContext = React.createContext<AvatarContextValue>({
color: "default",
isInGroup: false,
});
const useAvatarContext = () => React.useContext(AvatarContext);
export interface AvatarProps
extends Omit<
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>,
"title"
> {
size?: AvatarSize;
color?: AvatarColor;
radius?: keyof typeof designRadius;
isBordered?: boolean;
isDisabled?: boolean;
isPressable?: boolean;
status?: AvatarColor;
statusPosition?: StatusPosition;
isEditable?: boolean;
onUpload?: () => void;
title?: React.ReactNode;
description?: React.ReactNode;
}
const avatarSizes: Record<AvatarSize, string> = {
xs: "size-6 text-xs",
sm: "size-8 text-xs",
md: "size-10 text-sm",
lg: "size-12 text-base",
xl: "size-14 text-lg",
"2xl": "size-16 text-xl",
"3xl": "size-20 text-2xl",
};
const avatarColorBorders: Record<AvatarColor, string> = {
default: "ring-2 ring-zinc-300 dark:ring-zinc-700",
primary: "ring-2 ring-primary",
secondary: "ring-2 ring-secondary",
accent: "ring-2 ring-accent",
success: "ring-2 ring-success",
warning: "ring-2 ring-warning",
danger: "ring-2 ring-danger",
};
const statusColors: Record<AvatarColor, string> = {
default: "bg-zinc-400 dark:bg-zinc-500",
primary: "bg-primary",
secondary: "bg-secondary",
accent: "bg-accent",
success: "bg-success",
warning: "bg-warning",
danger: "bg-danger",
};
const statusPositions: Record<StatusPosition, string> = {
"top-left": "top-0 left-0 -translate-x-1/3 -translate-y-1/3",
"top-right": "top-0 right-0 translate-x-1/3 -translate-y-1/3",
"bottom-left": "bottom-0 left-0 -translate-x-1/3 translate-y-1/3",
"bottom-right": "bottom-0 right-0 translate-x-1/3 translate-y-1/3",
};
const fallbackColorMap: Record<AvatarColor, string> = {
default: "bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300",
primary: "bg-sky-500/15 text-sky-600 dark:text-sky-400",
secondary: "bg-purple-500/15 text-purple-600 dark:text-purple-400",
accent: "bg-pink-500/15 text-pink-600 dark:text-pink-400",
success: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
warning: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
danger: "bg-rose-500/15 text-rose-600 dark:text-rose-400",
};
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
AvatarProps
>(
(
{
size = "md",
color = "default",
radius = "full",
isBordered = false,
isDisabled = false,
isPressable = false,
status,
statusPosition = "bottom-right",
isEditable = false,
onUpload,
className,
children,
tabIndex,
title,
description,
...props
},
ref,
) => {
const { isInGroup } = useAvatarContext();
const isEffectivelyDisabled = isDisabled;
const keyboardProps = useKeyboardClick<HTMLSpanElement>(
isPressable && !isEffectivelyDisabled,
);
const editableKeyboardProps = useKeyboardClick<HTMLDivElement>(
isEditable && !isEffectivelyDisabled,
);
const avatarContent = (
<div className="relative inline-flex shrink-0 group">
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex shrink-0 overflow-hidden items-center justify-center select-none font-semibold transition-all duration-200",
avatarSizes[size],
designRadius[radius],
isBordered &&
cn(
"ring-offset-2 ring-offset-background",
avatarColorBorders[color],
),
isPressable &&
!isEffectivelyDisabled &&
"cursor-pointer hover:scale-105 active:scale-95 hover:opacity-90 transition-all duration-200 ease-in-out will-change-transform focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring outline-none",
isEffectivelyDisabled &&
"opacity-50 grayscale cursor-not-allowed pointer-events-none",
!title && !description && className,
)}
{...keyboardProps}
{...(tabIndex !== undefined ? { tabIndex } : {})}
{...props}
>
{children}
{isEditable && (
<div
onClick={(e) => {
e.stopPropagation();
onUpload?.();
}}
className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity cursor-pointer text-white"
aria-label="Upload image"
{...editableKeyboardProps}
>
<svg
className="size-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
)}
</AvatarPrimitive.Root>
{status && (
<span
aria-hidden="true"
className={cn(
"absolute size-3 rounded-full ring-2 ring-white dark:ring-zinc-900 z-10",
statusColors[status],
statusPositions[statusPosition],
)}
/>
)}
</div>
);
if ((title || description) && !isInGroup) {
return (
<AvatarContext.Provider value={{ color }}>
<div className={cn("inline-flex items-center gap-3", className)}>
{avatarContent}
<div className="flex flex-col">
{title && (
<span className="text-sm font-semibold text-zinc-950 dark:text-zinc-50 leading-none">
{title}
</span>
)}
{description && (
<span className="text-xs text-zinc-500 dark:text-zinc-400 mt-1 leading-none">
{description}
</span>
)}
</div>
</div>
</AvatarContext.Provider>
);
}
return (
<AvatarContext.Provider value={{ color }}>
{avatarContent}
</AvatarContext.Provider>
);
},
);
Avatar.displayName = "Avatar";
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square size-full object-cover", className)}
{...props}
/>
));
AvatarImage.displayName = "AvatarImage";
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => {
const { color } = useAvatarContext();
return (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex size-full items-center justify-center font-semibold text-xs leading-none select-none",
fallbackColorMap[color],
className,
)}
{...props}
/>
);
});
AvatarFallback.displayName = "AvatarFallback";
export type { AvatarColor, AvatarSize, StatusPosition };
export { Avatar, AvatarContext, AvatarFallback, AvatarImage };