"use client"; import { motion, useMotionValue, useReducedMotion, useSpring, } from "motion/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { MorphPopover, MorphPopoverContent, MorphPopoverTrigger, } from "@/components/motion/popover-morph"; import { Tooltip } from "@/components/motion/tooltip"; import { EASE_OUT, SPRING_LAYOUT, SPRING_MOUSE } from "@/lib/ease"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; export type AvatarStatus = "online" | "away" | "busy" | "offline"; export interface AvatarGroupItem { id: string; name: string; /** Image URL. Falls back to initials on a colored disc. */ src?: string; /** Background for the initials fallback (any CSS color). */ color?: string; status?: AvatarStatus; /** Shows a pulsing ring around the avatar. */ typing?: boolean; } export type AvatarGroupSize = "sm" | "md" | "lg"; export interface AvatarGroupProps { items: AvatarGroupItem[]; /** Avatars shown before the rest collapse into a "+N" chip. Default 4. */ max?: number; size?: AvatarGroupSize; /** Makes avatars buttons; also used for rows in the overflow list. */ onSelect?: (item: AvatarGroupItem) => void; /** Accessible label for the group. Default "Collaborators". */ label?: string; className?: string; /** Ring color separating overlapped avatars; match the surface behind. */ ringClassName?: string; } const SIZES: Record< AvatarGroupSize, { box: number; overlap: number; fan: number; text: string; dot: number } > = { sm: { box: 28, overlap: 6, fan: 6, text: "text-[10px]", dot: 8 }, md: { box: 36, overlap: 8, fan: 8, text: "text-xs", dot: 10 }, lg: { box: 44, overlap: 10, fan: 10, text: "text-sm", dot: 12 }, }; const STATUS_CLASS: Record = { online: "bg-emerald-500", away: "bg-amber-400", busy: "bg-rose-500", offline: "bg-muted-foreground/60", }; const STATUS_LABEL: Record = { online: "online", away: "away", busy: "busy", offline: "offline", }; const CHART_VARS = [1, 2, 3, 4, 5].map((n) => `var(--chart-${n})`); /** Deterministic fallback color from the name, so SSR and client agree. */ function colorFor(item: AvatarGroupItem) { if (item.color) return item.color; let hash = 0; for (const ch of item.name) hash = (hash * 31 + ch.charCodeAt(0)) | 0; return CHART_VARS[Math.abs(hash) % CHART_VARS.length]; } function initials(name: string) { const parts = name.trim().split(/\s+/); return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase(); } function describe(item: AvatarGroupItem) { const bits = [item.name]; if (item.status) bits.push(STATUS_LABEL[item.status]); if (item.typing) bits.push("typing"); return bits.join(", "); } /** * Overlapping avatar stack that fans apart on hover or keyboard focus. * Each avatar carries a name tooltip, an optional presence dot and an * "is typing" pulse; avatars past `max` collapse into a "+N" chip that opens * the full list in a popover. */ export function AvatarGroup({ items, max = 4, size = "md", onSelect, label = "Collaborators", className, ringClassName = "ring-background", }: AvatarGroupProps) { const reduce = useReducedMotion() ?? false; const canHover = useHoverCapable(); const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const [listOpen, setListOpen] = useState(false); const s = SIZES[size]; const shown = items.slice(0, max); const hidden = items.slice(max); const expanded = (hovered || focused || listOpen) && !reduce; return ( // Hover/focus only drive the decorative fan-out; every control inside is a // real button.
{ if (e.pointerType === "mouse" && canHover) setHovered(true); }} onPointerLeave={() => setHovered(false)} onFocus={() => setFocused(true)} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setFocused(false); } }} > {label} {shown.map((item, i) => ( ))} {hidden.length > 0 ? (
    {items.map((item) => { const row = ( <> {item.name} {item.typing ? ( typing… ) : item.status ? ( {STATUS_LABEL[item.status]} ) : null} ); return (
  • {onSelect ? ( ) : (
    {row}
    )}
  • ); })}
) : null}
); } // Typing pulse: a slow breathe so it reads as "activity" rather than an alert. const TYPING_PULSE = { duration: 1.4, repeat: Number.POSITIVE_INFINITY, ease: EASE_OUT, } as const; function Avatar({ item, size, ringClassName, onSelect, reduce, lift = false, decorative = false, }: { item: AvatarGroupItem; size: AvatarGroupSize; ringClassName: string; onSelect?: (item: AvatarGroupItem) => void; reduce: boolean; lift?: boolean; decorative?: boolean; }) { const s = SIZES[size]; const color = colorFor(item); const face = ( <> {item.typing ? ( reduce ? ( ) : ( ) ) : null} {item.src ? ( // biome-ignore lint/performance/noImgElement: library component, no framework image loader assumed. ) : ( initials(item.name) )} {item.status ? ( ) : null} ); const box = { width: s.box, height: s.box }; const hover = lift ? { y: -3 } : undefined; if (decorative) { return ( {face} ); } if (onSelect) { return ( onSelect(item)} whileHover={hover} transition={SPRING_LAYOUT} className="relative block shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background" style={box} > {face} ); } return ( {face} ); } /* -------------------------------------------------------------------------- */ /* Live cursors */ /* -------------------------------------------------------------------------- */ export interface LiveCursor { id: string; name: string; /** Any CSS color. */ color: string; /** Horizontal position, percent of the container width (0–100). */ x: number; /** Vertical position, percent of the container height (0–100). */ y: number; } export interface LiveCursorsProps { cursors: LiveCursor[]; className?: string; } /** * Collaboration cursors layered over a `relative` container. Positions arrive * as container-relative percentages (e.g. from a presence channel) and each * cursor springs toward its latest point, smoothing out sparse updates. */ export function LiveCursors({ cursors, className }: LiveCursorsProps) { const ref = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); useEffect(() => { const el = ref.current; if (!el) return; const observer = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; setSize((prev) => prev.width === width && prev.height === height ? prev : { width, height }, ); }); observer.observe(el); return () => observer.disconnect(); }, []); return (
{size.width > 0 ? cursors.map((cursor) => ( )) : null}
); } function CursorMark({ cursor, width, height, }: { cursor: LiveCursor; width: number; height: number; }) { const reduce = useReducedMotion() ?? false; const toPx = useCallback( (pct: number, extent: number) => (Math.min(Math.max(pct, 0), 100) / 100) * extent, [], ); const targetX = useMotionValue(toPx(cursor.x, width)); const targetY = useMotionValue(toPx(cursor.y, height)); const springX = useSpring(targetX, SPRING_MOUSE); const springY = useSpring(targetY, SPRING_MOUSE); useEffect(() => { targetX.set(toPx(cursor.x, width)); targetY.set(toPx(cursor.y, height)); }, [cursor.x, cursor.y, width, height, targetX, targetY, toPx]); return ( {cursor.name} ); }