// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file AvatarStatusDot.tsx * @input Uses React, StyleX, theme tokens, AvatarSizeContext, and * AvatarStatusLabelContext (label reporting) * @output Exports AvatarStatusDot component and AvatarStatusDotProps type * @position Sub-component of Avatar; renders a size-aware status indicator * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Avatar/Avatar.doc.mjs (features, files table) * - /packages/core/src/Avatar/index.ts (exports) * - /apps/storybook/stories/Avatar.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/Avatar/ (showcase blocks) */ import React, { use, useCallback, useMemo, type ReactNode } from 'react'; import type { BaseProps } from "../BaseProps.js"; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, radiusVars } from "../theme/tokens.stylex.js"; import { AvatarSizeContext } from "./AvatarSizeContext.js"; import { AvatarStatusLabelContext } from "./AvatarStatusLabelContext.js"; import { isRenderable, mergeProps, mergeRefs } from "../utils/index.js"; import { themeProps } from "../utils/themeProps.js"; import type { AvatarStatusDotVariantMap } from "./index.js"; /** * Discrete size tier of the status dot, derived from the avatar size. * Keys the built-in shape glyph's stroke weight — see `GLYPH_STROKE_WIDTH`. */ type StatusDotSizeTier = 'small' | 'medium' | 'large'; /** * Resolves the status dot size, border width, icon size, and size tier * based on the avatar size. * * Uses discrete size tiers rather than a continuous ratio so the dot * looks intentional at every avatar size: * * | Avatar size | Tier | Dot | Border | Icon | Field | Glyph stroke | * |--------------|--------|------|--------|------|-------|--------------| * | ≤ 36px | small | 10px | 1px | — | 8px | 1px | * | 40–72px | medium | 20px | 2px | 12px | 16px | 1.5px | * | ≥ 96px | large | 32px | 4px | 18px | 24px | 2px | * * "Field" is the dot's inner field — the dot minus both borders — that the * shape glyph is drawn into; see `GLYPH_STROKE_WIDTH` for the stroke ladder. * * Icons are not rendered at the smallest tier — there isn't enough * room for them to be legible. The built-in shape glyphs (see * `glyphShapeMap`) do render there, so status stays distinguishable * without colour at every size. */ function resolveStatusDotSize(avatarSize: number): { dotSize: number; borderWidth: number; iconSize: number; tier: StatusDotSizeTier; } { if (avatarSize <= 36) { return { dotSize: 10, borderWidth: 1, iconSize: 0, tier: 'small' }; } if (avatarSize <= 72) { return { dotSize: 20, borderWidth: 2, iconSize: 12, tier: 'medium' }; } return { dotSize: 32, borderWidth: 4, iconSize: 18, tier: 'large' }; } /** * AvatarStatusDot variant type. Extensible via module augmentation of AvatarStatusDotVariantMap. */ export type AvatarStatusDotVariant = keyof AvatarStatusDotVariantMap; export interface AvatarStatusDotProps extends BaseProps { ref?: React.Ref; /** * The semantic variant of the dot. Each variant pairs a colour with a * distinct built-in shape so status is never conveyed by colour alone * (WCAG 2.1 SC 1.4.1): * - `success` — filled green dot (e.g. online, accepted) * - `neutral` — grey ring (e.g. away, offline, pending) * - `error` — red dot with a minus bar (e.g. busy, do not disturb) * * Matches the `variant` naming convention from `StatusDot`. * @default 'success' */ variant?: AvatarStatusDotVariant; /** * Accessible label for the status dot. * Describes the meaning of the indicator for screen readers * (e.g. "Online", "Accepted", "John Doe is busy"). * * Note: inside an Avatar the dot sits in the avatar's `role="img"` * subtree, where descendant semantics are pruned — the dot is never its * own stop there. Instead, Avatar reads this `label` and composes it into * its own accessible name (e.g. "Jane Doe, Online"), which is how the * status reaches assistive tech (WCAG 4.1.2). Standalone dots (outside an * Avatar) expose `role="img"` with this label directly. */ label?: string; /** * Optional icon to render centered inside the dot. * Accepts any ReactNode (typically an SVG icon). * The icon is automatically sized to fit the dot and hidden * at the smallest avatar sizes where there isn't enough room. * * A rendered icon replaces the variant's built-in shape glyph, so use a * different icon per status — the same icon on every variant leaves the * statuses distinguishable by colour alone (WCAG 1.4.1). At the smallest * avatar sizes the built-in glyph still shows instead of the icon. * Booleans and empty strings are ignored (safe for `cond && `), * but a component that renders nothing still counts as an icon and * suppresses the glyph. * * @example * ``` * } /> * ``` */ icon?: ReactNode; } const styles = { dot: { kaIpWk: "xjspbzw", ksu8eU: "x1y0btm7", kVAM5u: "x1touxvs", kB7OPa: "x9f619", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", $$css: true as const }, success: { kWkggS: "xdsz4j9", kMwMTN: "xrkvqaz", $$css: true as const }, neutral: { kWkggS: "x10xzikg", kMwMTN: "xv1l7n4", $$css: true as const }, error: { kWkggS: "x1pjz0fi", kMwMTN: "xrkvqaz", $$css: true as const }, icon: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kLWn49: "x14ju556", $$css: true as const } }; const dynamicStyles = { size: (dotSize: number, borderWidth: number) => [{ kzqmXN: dotSize != null ? "x5lhr3w" : dotSize, kZKoxP: dotSize != null ? "x16ye13r" : dotSize, kMzoRj: borderWidth != null ? "x1mw0n95" : borderWidth, $$css: true as const }, { "--x-width": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(dotSize), "--x-height": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(dotSize), "--x-borderWidth": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(borderWidth) }], iconSize: (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) }] }; const variantStyleMap: Partial> = { success: styles.success, neutral: styles.neutral, error: styles.error }; /** * Built-in shape glyph per variant, so each status differs by shape and not * only by colour (WCAG 2.1 SC 1.4.1). The glyph is a stroked inline SVG * painted in `currentColor`: * - `ring` — a stroked circle on a surface plate; the dot reads as hollow * (away/offline). * - `minus` — a round-capped bar across the filled dot; the dot reads as * "do not disturb" (busy). * * `success` stays the plain filled dot — filled, hollow, and barred are the * three distinct fill topologies. Custom augmented variants have no entry * and render no glyph; see the `AvatarStatusDotVariantMap` docs. */ type AvatarStatusDotGlyphShape = 'ring' | 'minus'; const glyphShapeMap: Partial> = { neutral: 'ring', error: 'minus' }; /** * Glyph stroke weight per tier, in px of the dot's inner field. * * Roughly `field / 12`, floored at 1px so the smallest tier stays visible. * That lands on the 1 / 1.5 / 2 ladder the rest of the system draws with — * `Icon`'s default set strokes at 1.5 in a 24 viewBox, as does the * `CheckboxInput` checkmark — rather than the 25% band a CSS box cutout * produced, which was several times heavier than any other glyph we ship. */ const GLYPH_STROKE_WIDTH: Record = { small: 1, medium: 1.5, large: 2 }; /** Fraction of the inner field the minus bar spans, cap to cap. */ const MINUS_BAR_SPAN = 0.75; /** * The built-in shape glyph, drawn as a stroked inline SVG in `currentColor`. * * Stroking (rather than a CSS box) is what buys sub-pixel control and round * line caps, so the mark stays intentional at every tier — including the * 10px dot, where a box cutout can only land on whole pixels. * * `viewBox` is one user unit per px of the inner field, so every value here * is literal px. */ function StatusDotGlyph({ shape, field, stroke }: { shape: AvatarStatusDotGlyphShape; field: number; stroke: number; }) { const center = field / 2; return ; } /** * A status indicator dot that automatically scales to match the parent * Avatar's size. * * Each variant pairs a colour with a distinct built-in shape (filled dot, * ring, minus bar) so status stays distinguishable without colour * perception (WCAG 2.1 SC 1.4.1). Themes can target the shape glyph via * the `astryx-avatar-status-dot-glyph` class and its `data-shape` * attribute. * * Must be used inside an Avatar's `status` prop so it can read * the avatar size from context. * * @example * ``` * } * /> * } />} * /> * ``` */ export function AvatarStatusDot({ ref, variant = 'success', label, icon, xstyle, className, style, ...props }: AvatarStatusDotProps) { const avatarSize = use(AvatarSizeContext); const statusLabelRef = use(AvatarStatusLabelContext); // Report the label through the avatar's ref from a callback ref rather than // an Effect: the ref runs in the commit phase, so the avatar's accessible // name is composed before paint, and a ref write costs no render. /* eslint-disable react-compiler/react-compiler -- the avatar shares its own ref through context so the dot can write into it; a context read looks immutable to the compiler */ const reportRef = useCallback(() => { const target = statusLabelRef?.current; if (target == null) { return; } target.label = label; target.update?.(); return () => { target.label = undefined; target.update?.(); }; }, [statusLabelRef, label]); /* eslint-enable react-compiler/react-compiler */ // Memoized so React only detaches and reattaches when the reported label // actually changes; an inline merge would withdraw and re-report on every // render of the avatar. const rootRef = useMemo(() => mergeRefs(ref, reportRef), [ref, reportRef]); const { dotSize, borderWidth, iconSize, tier } = resolveStatusDotSize(avatarSize); const showsIcon = isRenderable(icon) && iconSize > 0; // A rendered icon is itself a non-colour mark; overlaying both cutouts in // the dot's small inner field would make each illegible. const glyphShape = showsIcon ? undefined : glyphShapeMap[variant]; return
{showsIcon && } {glyphShape && }
; } AvatarStatusDot.displayName = 'AvatarStatusDot';