/** * @title Avatar Group * @description 여러 아바타를 겹쳐 쌓거나 격자로 모아 보여주고, 넘치는 인원은 +N 메뉴로 펼쳐요. */import * as React from 'react';import { Popover as PopoverPrimitive, Tooltip as TooltipPrimitive } from 'radix-ui';import { cn } from '@/lib/utils';import { Avatar, avatarRadiusClass, type AvatarProps as BaseAvatarProps } from '@/components/ui/avatar';// Atlassian @atlaskit/avatar-group 스펙 (avatar-group / stack / grid / more-indicator / avatar-group-item 해독 값)/** AvatarGroup 이 지원하는 크기 (16px 계열은 접근성 문제로 제외) */export type AvatarGroupSize = 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge';export type AvatarProps = BaseAvatarProps & { name: string; key?: string | number;};export type onAvatarClickHandler = ( event: React.MouseEvent<HTMLElement>, analyticsEvent: undefined, index: number,) => void;const MAX_COUNT = { grid: 11, stack: 5 } as const;const MAX_DISPLAY_COUNT = 99;/* ------------------------------------------------------------------------------------------------- * MoreIndicator — "+N" 버튼 * -----------------------------------------------------------------------------------------------*/const moreSizeClass: Record<AvatarGroupSize, string> = { small: 'size-6 ds-font-body-small', medium: 'size-8 ds-font-body-small', large: 'size-10 ds-font-body-small', xlarge: 'size-24 ds-font-body-large', xxlarge: 'size-32 ds-font-body-large',};export interface MoreIndicatorProps extends Omit<React.ComponentProps<'button'>, 'onClick'> { appearance?: 'circle' | 'square'; borderColor?: string; size?: AvatarGroupSize; /** 숨겨진 아바타 수. 99 를 넘으면 +99 로 보여줘요. */ count?: number; /** 메뉴가 열려 있을 때 선택 스타일 */ isActive?: boolean; /** 스크린 리더용 이름. 기본 "외 N명" */ moreIndicatorLabel?: string; buttonProps?: Partial<React.ComponentProps<'button'>>; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void; testId?: string;}const MoreIndicator = React.forwardRef<HTMLButtonElement, MoreIndicatorProps>(function MoreIndicator( { appearance = 'circle', borderColor = 'var(--ds-surface)', size = 'medium', count = 0, isActive, moreIndicatorLabel, buttonProps = {}, onClick, testId, className, style, ...props }, ref,) { const displayCount = count > MAX_DISPLAY_COUNT ? MAX_DISPLAY_COUNT : count; const ariaLabel = moreIndicatorLabel || buttonProps['aria-label'] || `외 ${displayCount}명`; return ( <button type="button" {...buttonProps} {...props} ref={ref} data-slot="avatar-group-more" data-testid={testId} data-active={isActive || undefined} aria-label={ariaLabel} onClick={(event) => { buttonProps.onClick?.(event); onClick?.(event); }} style={{ '--avatar-border-color': borderColor, ...style } as React.CSSProperties} className={cn( 'm-ds-025 box-content flex cursor-pointer flex-col items-stretch justify-center overflow-hidden border-none p-ds-0 [transform:translateZ(0)]', 'bg-ds-background-neutral text-ds-text shadow-[0_0_0_2px_var(--avatar-border-color)]', 'transition-[background-color,border-color] duration-150 ease-[cubic-bezier(0.4,1,0.6,1)]', 'hover:bg-ds-background-neutral-hovered active:bg-ds-background-neutral-pressed', 'outline-2 outline-offset-2 outline-transparent focus-visible:shadow-none focus-visible:outline-ds-border-focused', avatarRadiusClass[size], appearance === 'circle' && 'rounded-ds-full', moreSizeClass[size], isActive && 'bg-ds-background-selected text-ds-text-selected shadow-[0_0_0_var(--ds-border-width-selected)_var(--ds-border-selected)] hover:bg-ds-background-selected-hovered active:bg-ds-background-selected-pressed', className, buttonProps.className, )} > +{displayCount} </button> );});/* ------------------------------------------------------------------------------------------------- * AvatarGroupItem — 넘침 메뉴의 한 줄 (menu ButtonItem / LinkItem 스펙) * -----------------------------------------------------------------------------------------------*/export interface AvatarGroupItemProps { avatar: AvatarProps; index: number; onAvatarClick?: onAvatarClickHandler; testId?: string;}const AvatarGroupItem = React.forwardRef<HTMLElement, AvatarGroupItemProps>(function AvatarGroupItem( { avatar, index, onAvatarClick, testId }, ref,) { const { href, target, onClick, name, key: _key, borderColor: _b, size: _s, testId: _t, ...rest } = avatar; const callback = onClick ? (event: React.MouseEvent<HTMLElement>) => onClick(event, undefined) : onAvatarClick ? (event: React.MouseEvent<HTMLElement>) => onAvatarClick(event, undefined, index) : undefined; const Comp = (href ? 'a' : 'button') as React.ElementType; return ( <Comp ref={ref} role="menuitem" data-slot="avatar-group-item" data-testid={testId} data-avatar-group-item="" tabIndex={-1} disabled={Comp === 'button' ? avatar.isDisabled : undefined} type={Comp === 'button' ? 'button' : undefined} {...(href ? { href, target, rel: target === '_blank' ? 'noopener noreferrer' : undefined } : {})} onClick={(event: React.MouseEvent<HTMLElement>) => callback?.(event)} className={cn( 'relative m-ds-0 box-border flex min-h-10 w-full cursor-pointer select-none items-center gap-ds-150 border-0 px-ds-200 py-ds-100 text-left text-inherit no-underline', 'bg-ds-background-neutral-subtle hover:bg-ds-background-neutral-subtle-hovered active:bg-ds-background-neutral-subtle-hovered', 'transition-[background-color] duration-[50ms] ease-[cubic-bezier(0.4,1,0.6,1)]', 'outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ds-border-focused', 'disabled:cursor-not-allowed disabled:bg-ds-background-neutral-subtle disabled:text-ds-text-disabled', )} > <span className="flex min-h-6 min-w-6 shrink-0 items-center justify-center"> <Avatar {...rest} as="span" name="" size="small" borderColor="transparent" testId={testId && `${testId}--avatar`} /> </span> <span className="flex grow flex-col justify-center overflow-hidden"> <span className="block truncate ds-font-body">{name}</span> </span> </Comp> );});/* ------------------------------------------------------------------------------------------------- * Tooltip (ADS tooltip-container 스펙) * -----------------------------------------------------------------------------------------------*/function AvatarTooltip({ content, position, testId, children,}: { content: React.ReactNode; position: 'top' | 'bottom' | 'left' | 'right'; testId?: string; children: React.ReactNode;}) { return ( <TooltipPrimitive.Root> <TooltipPrimitive.Trigger asChild> <span className="inline-flex">{children}</span> </TooltipPrimitive.Trigger> <TooltipPrimitive.Portal> <TooltipPrimitive.Content side={position} sideOffset={8} data-testid={testId} className={cn( 'z-[800] box-border max-w-[240px] rounded-ds-small bg-ds-background-neutral-bold px-ds-075 py-ds-050 ds-font-body-small text-ds-text-inverse wrap-break-word', 'animate-in fade-in-0 duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0', )} > {content} </TooltipPrimitive.Content> </TooltipPrimitive.Portal> </TooltipPrimitive.Root> );}/* ------------------------------------------------------------------------------------------------- * 목록 모션 (platform-dst-motion-uplift ON) * 처음 그릴 때는 애니메이션 없이, 이후 추가되는 아바타는 80%→100% 확대 + 페이드 인(150ms), * 빠지는 아바타는 100%→80% 축소 + 페이드 아웃(100ms). 빠지는 게 끝난 뒤에 새 항목이 들어와요(exitThenEnter). * -----------------------------------------------------------------------------------------------*/type MotionItem = { key: React.Key; node: React.ReactNode };type RenderedMotionItem = MotionItem & { motion?: 'enter' | 'exit' };// motion.avatar.enter/exit 길이(150ms/100ms). 애니메이션이 꺼져 있어도 정리되도록 타이머로 끝을 맞춰요.const AVATAR_ENTER_MS = 150;const AVATAR_EXIT_MS = 100;type ExitingItem = { key: React.Key; node: React.ReactNode; index: number };function useAvatarListMotion(items: MotionItem[]): RenderedMotionItem[] { const keysSignature = items.map((item) => String(item.key)).join('\u0000'); const previousRef = React.useRef<{ keys: React.Key[]; nodes: Map<React.Key, ExitingItem> } | null>(null); const [entering, setEntering] = React.useState<ReadonlySet<React.Key>>(() => new Set()); const [exiting, setExiting] = React.useState<ExitingItem[]>([]); React.useLayoutEffect(() => { const previous = previousRef.current; if (!previous) return; const nextKeys = items.map((item) => item.key); const added = nextKeys.filter((key) => !previous.keys.includes(key)); const removed = previous.keys.filter((key) => !nextKeys.includes(key)).map((key) => previous.nodes.get(key)!); if (!added.length && !removed.length) return; setEntering(new Set(added)); if (removed.length) { setExiting((current) => [...current.filter((item) => !nextKeys.includes(item.key)), ...removed]); window.setTimeout(() => setExiting([]), AVATAR_EXIT_MS); } if (added.length) { window.setTimeout(() => setEntering(new Set()), (removed.length ? AVATAR_EXIT_MS : 0) + AVATAR_ENTER_MS); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [keysSignature]); // 다음 비교를 위해 이번에 그린 노드를 기억해요 React.useLayoutEffect(() => { previousRef.current = { keys: items.map((item) => item.key), nodes: new Map(items.map((item, index) => [item.key, { key: item.key, node: item.node, index }])), }; }); const visibleExiting = exiting.filter((item) => !items.some((current) => current.key === item.key)); if (!visibleExiting.length) { return items.map((item) => (entering.has(item.key) ? { ...item, motion: 'enter' } : item)); } // 나가는 중: 새로 들어올 항목은 잠시 숨기고, 나가는 항목을 원래 자리에 둬요 const result: RenderedMotionItem[] = items.filter((item) => !entering.has(item.key)); [...visibleExiting] .sort((a, b) => a.index - b.index) .forEach((item) => result.splice(Math.min(item.index, result.length), 0, { key: item.key, node: item.node, motion: 'exit' })); return result;}const avatarMotionClass = { enter: 'animate-in fade-in-0 zoom-in-80 fill-mode-backwards duration-(--ds-motion-avatar-enter-duration) ease-(--ds-motion-avatar-enter-curve)', exit: 'pointer-events-none animate-out fade-out-0 zoom-out-80 fill-mode-forwards duration-(--ds-motion-avatar-exit-duration) ease-(--ds-motion-avatar-exit-curve)',} as const;/* ------------------------------------------------------------------------------------------------- * AvatarGroup * -----------------------------------------------------------------------------------------------*/export interface AvatarGroupOverrides { AvatarGroupItem?: { render?: (Component: typeof AvatarGroupItem, props: AvatarGroupItemProps, index: number) => React.ReactNode; }; Avatar?: { render?: (Component: typeof Avatar, props: AvatarProps, index: number) => React.ReactNode; }; MoreIndicator?: { render?: (Component: typeof MoreIndicator, props: MoreIndicatorProps) => React.ReactNode; };}export interface AvatarGroupProps { /** stack 은 겹쳐 쌓기(기본 최대 5), grid 는 격자(기본 최대 11) */ appearance?: 'stack' | 'grid'; /** 아바타 데이터 배열. 각 항목은 Avatar props 와 같아요. */ data: AvatarProps[]; size?: AvatarGroupSize; /** 보여줄 최대 개수 (+N 포함). 0 이나 미지정이면 appearance 기본값 */ maxCount?: number; borderColor?: string; onAvatarClick?: onAvatarClickHandler; /** 지정하면 +N 을 눌렀을 때 메뉴 대신 이 함수를 불러요. */ onMoreClick?: (event: React.MouseEvent<HTMLButtonElement>) => void; showMoreButtonProps?: Partial<React.ComponentProps<'button'>>; moreIndicatorLabel?: string; isTooltipDisabled?: boolean; tooltipPosition?: 'top' | 'bottom' | 'left' | 'right'; /** 목록(ul)의 접근성 이름. 예: "담당자" */ label?: string; overrides?: AvatarGroupOverrides; testId?: string; className?: string;}function AvatarGroup({ appearance = 'stack', data, size = 'medium', maxCount, borderColor, onAvatarClick, onMoreClick, showMoreButtonProps = {}, moreIndicatorLabel, isTooltipDisabled, tooltipPosition = 'bottom', label = '아바타 그룹', overrides, testId, className,}: AvatarGroupProps) { const [isOpen, setIsOpen] = React.useState(false); const triggeredByKeyboard = React.useRef(false); const contentRef = React.useRef<HTMLDivElement>(null); const groupId = React.useId(); const max = maxCount === undefined || maxCount === 0 ? MAX_COUNT[appearance] : maxCount; const total = data.length; const maxAvatar = total > max ? max - 1 : max; const renderAvatar = (props: AvatarProps, index: number) => overrides?.Avatar?.render ? overrides.Avatar.render(Avatar, props, index) : <Avatar {...props} />; const renderMoreIndicator = (props: MoreIndicatorProps) => overrides?.MoreIndicator?.render ? overrides.MoreIndicator.render(MoreIndicator, props) : <MoreIndicator {...props} />; const focusItem = (which: 'first' | 'last' | 'next' | 'prev') => { const items = Array.from( contentRef.current?.querySelectorAll<HTMLElement>('[data-avatar-group-item]:not([disabled])') ?? [], ); if (!items.length) return; const current = items.indexOf(document.activeElement as HTMLElement); const nextIndex = which === 'first' ? 0 : which === 'last' ? items.length - 1 : which === 'next' ? (current + 1) % items.length : (current - 1 + items.length) % items.length; items[nextIndex]?.focus(); }; const avatars = data.slice(0, maxAvatar).map((avatarData, idx) => { const { key, ...avatarRest } = avatarData; const callback = avatarData.onClick ? (event: React.MouseEvent<HTMLElement>) => avatarData.onClick?.(event, undefined) : onAvatarClick ? (event: React.MouseEvent<HTMLElement>) => onAvatarClick(event, undefined, idx) : undefined; const finalAvatar = renderAvatar( { ...avatarRest, size, borderColor: borderColor || avatarData.borderColor, testId: testId && `${testId}--avatar-${idx}`, onClick: callback, stackIndex: max - idx, }, idx, ); const node = !isTooltipDisabled && !avatarData.isDisabled ? ( <AvatarTooltip content={avatarData.name} position={tooltipPosition} testId={testId && `${testId}--tooltip-${idx}`}> {finalAvatar} </AvatarTooltip> ) : ( finalAvatar ); return { key: key ?? idx, node }; }); let more: React.ReactNode = null; if (total > maxAvatar) { const count = total - maxAvatar; const moreProps: MoreIndicatorProps = { buttonProps: showMoreButtonProps, borderColor, count, size, testId: testId && `${testId}--overflow-menu--trigger`, isActive: isOpen, moreIndicatorLabel, }; if (typeof onMoreClick === 'function') { more = renderMoreIndicator({ ...moreProps, isActive: false, onClick: onMoreClick }); } else { more = ( <PopoverPrimitive.Root open={isOpen} onOpenChange={setIsOpen}> <PopoverPrimitive.Trigger asChild onPointerDown={() => { triggeredByKeyboard.current = false; }} onKeyDown={(event) => { triggeredByKeyboard.current = true; // 트리거에 포커스가 있을 때 아래 화살표로도 연다 if (event.key === 'ArrowDown' && !isOpen) { event.preventDefault(); setIsOpen(true); } }} > {renderMoreIndicator(moreProps)} </PopoverPrimitive.Trigger> <PopoverPrimitive.Portal> <PopoverPrimitive.Content ref={contentRef} side="bottom" align="end" sideOffset={8} collisionPadding={8} aria-label={label} data-testid={testId && `${testId}--overflow-menu`} onOpenAutoFocus={(event) => { event.preventDefault(); // 키보드로 열었을 때만 첫 항목에 포커스 if (triggeredByKeyboard.current) requestAnimationFrame(() => focusItem('first')); else contentRef.current?.focus(); }} onKeyDown={(event) => { const map: Record<string, 'first' | 'last' | 'next' | 'prev'> = { ArrowDown: 'next', ArrowUp: 'prev', Home: 'first', End: 'last', }; const action = map[event.key]; if (action) { event.preventDefault(); focusItem(action); } }} className={cn( 'z-[510] box-border block rounded-ds-large bg-ds-surface-overlay text-ds-text shadow-ds-overlay outline-none', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95', )} > <div role="menu" aria-label={label} data-testid={testId && `${testId}--section`} className="flex min-w-[250px] max-w-[800px] max-h-[300px] flex-col overflow-auto py-1.5" > {data.slice(maxAvatar).map((avatarData, i) => { const index = i + maxAvatar; const itemProps: AvatarGroupItemProps = { avatar: avatarData, index, onAvatarClick, testId: testId && `${testId}--avatar-group-item-${index}`, }; return ( <React.Fragment key={avatarData.key ?? index}> {overrides?.AvatarGroupItem?.render ? ( overrides.AvatarGroupItem.render(AvatarGroupItem, itemProps, index) ) : ( <AvatarGroupItem {...itemProps} /> )} </React.Fragment> ); })} </div> </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> ); } } const children = useAvatarListMotion(more ? [...avatars, { key: '__more', node: more }] : avatars); return ( <TooltipPrimitive.Provider delayDuration={300}> {appearance === 'stack' ? ( <ul id={groupId} data-slot="avatar-group" data-appearance="stack" data-testid={testId && `${testId}--avatar-group`} aria-label={label} className={cn( 'isolate m-ds-0 me-ds-100 flex list-none! p-ds-0 leading-none', size === 'small' && 'me-ds-050', className, )} > {children.map((child) => ( <li key={child.key} className={cn( 'm-ds-0 me-ds-negative-100', size === 'small' && 'me-ds-negative-050', child.motion && avatarMotionClass[child.motion], )} > {child.node} </li> ))} </ul> ) : ( <ul id={groupId} data-slot="avatar-group" data-appearance="grid" data-testid={testId && `${testId}--avatar-group`} aria-label={label} className={cn( 'isolate mt-ds-0 mb-ds-050 me-ds-negative-050 ms-ds-negative-050 flex list-none! flex-wrap justify-start px-ds-025 py-ds-0 leading-none', className, )} > {children.map((child) => ( <li key={child.key} className={cn('m-ds-0 mb-ds-100 px-ds-050', child.motion && avatarMotionClass[child.motion])}> {child.node} </li> ))} </ul> )} </TooltipPrimitive.Provider> );}export { AvatarGroup, AvatarGroupItem, MoreIndicator };export default AvatarGroup;