// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file AvatarGroup.tsx * @input Uses React, StyleX, theme tokens, AvatarGroupContext * @output Exports AvatarGroup component and AvatarGroupProps * @position Core implementation; consumed by index.ts * * Compositional API: children are Avatar elements (and optionally * one AvatarGroupOverflow). The group provides overlap styling via * context — no child introspection needed. * * When the group contains interactive avatars (rendered as links/buttons) or an * interactive AvatarGroupOverflow, it becomes a single Tab stop with roving * arrow-key focus (via useListFocus with hasRovingTabIndex) and exposes a * screen-reader keyboard hint via aria-describedby. A purely static facepile * gets neither — it stays a plain non-focusable group. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/AvatarGroup/AvatarGroup.doc.mjs (props table, features) * - /packages/core/src/AvatarGroup/index.ts (exports if types change) * - /apps/storybook/stories/AvatarGroup.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/AvatarGroup/ (showcase blocks) */ import { useId, useMemo, useState, type ReactNode } from 'react'; import type { BaseProps } from "../BaseProps.js"; import { resolveSize, type AvatarShape, type AvatarSize } from "../Avatar/index.js"; import * as stylex from "../style-runtime.js"; import { mergeProps } from "../utils/index.js"; import { composeEventHandlers } from "../utils/composeEventHandlers.js"; import { AvatarGroupContext } from "./AvatarGroupContext.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import { useListFocus } from "../hooks/useListFocus.js"; import { useIsomorphicLayoutEffect } from "../hooks/useIsomorphicLayoutEffect.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; const OVERLAP_RATIO = 0.25; export interface AvatarGroupProps extends BaseProps { /** Ref forwarded to the root element. */ ref?: React.Ref; /** * Avatar children, optionally followed by one AvatarGroupOverflow. * Consumers are responsible for slicing to the desired visible count. */ children: ReactNode; /** * Size applied to all avatars via context. This wins over each child * Avatar's own `size` prop, including when it is left at the default, so * set the size here rather than on the children. * @default 'md' */ size?: AvatarSize; /** * Shape applied to all avatars via context, overriding each avatar's own * `shape` prop so a group stays visually uniform. Also applied to the * `AvatarGroupOverflow` "+N" indicator, so it matches the group. * @default 'circle' */ shape?: AvatarShape; /** * Test ID for integration testing. */ 'data-testid'?: string; } const styles = { root: { k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", $$css: true as const } }; /** * Stacked avatar display showing multiple avatars overlapping with an * optional overflow indicator. Uses a compositional children-based API * so each avatar can carry its own props (status dots, click handlers, etc.). * * Consumers handle slicing — pass only the avatars you want visible, * then add an AvatarGroupOverflow for the "+N" indicator. * * @example * ``` * * {users.slice(0, 3).map(u => ( * * ))} * * * ``` */ export function AvatarGroup({ children, size = 'md', shape = 'circle', 'data-testid': testId, 'aria-label': ariaLabelFromProps, 'aria-describedby': ariaDescribedByFromProps, onKeyDown, onFocus, xstyle, className, style, ref, ...props }: AvatarGroupProps): ReactNode { const t = useTranslator(); const ariaLabel = ariaLabelFromProps ?? t('@astryx.avatarGroup.label'); const numericSize = resolveSize(size); const overlap = Math.round(numericSize * OVERLAP_RATIO); const contextValue = useMemo(() => ({ size, shape, overlap, numericSize }), [size, shape, overlap, numericSize]); // The keyboard hint and roving tab stop only make sense once the group has // interactive children. Detect their presence from the rendered DOM after // commit. (Arrow-key direction is auto-detected inside useListFocus.) const [hasInteractiveItems, setHasInteractiveItems] = useState(false); // Single tab stop + roving arrow focus over the group's interactive items. // `itemSelector` targets the shared `[data-avatar-item]` marker stamped on // interactive avatars (their rendered /