import React, { useEffect, useState, type ComponentProps, type ElementType } from "react"; import { Avatar, AvatarGroup, useChakraContext, type SystemStyleObject } from "@chakra-ui/react"; import { Tooltip } from "./ToggleTip"; import { finePointer, spreadContainerCss, spreadItemCss, useTouchSpread } from "./useTouchSpread"; export interface AvatarStackItem { /** shown in the tooltip, and as initials when there is no icon/image */ name: string /** an IconType (getMusicServiceIcon) or Chakra icon component (getMusicServiceChakraIcon) */ icon?: ElementType src?: string colorPalette?: string } export interface AvatarStackProps extends Omit, 'children'> { items: AvatarStackItem[] /** avatars shown before the overflow chip. Default 5 */ max?: number /** total used for the overflow chip (shows count - max). Default items.length */ count?: number /** gap between avatars while the stack is hovered. Default '1' */ spread?: SystemStyleObject['spaceX'] /** locale for the overflow number */ locale?: string /** screen reader text for the overflow chip. Default `and ${n} others` */ moreLabel?: (n: string) => string /** reserve the spread width while collapsed (as trailing space) so spreading never changes layout, ie never makes neighbours wrap. Default false */ reserveSpread?: boolean } // Avatar.Root must receive the props AvatarGroup (Group) clones onto its children (data-group-item, --group-index...) // but Tooltip.Root renders no element, so this component takes them and puts them on Avatar.Root itself const StackAvatar = (props: ComponentProps & { item: AvatarStackItem, tooltipOpen?: boolean }) => { const {item: {name, icon: Icon, src, colorPalette}, tooltipOpen, ...rest} = props; return ( {/* icons scale with the avatar instead of its font size, so they fill most of the circle */} {Icon !== undefined ? : undefined} {src !== undefined ? : undefined} ); } /** * AvatarGroup that spreads out while hovered and lifts the hovered avatar, with a tooltip of its name. * Avatars past `max` are replaced by a "+n" chip. * * Hover effects only apply to devices with a fine pointer. * On touch devices the first tap spreads the stack until a tap outside of it, then tapping an avatar shows its tooltip. * Motion is removed for prefers-reduced-motion. * Inspired by https://moonarc.dev/components/avatar-stack/ * */ export const AvatarStack = (props: AvatarStackProps) => { const { items, max = 5, count = items.length, spread = '1', locale, moreLabel = (n) => `and ${n} others`, reserveSpread = false, ...rest } = props; const visible = items.slice(0, max); const remaining = count - visible.length; const remainingStr = remaining.toLocaleString(locale); const touch = useTouchSpread(); const {token} = useChakraContext(); let reserveCss: SystemStyleObject | undefined; const gaps = visible.length + (remaining > 0 ? 1 : 0) - 1; if (reserveSpread && gaps > 0) { // spreading only widens the gaps, from the group overlap (AvatarGroup's spaceX, -3) to `spread`. // padding the end by the total of that difference, removed in step with the spread (same transition), keeps the width constant // negative spacing ('-3') has no token of its own, it is the positive token negated const spacing = (val: unknown): string => { if (typeof val !== 'string' && typeof val !== 'number') { return '0px'; } const str = String(val); return str.startsWith('-') ? `(${spacing(str.slice(1))} * -1)` : token.var(`spacing.${str}`, str); }; const delta = `(${spacing(spread)} - ${spacing(rest.spaceX ?? '-3')})`; reserveCss = { paddingInlineEnd: `calc(${gaps} * ${delta})`, _motionSafe: {transitionProperty: 'padding', transitionDuration: 'moderate', transitionTimingFunction: 'ease-out'}, [finePointer]: {_hover: {paddingInlineEnd: '0'}}, '&[data-spread]': {paddingInlineEnd: '0'}, }; } // avatar tapped while spread by touch, touch never opens a tooltip by itself const [active, setActive] = useState(undefined); useEffect(() => { if (!touch.spread) { setActive(undefined); } }, [touch.spread]); return ( {visible.map((x, i) => ( setActive(i)} // undefined when not spread by touch so mouse hover works as usual tooltipOpen={touch.spread ? active === i : undefined} /> ))} {remaining > 0 ? ( // wider numbers stretch the chip into a pill // offset (not margin, the group's spacing overrides it) so it reads as separate from the list +{remainingStr} ) : undefined} ); }