// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Avatar.tsx * @input Uses React, HTMLAttributes, ReactNode, useState, useRef; useTooltip * (Tooltip hook) for the optional name-on-hover tooltip; useTranslator (i18n) * @output Exports Avatar component, AvatarProps, AvatarSize types * @position Core implementation; consumed by index.ts * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Avatar/Avatar.doc.mjs (props table, features, implementation notes) * - /packages/core/src/Avatar/index.ts (exports if types change) * - /packages/core/src/Avatar/AvatarStatusLabelContext.ts (the status label ref) * - /apps/storybook/stories/Avatar.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/Avatar/ (showcase blocks) * * Last synced props: alt, fallbackSrc, name, size, src, status, href, as, target, rel, onClick */ import { isValidElement, useCallback, useMemo, useRef, useState, type ReactNode } from 'react'; import type { BaseProps } from "../BaseProps.js"; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, typographyVars, fontWeightVars, radiusVars } from "../theme/tokens.stylex.js"; import { AvatarSizeContext } from "./AvatarSizeContext.js"; import { AvatarStatusLabelContext, type AvatarStatusLabelTarget } from "./AvatarStatusLabelContext.js"; import { useAvatarGroup } from "../AvatarGroup/AvatarGroupContext.js"; import { mergeProps } from "../utils/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import { themeProps } from "../utils/themeProps.js"; import { firstCharacter } from "../utils/characters.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { useTooltip } from "../Tooltip/useTooltip.js"; import { useDevWarning } from "../hooks/useDevWarning.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import type { LinkComponentType } from "../Link/types.js"; import { useTranslator, type TranslatorFn } from "../i18n/index.js"; /** * The offset ratio for positioning elements on a circle's edge at 45°. * * For a square with side length S containing an inscribed circle of diameter S, * a diagonal line from corner to corner intersects the circle at: * x = S/2 × (1 ± 1/√2) * * The distance from the corner to this intersection point (along each axis) is: * S/2 × (1 - 1/√2) ≈ 0.146S * * This constant represents that ratio: (1 - 1/√2) / 2 ≈ 0.146 */ const CIRCLE_EDGE_OFFSET_RATIO = (1 - 1 / Math.SQRT2) / 2; /** * The ratio of font size to avatar size for initials. * * At 40%, two-letter initials fit comfortably within the circle with adequate * padding. This ratio provides good legibility across all avatar sizes: * - 24px avatar → 9.6px font * - 48px avatar → 19.2px font * - 128px avatar → 51.2px font */ const INITIALS_FONT_SIZE_RATIO = 0.4; /** * Named size options. * * Avatar uses the same abbreviated scale as Icon (`xsm`/`sm`/`md`/`lg`/`xl`), * but the values are larger because avatars align with media rather than * glyphs. The tiers follow the standard avatar size scale. */ type AvatarNamedSize = 'xsm' | 'sm' | 'md' | 'lg' | 'xl'; /** * Numeric size options (in pixels) */ type AvatarNumericSize = 16 | 20 | 24 | 32 | 36 | 40 | 48 | 60 | 64 | 72 | 96 | 128 | 144 | 180; /** * Avatar size - can be a named size or a specific pixel value */ export type AvatarSize = AvatarNamedSize | AvatarNumericSize; /** * Avatar shape options */ export type AvatarShape = 'circle' | 'rounded' | 'square'; /** * Resolves named sizes to their numeric pixel values */ export function resolveSize(size: AvatarSize): number { if (typeof size === 'number') { return size; } switch (size) { case 'xsm': return 20; case 'sm': return 24; case 'md': return 36; case 'lg': return 48; case 'xl': return 128; } } // Sets one local custom property rather than `borderRadius` directly, so // every clipping/ring surface that needs the shape's radius (the wrapper, // the interactive focus ring, the content div that actually clips via // `overflow: hidden`, and AvatarGroupOverflow's "+N" indicator) reads the // same value instead of each hardcoding its own borderRadius independently. // A theme override only has to reach this one property, not fight the // per-surface StyleX class order to change the shape everywhere at once. export const shapeStyles = { circle: { "--_avatar-radius": "xeincmr", $$css: true as const }, rounded: { "--_avatar-radius": "xdarwi0", $$css: true as const }, square: { "--_avatar-radius": "x877142", $$css: true as const } }; /** * Base styles for the avatar * Uses a wrapper/content structure so status isn't clipped by overflow:hidden */ const styles = { wrapper: { kVAEAm: "x1n2onr6", k1xSpc: "x3nfvp2", kmuXW: "x2lah0s", kaIpWk: "xbc78cj", $$css: true as const }, fallback: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kzqmXN: "xh8yej3", kZKoxP: "x5yr21d", kWkggS: "x17x4s8c", kMwMTN: "xv1l7n4", kMv6JI: "x9ynric", k63SB2: "x1e4wzip", kP9fke: "xtvhhri", $$css: true as const }, status: { kVAEAm: "x10l6tqk", $$css: true as const }, interactive: { kysU6D: "xjyslct", kmVPX3: "x1717udv", kogj98: "x1ghz6dp", kMzoRj: "xc342km", ksu8eU: "xng3xce", kWkggS: "xjbqb8w", kMwMTN: "x1heor9g", kVVagm: "xln7xf2", kybGjl: "x1hl2dhg", kkrTdU: "x1ypdohk x16khyan", kaIpWk: "xbc78cj", $$css: true as const } }; /** * Dynamic styles that depend on the avatar size */ const _temp = { kGuDYH: "xdmh292", "$$css": true }; const _temp2 = { krVfgx: "x1nqzi6q", kt4wiu: "xqxgn94", k3aq6I: "x4v1no5 " + "xqgnvz5", "$$css": true }; const dynamicStyles = { size: (size: number) => [{ kzqmXN: size != null ? "x5lhr3w" : size, kZKoxP: size != null ? "x16ye13r" : size, $$css: true as const }, { "--x-width": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(size), "--x-height": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(size) }], fontSize: (size: number) => [_temp, { "--x-fontSize": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(`${size * INITIALS_FONT_SIZE_RATIO}px`) }], statusPositionCircle: (size: number) => [_temp2, { "--x-bottom": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(size * CIRCLE_EDGE_OFFSET_RATIO), "--x-insetInlineEnd": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(size * CIRCLE_EDGE_OFFSET_RATIO) }], statusPositionCorner: { krVfgx: "x1ey2m1c", kt4wiu: "xtijo5x", k3aq6I: "xv8yid9 x10fxb7p", $$css: true as const } }; const BORDER_WIDTH = 2; const groupStyles = { ring: { kMzoRj: "xdh2fpr", ksu8eU: "x1y0btm7", kVAM5u: "x1touxvs", kWkggS: "x10xzikg", kB7OPa: "x1afcbsf", $$css: true as const }, overlap: { keTefX: "x1sik18w", $$css: true as const } }; const _temp3 = { "--_avatar-group-overlap": "xlz5hwt", "$$css": true }; const groupDynamicStyles = { overlap: (offset: number) => [_temp3, { "--x---_avatar-group-overlap": `${offset}px` != null ? `${offset}px` : undefined }] }; export interface AvatarProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * The alt text shown on hover and made accessible to screen readers. * Falls back to `name` if not provided. */ alt?: string; /** * testid for tests. */ 'data-testid'?: string; /** * Fallback image source when primary `src` fails to load. * If this also fails, shows initials derived from `name`. */ fallbackSrc?: string; /** * The user's name. Used for: * - Generating initials when no image is available * - Default alt text if `alt` is not provided */ name?: string; /** * The size of the avatar. A named size (`xsm` 20px, `sm` 24px, `md` 36px, * `lg` 48px, `xl` 128px) or a specific pixel value. * * Inside an `AvatarGroup` the group's `size` wins: a group sizes its members * uniformly, so this prop is ignored there. * @default 'md' */ size?: AvatarSize; /** * Shape variant of the avatar. * - 'circle': Full circle (default) * - 'rounded': Rounded square * - 'square': Sharp square * @default 'circle' */ shape?: AvatarShape; /** * The primary image source for the avatar. */ src?: string; /** * Content displayed in the corner of the avatar. * Typically used for status indicators or badges. * * `AvatarStatusDot` reports its own `label` to the avatar, which composes * it into the avatar's accessible name (e.g. "Jane Doe, Online") so * assistive tech can reach the status: the `role="img"` root prunes * descendant semantics (WCAG 4.1.2). Reporting goes through context, so it * still works when the dot sits inside a wrapper component of your own. A * custom status element that is not an `AvatarStatusDot` names itself the * same way, by rendering an `AvatarStatusDot` or by carrying a string * `label` prop. */ status?: ReactNode; /** * Tooltip shown on hover (and keyboard focus). * - omitted / `true`: show the avatar's `name` * - a string: show that text instead * - `false`: no tooltip * * The avatar owns this tooltip. It is NOT auto-disabled when wrapped in your * own Tooltip/HoverCard — set `tooltip={false}` if you provide your own * overlay. No tooltip is shown if `tooltip` is `true`/omitted and there is * no (non-whitespace) `name`. * @default true */ tooltip?: string | boolean; /** * When provided, the avatar becomes an interactive link (`` or custom * link component) pointing at `href`. Follows the same element-swap rules as * Button: `href` renders a link, otherwise `onClick` renders a * `; } else { rootElement =
{visualContent}
; } const avatarElement = {rootElement} ; // Always return the same structure so the avatar keeps its position in the // React tree regardless of the tooltip flag — toggling it must not remount // the avatar subtree (and lose image-load state). The tooltip is a sibling // (no wrapper DOM); the hook's ref is already on the root via `rootRef`. return <> {avatarElement} {showTooltip ? tooltipHook.renderTooltip(trimmedTooltip) : null} ; } Avatar.displayName = 'Avatar';