// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Icon.tsx * @input Uses ReactSVGProps, icon components or semantic icon names * @output Exports Icon component, IconProps, IconColor, IconSize, IconType types * @position Core implementation; consumed by index.ts, tested by Icon.test.tsx * * Supports two modes: * - Component mode: Pass an SVG icon component (e.g. from @heroicons/react) — rendered * directly with and spread SVG props. * - String mode: Pass a semantic name (e.g. 'close', 'chevronDown') — resolved from the * theme's icon registry (or built-in fallback SVGs) and wrapped in a styled span. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Icon/Icon.doc.mjs (props table, features, implementation notes) * - /packages/core/src/Icon/Icon.test.tsx (tests for new/changed behavior) * - /packages/core/src/Icon/index.ts (exports if types change) * - /apps/storybook/stories/Icon.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/Icon/ (showcase blocks) */ import React, { type ComponentType, type SVGProps } from 'react'; import * as stylex from "../style-runtime.js"; import type { StyleXStyles } from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars } from "../theme/tokens.stylex.js"; import { useThemeName } from "../theme/useTheme.js"; import { getIcon } from "./globalIconRegistry.js"; import type { IconName, NamespacedIconName } from "./globalIconRegistry.js"; import { mergeProps } from "../utils/index.js"; import { themeProps } from "../utils/themeProps.js"; // ============================================================================= // Styles // ============================================================================= const styles = { root: { kmuXW: "x2lah0s", $$css: true as const }, span: { k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmuXW: "x2lah0s", $$css: true as const } }; const colorStyles = { primary: { kMwMTN: "xtbr613", $$css: true as const }, secondary: { kMwMTN: "xv9yike", $$css: true as const }, tertiary: { kMwMTN: "xv9yike", $$css: true as const }, disabled: { kMwMTN: "xqa6c3m", $$css: true as const }, accent: { kMwMTN: "xqwr325", $$css: true as const }, success: { kMwMTN: "xtjic6", $$css: true as const }, error: { kMwMTN: "xjt36v0", $$css: true as const }, warning: { kMwMTN: "xs3pv69", $$css: true as const }, inherit: { kMwMTN: "x1heor9g", $$css: true as const }, blue: { kMwMTN: "x1fns2mt", $$css: true as const }, red: { kMwMTN: "xeffzf7", $$css: true as const }, green: { kMwMTN: "xmxeech", $$css: true as const }, gray: { kMwMTN: "x1eyinzz", $$css: true as const }, cyan: { kMwMTN: "x157w0xa", $$css: true as const }, teal: { kMwMTN: "x1f3zxcb", $$css: true as const }, yellow: { kMwMTN: "x1g6zdft", $$css: true as const }, orange: { kMwMTN: "xxu74a4", $$css: true as const }, pink: { kMwMTN: "x1kxxfg5", $$css: true as const }, purple: { kMwMTN: "xzdw94u", $$css: true as const } }; /** * Size styles for direct SVG icon components. * Uses width/height only — SVG components handle their own viewBox scaling. * * Sizes are expressed in `rem` (relative to the root font-size) so icons scale * in step with text when the document font-size changes, matching the rest of * the design system's rem-based type scale. Values are the px-equivalents at a * 16px root: 12px → 0.75rem, 16px → 1rem, 20px → 1.25rem, 24px → 1.5rem. */ const sizeStyles = { xsm: { kzqmXN: "x1jw3ynk", kZKoxP: "xvle69y", $$css: true as const }, sm: { kzqmXN: "xcdlrvm", kZKoxP: "x1l36t39", $$css: true as const }, md: { kzqmXN: "xwqq7k2", kZKoxP: "xmll18r", $$css: true as const }, lg: { kzqmXN: "xp8d6y2", kZKoxP: "xam5rvr", $$css: true as const } }; /** * Size styles for string-based (registry) icons. * Includes fontSize so that 1em-based icons from the registry scale correctly. * * Expressed in `rem` for the same reason as {@link sizeStyles} — icons track the * root font-size instead of being locked to absolute pixels. */ const spanSizeStyles = { xsm: { kzqmXN: "x1jw3ynk", kZKoxP: "xvle69y", kGuDYH: "xboafo0", $$css: true as const }, sm: { kzqmXN: "xcdlrvm", kZKoxP: "x1l36t39", kGuDYH: "x1jchvi3", $$css: true as const }, md: { kzqmXN: "xwqq7k2", kZKoxP: "xmll18r", kGuDYH: "x1603h9y", $$css: true as const }, lg: { kzqmXN: "xp8d6y2", kZKoxP: "xam5rvr", kGuDYH: "xngnso2", $$css: true as const } }; // ============================================================================= // Types // ============================================================================= export type IconColor = keyof typeof colorStyles; export type IconSize = keyof typeof sizeStyles; /** * Type for icon components that can be passed to Icon. * Use this type when accepting an icon prop in other components. */ export type IconType = ComponentType>; /** * Props for Icon component. * Extends SVGProps to allow passing additional SVG attributes (used when icon is a component). */ export interface IconProps extends Omit, 'ref' | 'color'> { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Icon to render. Can be: * - A semantic name string (e.g. 'close', 'chevronDown') — resolved from theme or built-in fallback * - A namespaced extension key (e.g. 'richtext:bold') for a glyph owned by * one component or library — resolved the same way, themeable by key * - An SVG icon component (e.g. from @heroicons/react) — rendered directly */ icon: IconType | IconName | NamespacedIconName; /** * The color variant of the icon. * @default 'inherit' */ color?: IconColor; /** * The size of the icon. * - 'xsm': 0.75rem (12px at a 16px root) * - 'sm': 1rem (16px at a 16px root) * - 'md': 1.25rem (20px at a 16px root) * - 'lg': 1.5rem (24px at a 16px root) * @default 'md' */ size?: IconSize; /** * Accessible name for the icon. Set this only when the icon is MEANINGFUL on * its own — a standalone status glyph or an icon-only indicator with no * adjacent text conveying the same information. Providing it exposes the icon * to assistive tech as `role="img"` with this string as the accessible name * (via `aria-label`) and drops the default `aria-hidden="true"`. * * Omit it (the default) for decorative icons — the common case, e.g. an icon * beside a text label — and the icon stays hidden from assistive tech * (`aria-hidden="true"`). An empty string (`''`) is treated the same as * omitting it (decorative), since an empty accessible name is meaningless. * * Don't set `label` when an interactive parent (Button, IconButton, link) * already names the control — that produces a duplicate announcement. * * Meaningful, standalone icon: give it a label. * * @example * ``` * * ``` * * Decorative icon (the default): omit label. * * @example * ``` * * ``` */ label?: string; /** * StyleX styles created via `stylex.create()`. Folded into the icon's own * `stylex.props()` call (as the last argument) so it merges with the base * color/size styles for optimal deduplication, matching how other Astryx * components accept `xstyle`. * * @example * ``` * const overrides = stylex.create({ root: { opacity: 0.5 } }); * * ``` */ xstyle?: StyleXStyles; } /** * Derives the ARIA attributes for an icon from its `label` prop. * * - Non-empty `label` → meaningful image: `role="img"` + `aria-label`, and no * `aria-hidden` (an `aria-hidden` element is removed from the accessibility * tree, so its accessible name would be ignored). * - Omitted or empty `label` → decorative default: `aria-hidden="true"`. * * The result is spread BEFORE `{...props}` in both render modes so an explicit * `aria-hidden` / `role` / `aria-label` from the consumer always wins. */ function getIconA11yProps(label: string | undefined): { role: 'img'; 'aria-label': string; } | { 'aria-hidden': 'true'; } { return label != null && label !== '' ? { role: 'img', 'aria-label': label } : { 'aria-hidden': 'true' }; } // ============================================================================= // Component // ============================================================================= /** * Renders an icon from the icon registry or a custom SVG component. * * @example * ``` * * ``` */ export function Icon({ icon, color = 'inherit', size = 'md', label, ref, className, style, xstyle, ...props }: IconProps) { // Derive ARIA from `label`: decorative (aria-hidden) by default, or a // meaningful image (role="img" + aria-label) when `label` is non-empty. const a11yProps = getIconA11yProps(label); // String mode: resolve from icon registry, wrap in styled span if (typeof icon === 'string') { return ; } // Component mode: render SVG component directly with ref forwarding const IconComponent = icon; return ; } Icon.displayName = 'Icon'; // ============================================================================= // Internal: Registry Icon Renderer // ============================================================================= /** * Internal component that resolves a semantic icon name from the registry * and renders it in a styled span with proper sizing. * * Extracted as a separate component so getIcon is only called * when the icon prop is a string. */ function IconFromRegistry({ name, color, size, a11yProps, className, style, xstyle, spanProps }: { name: IconName | NamespacedIconName; color: IconColor; size: IconSize; a11yProps: { role: 'img'; 'aria-label': string; } | { 'aria-hidden': 'true'; }; className?: string; style?: React.CSSProperties; xstyle?: StyleXStyles; spanProps?: Omit, 'ref' | 'color'>; }) { const themeName = useThemeName(); const resolvedIcon = getIcon(name, themeName); if (resolvedIcon == null) { return null; } // The styling props (className, style, xstyle) are handled here so they // COMPOSE with the internal astryx-icon + StyleX classes/styles instead of // being shadowed by the later spread: xstyle folds into stylex.props, and // className/style merge via mergeProps. Other span props keep their prior // precedence (spread before the internal merge). const restSpanProps = spanProps as React.HTMLAttributes ?? {}; return {resolvedIcon} ; } /** * Renders an icon slot value. Handles semantic names, ReactNode values, and * component types: * - If the value is a semantic icon name string, wraps it in Icon. * - If the value is a component (function or forwardRef object), wraps it in Icon. * - Otherwise, renders the ReactNode directly. */ export function renderIconSlot(icon: React.ReactNode | IconType, props?: { size?: IconSize; color?: IconColor; }): React.ReactNode { if (typeof icon === 'string') { return ; } if (typeof icon === 'function' || typeof icon === 'object' && icon !== null && 'render' in icon) { return ; } return icon; }