// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Button.tsx * @input Uses React, ButtonHTMLAttributes, ReactNode, i18n (useTranslator) * @output Exports Button component, ButtonProps, ButtonVariant types * @position Core implementation; consumed by index.ts, tested by Button.test.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Button/Button.doc.mjs (props table, features, implementation notes) * - /packages/core/src/Button/Button.test.tsx (tests for new/changed behavior) * - /packages/core/src/Button/index.ts (exports if types change) * - /apps/storybook/stories/Button.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/Button/ (showcase blocks) * * Last synced props: label, variant, size, isDisabled, isLoading, isInterruptible, clickAction, icon, isIconOnly, width, children, tooltip, endContent, href, as, target, rel */ import { useRef, useTransition, type ReactNode } from 'react'; import type { BaseProps } from "../BaseProps.js"; import type { Elevation, SizeValue } from "../utils/types.js"; import * as stylex from "../style-runtime.js"; import { useTooltip } from "../Tooltip/useTooltip.js"; import "../theme/tokens.stylex.js"; import { colorVars, sizeVars, spacingVars, radiusVars, borderVars, durationVars, easeVars, fontWeightVars, typeScaleVars, shadowVars, focusVars } from "../theme/tokens.stylex.js"; import { Spinner } from "../Spinner/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { EDGE_COMP_ATTR } from "../Layout/edgeCompensation.stylex.js"; import { useSize } from "../SizeContext/SizeContext.js"; import { useButtonGroup } from "../ButtonGroup/ButtonGroupContext.js"; import { mergeProps } from "../utils/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import type { LinkComponentType } from "../Link/types.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import { useTranslator } from "../i18n/index.js"; import type { ButtonVariantMap } from "./index.js"; /** * Base button styles * Pseudo-classes are nested within properties per StyleX recommendation: * https://stylexjs.com/docs/learn/styling-ui/defining-styles#pseudo-classes */ const styles = { base: { "--button-focus-offset": "x16bblx1", kInvED: "x1wfwxd8 x13aywxo", kVAEAm: "x1n2onr6", k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kOIVth: "x1txdalj", k8WAf4: "xce4md1", kg3NbH: "xrrkdod", kMzoRj: "xc342km", ksu8eU: "xng3xce", kaIpWk: "x1jxw6zd", kMv6JI: "xjb2p0i", kGuDYH: "xcr08ib", kLWn49: "x1kq96og", k63SB2: "x1e4wzip", khDVqt: "xuxw1ft", kkrTdU: "x1ypdohk x16khyan", k1ekBW: "xrafxwg", kIyJzY: "xuedmi6 x12w9bfk", kAMwcw: "xlr8y92", $$css: true as const }, pressable: { k3aq6I: "x3oybdh x8ppqj", $$css: true as const }, inactive: { kkrTdU: "xt0e3qv", kKwaWg: "x18o3ruo", k3aq6I: "x1c071of x1pdlv7q", $$css: true as const }, disabled: { kSiTet: "xbyyjgo", $$css: true as const }, ariaDisabled: { kKwaWg: "x18o3ruo xuqm82a", $$css: true as const }, iconOnly: { "--button-icon-only-aspect": "x1v15ycx", kOBAk4: "xioom0i", kg3NbH: "xnjsko4", k8WAf4: "xt970qd", $$css: true as const }, iconWrapper: { k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmuXW: "x2lah0s", $$css: true as const }, contentWrapper: { k1xSpc: "xjp7ctv", $$css: true as const }, link: { kybGjl: "x1hl2dhg", $$css: true as const } }; // Dynamic style for the consumer-controlled button width. Numbers are treated // as pixels by StyleX; strings (e.g. '100%') are used as-is. const dynamicStyles = { width: (width: SizeValue | null) => [{ kzqmXN: width != null ? "x5lhr3w" : width, $$css: true as const }, { "--x-width": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(width) }] }; const sizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; /** * Icon size per button size. * Matches Icon sizing: sm/md=16px, lg=20px. * fontSize is set so emoji and text-based icons scale correctly. */ const iconSizeStyles = { sm: { kzqmXN: "x1kky2od", kZKoxP: "xlup9mm", kGuDYH: "x1j61zf2", $$css: true as const }, md: { kzqmXN: "x1kky2od", kZKoxP: "xlup9mm", kGuDYH: "x1j61zf2", $$css: true as const }, lg: { kzqmXN: "xw4jnvo", kZKoxP: "x1qx5ct2", kGuDYH: "xwsyq91", $$css: true as const } }; /** * Resting elevation for floating buttons (e.g. a FAB). `none` is the default * flat button; `low`/`med`/`high` map to the shadow token scale. 'none' stays * a literal so it never conflicts with a variant's background layering. */ const elevationStyles = { none: { kGVxlE: "x1gnnqk1", $$css: true as const }, low: { kGVxlE: "x1i5ehqx", $$css: true as const }, med: { kGVxlE: "x14hfi27", $$css: true as const }, high: { kGVxlE: "x1kcpxr7", $$css: true as const } }; /** * Variant styles using backgroundImage for layered colors * Pseudo-classes are nested within properties per StyleX recommendation * Overlay is stacked on top of base color using multiple linear-gradients * Focus outline is shared across variants for consistent keyboard affordance. */ const variants = { primary: { kWkggS: "x1ewilqj", kMwMTN: "x17wrial", $$css: true as const }, secondary: { kWkggS: "x17x4s8c", kMwMTN: "x1tgivj0", $$css: true as const }, ghost: { kWkggS: "xjbqb8w", kMwMTN: "x1tgivj0", $$css: true as const }, destructive: { kWkggS: "x1pjz0fi", kMwMTN: "x1m024r3", kjBf7l: "x1e0x2sz", $$css: true as const } }; /** * Button variant type derived from ButtonVariantMap. * Extensible via module augmentation of ButtonVariantMap. */ export type ButtonVariant = keyof ButtonVariantMap; /** * Button size type derived from the sizeStyles StyleX object */ export type ButtonSize = keyof typeof sizeStyles; export interface ButtonProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** HTML button type attribute. @default 'button' */ type?: 'button' | 'submit' | 'reset'; /** HTML name attribute for form submission. */ name?: string; /** HTML value attribute for form submission. */ value?: string | number | ReadonlyArray; /** Associates the button with a form element by ID. */ form?: string; /** * Accessible label for the button (required for accessibility). * Rendered as visible text by default. When `isIconOnly` is true, * used as aria-label instead. */ label: string; /** * The visual style variant of the button. * @default 'secondary' */ variant?: ButtonVariant; /** * The size of the button. * @default 'md' */ size?: ButtonSize; /** * Resting elevation — the shadow depth the button sits at. Use for floating * buttons (FABs) that hover above content. `none` is the default flat button. * @default 'none' */ elevation?: Elevation; /** * Whether the button is disabled. * @default false */ isDisabled?: boolean; /** * Whether the button is in a loading state. Loading prevents interaction * without dimming the spinner; explicit disabled states remain dimmed. * @default false */ isLoading?: boolean; /** * Keep the button interactive while a `clickAction` is pending. The loading * state still renders the spinner and `aria-busy`, but the button is not * disabled and the in-flight action is not deduped — so a re-click lands and * interrupts the previous action with a fresh one. Use for interruptible * actions (e.g. a toggle whose action can be re-triggered before the previous * one settles), not fire-once actions (submit/save/pay). * @default false */ isInterruptible?: boolean; /** * Click handler. For async actions that should show a loading state, * use `clickAction` instead. */ onClick?: React.MouseEventHandler; /** * Async click action. Shows loading state while pending. */ clickAction?: (e: React.MouseEvent) => void | Promise; /** * Icon element rendered before the label text. */ icon?: ReactNode; /** * When true, renders as a square icon-only button with `label` as aria-label. * Requires `icon` to be provided. * @default false */ isIconOnly?: boolean; /** * Width of the button. Numbers are treated as pixels, strings are used as-is * (e.g. `'100%'` for a full-width button). By default the button sizes to * its content. */ width?: SizeValue; /** * Optional visible content. When provided, rendered instead of `label` as the * visible text (label still serves as the accessible name via aria-label). */ children?: ReactNode; /** * Content rendered after the label text (badge, icon, chevron, etc.). * Ignored when `isIconOnly` is true to preserve square aspect ratio. * * Wrapped in a container that inherits the button's text color, * so child elements match the button variant's color automatically. */ endContent?: ReactNode; /** * Tooltip text shown on hover. */ tooltip?: string; /** * When provided, renders the button as a link (`` or custom component). * When the button is disabled, still renders as `