// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file ToggleButton.tsx * @input Uses Button, React, StyleX, theme tokens * @output Exports ToggleButton component and types * @position Thin wrapper over Button; adds controlled toggle pattern * * ToggleButton wraps Button with `isPressed` and adds: * - `onPressedChange` controlled toggle callback * - `pressedIcon` for outline-to-filled icon swap * - Font weight shift on press with width reservation to prevent layout shift * - Group integration via ToggleButtonGroupContext * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/ToggleButton/index.ts (exports if types change) * - /apps/storybook/stories/ToggleButton.stories.tsx * - /packages/cli/assets/templates/blocks/components/ToggleButton/ (showcase blocks) */ import React, { useOptimistic, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, fontWeightVars } from "../theme/tokens.stylex.js"; import { Button, type ButtonSize } from "../Button/index.js"; import type { Elevation } from "../utils/types.js"; import { useToggleButtonGroup } from "./ToggleButtonGroup.js"; import type { BaseProps } from "../BaseProps.js"; import { themeProps } from "../utils/themeProps.js"; // ============================================================================= // Styles // ============================================================================= /** * Font weight shift on press with width reservation trick. * A hidden span renders the same text at semibold weight to reserve * the wider width, preventing layout shift when toggling. */ const pressedStyles = { background: { kAXs8y: "x1kzknox", kWkggS: "xi89dp7 x1jzqe4", kMwMTN: "x1k5gbb1", $$css: true as const } }; // ============================================================================= // Props // ============================================================================= export interface ToggleButtonProps extends BaseProps { ref?: React.Ref; /** * Accessible label for the button (required). * Used as visible text, or as aria-label for icon-only buttons. */ label: string; /** * Whether the button is currently pressed/active. * When used inside ToggleButtonGroup, this is controlled by the group * and this prop is ignored. */ isPressed?: boolean; /** * Called when the pressed state should change. Receives the next pressed * state and the originating click event. Call `event.preventDefault()` to * opt out of running `pressedChangeAction` (e.g. to handle the toggle * entirely in this callback). * When used inside ToggleButtonGroup, this is handled by the group * and this prop is ignored. */ onPressedChange?: (isPressed: boolean, event: React.MouseEvent) => void; /** * Action handler for API- or navigation-backed toggles, run inside a * transition by Button. The button shows a loading spinner while the action * is pending — whether it returns a promise or synchronously triggers a * suspending update (e.g. a router navigation that suspends on data). * * @example * ``` * { * await api.setFavorite(itemId, newState); * }} * /> * ``` */ pressedChangeAction?: (isPressed: boolean) => void | Promise; /** * The size of the toggle button. * When used inside ToggleButtonGroup, defaults to the group's size. * @default 'md' */ size?: ButtonSize; /** * Resting elevation — the shadow depth the button sits at, mirroring * Button's `elevation` for floating (FAB-style) toggle buttons. * `none` is the default flat button. Applies inside a * ToggleButtonGroup as well — grouped children retain their own * elevation. * @default 'none' */ elevation?: Elevation; /** * Whether the button is disabled. * When used inside ToggleButtonGroup, a disabled group disables this button * too, but an enabled group does not re-enable a button that disables itself. * @default false */ isDisabled?: boolean; /** * Whether the button is in a loading state. * @default false */ isLoading?: boolean; /** * Icon element rendered before the label text. */ icon?: ReactNode; /** * When true, renders as a square icon-only button with `label` as aria-label * and an automatic tooltip from the label. * @default false */ isIconOnly?: boolean; /** * Icon element to render when the button is pressed. * Use to swap between outline (unpressed) and filled (pressed) icon styles. * Falls back to `icon` if not provided. * * To color the pressed icon, pass an already-colored element: * @example * ``` * pressedIcon={} * ``` */ pressedIcon?: ReactNode; /** * Optional visible content. When provided, rendered instead of `label` * as the visible text. */ children?: ReactNode; /** * Tooltip text shown on hover. * Passed through to Button. */ tooltip?: string; /** * Value identifier when used inside ToggleButtonGroup. * Required when used in a group. */ value?: string; } // ============================================================================= // Component // ============================================================================= /** * A button that toggles between pressed and unpressed states. * Thin wrapper over Button — adds controlled toggle pattern, * icon swap, and font weight emphasis. * * Use for toolbar actions, view mode switches, and formatting controls. * For on/off settings, use Switch instead. * * Works standalone (with `isPressed`/`onPressedChange`) or inside * ToggleButtonGroup (which controls selection via `value`). * * @example * ``` * const [isBold, setIsBold] = useState(false); * } * isPressed={isBold} * onPressedChange={setIsBold} * /> * ``` */ export function ToggleButton({ ref, label, isPressed: isPressedProp, onPressedChange: onPressedChangeProp, pressedChangeAction, size: sizeProp, elevation = 'none', isDisabled: isDisabledProp = false, isLoading = false, icon, isIconOnly = false, pressedIcon, children, tooltip, value, xstyle, className: _className, style, ...props }: ToggleButtonProps): ReactNode { const group = useToggleButtonGroup(); const committedPressed = group && value != null ? group.selectedValues.has(value) : isPressedProp ?? false; const size = sizeProp ?? group?.size ?? 'md'; // Either source disabling this button is enough. `??` could not express that: // the group always supplies a boolean (its own prop defaults to false), so the // fallback never ran and an enabled group handed a member that had disabled // itself its availability back. A group still disables everything it contains // — that is the half `??` got right — but it cannot re-enable a member // (family:buttons FR3). const isDisabled = (group?.isDisabled ?? false) || isDisabledProp; // Track the pressed state optimistically so the button reflects the intended // state immediately while an async action is pending. The optimistic update // is applied inside Button's clickAction transition (below), satisfying // React's "useOptimistic must run in a transition" rule. const [optimisticPressed, setOptimisticPressed] = useOptimistic(committedPressed); const isPressed = optimisticPressed; // Next state derives from the *optimistic* value, not the committed one, so a // re-click while an action is pending reads the in-flight intent and toggles // (true -> false -> true) rather than recomputing from the stale committed // state. handleClick and clickAction share this so the callback and the // optimistic update always agree within a single click. const nextPressed = !isPressed; const resolvedIcon = isPressed && pressedIcon ? pressedIcon : icon; // Synchronous part of the toggle. Button calls onClick before clickAction and // skips clickAction when the event is defaultPrevented, so calling // preventDefault() inside onPressedChange opts out of pressedChangeAction. const handleClick = (event: React.MouseEvent) => { if (isDisabled) { return; } if (group && value != null) { // Group mode delegates selection to the group; no async-action path. group.toggle(value); event.preventDefault(); return; } onPressedChangeProp?.(nextPressed, event); }; // Async part of the toggle, run inside Button's transition (so the optimistic // update is valid and Button's isPending drives the pending spinner). Skipped // automatically by Button when handleClick called preventDefault, and never // wired in group mode (where there is no async action). Button is rendered // interruptible, so it stays clickable while pending and a re-click starts a // fresh transition that interrupts the previous one. const clickAction = group && value != null ? undefined : async () => { setOptimisticPressed(nextPressed); await pressedChangeAction?.(nextPressed); }; // isIconOnly prop is the source of truth for icon-only rendering. const labelContent = children != null ? {children} : !isIconOnly ? {label} : undefined; return ; } ToggleButton.displayName = 'ToggleButton';