"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
const avatarGroupVariants = cva("flex w-fit items-center", {
variants: {
size: {
xs: "-space-x-1.5",
sm: "-space-x-2",
default: "-space-x-2",
lg: "-space-x-2.5",
},
// The ring is what keeps overlapping faces apart; drop it only where the
// group sits on a background it cannot know.
ring: {
true: "*:ring-2 *:ring-background",
false: "",
},
},
defaultVariants: { size: "default", ring: true },
})
type AvatarGroupSize = NonNullable<VariantProps<typeof avatarGroupVariants>["size"]>
// COUPLED TO registry/vibra/ui/avatar.tsx: Avatar sizes itself with `size-8`
// and its fallback with `text-sm`, and its own data-[size=…] variants beat a
// plain utility on specificity. So the four sizes here are set as flat classes
// that tailwind-merge folds into those base ones, and `size` is left off the
// primitive. If Avatar ever moves its base size into a data-variant, these
// stop winning — avatar-group.test.tsx pins the rendered size classes.
const AVATAR_SIZES: Record<AvatarGroupSize, { root: string; text: string }> = {
xs: { root: "size-5", text: "text-avatar" },
sm: { root: "size-6", text: "text-2xs" },
default: { root: "size-8", text: "text-xs" },
lg: { root: "size-10", text: "text-sm" },
}
export type AvatarGroupUser = {
name: string
src?: string
/** Replaces the initials taken from `name`. */
fallback?: string
}
export type AvatarGroupProps = React.ComponentProps<"div"> & {
users: AvatarGroupUser[]
/** How many faces to show before the rest collapse into a "+N" chip. */
max?: number
size?: AvatarGroupSize
ring?: boolean
}
/** A row of overlapping faces, with the overflow named for anyone who cannot see the pictures. */
function AvatarGroup({
className,
users,
max,
size = "default",
ring = true,
...props
}: AvatarGroupProps) {
if (users.length === 0) return null
const limit = max === undefined ? users.length : Math.max(0, max)
const shown = users.slice(0, limit)
const hidden = users.slice(limit)
const sizing = AVATAR_SIZES[size]
return (
<div
data-slot="avatar-group"
data-size={size}
data-ring={ring || undefined}
role="group"
aria-label={`${users.length} ${users.length === 1 ? "person" : "people"}`}
className={cn(avatarGroupVariants({ size, ring }), className)}
{...props}
>
{shown.map((user, index) => (
<Avatar
// Two people can share a name; the position is what makes the key unique.
key={`${user.name}-${index}`}
role="img"
aria-label={user.name}
className={sizing.root}
>
{user.src ? <AvatarImage src={user.src} alt="" /> : null}
<AvatarFallback aria-hidden="true" className={sizing.text}>
{user.fallback ?? getInitials(user.name)}
</AvatarFallback>
</Avatar>
))}
{hidden.length > 0 ? (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
data-slot="avatar-group-overflow"
// The tooltip only reaches a pointer or a focused control, so
// the names it lists are also the button's own name.
aria-label={`${hidden.length} more: ${hidden.map((user) => user.name).join(", ")}`}
className={cn(
"relative inline-flex shrink-0 cursor-default items-center justify-center rounded-full bg-muted font-medium text-muted-foreground tabular-nums focus-ring",
sizing.root,
sizing.text
)}
>
{`+${hidden.length}`}
</button>
}
/>
<TooltipContent>{hidden.map((user) => user.name).join(", ")}</TooltipContent>
</Tooltip>
) : null}
</div>
)
}
export { AvatarGroup, avatarGroupVariants }