// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Switch.tsx * @input Uses React, useId, ChangeEvent, FieldLabel, FieldStatus, IconType, InputStatus, useTooltip * @output Exports Switch component, SwitchProps, SwitchLabelPosition, SwitchLabelSpacing * @position Core implementation; consumed by index.ts, tested by Switch.test.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Switch/Switch.doc.mjs (props table, features, implementation notes) * - /packages/core/src/Switch/Switch.test.tsx (tests for new/changed behavior) * - /packages/core/src/Switch/index.ts (exports if types change) * - /apps/storybook/stories/Switch.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/Switch/ (showcase blocks) */ import { useId, useOptimistic, useTransition, type ChangeEvent, type FocusEvent, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars, durationVars, easeVars, typographyVars, typeScaleVars, focusVars } from "../theme/tokens.stylex.js"; import { FieldLabel } from "../Field/FieldLabel.js"; import { FieldStatus } from "../FieldStatus/FieldStatus.js"; import type { IconType } from "../Icon/index.js"; import type { InputStatus } from "../Field/types.js"; import { Spinner } from "../Spinner/index.js"; import { useTooltip } from "../Tooltip/index.js"; import { mergeProps, mergeRefs, rtlStyles } from "../utils/index.js"; import "./switch.markers.stylex.js"; import { switchScope } from "./switch.markers.stylex.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.js"; import { themeProps } from "../utils/themeProps.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; const wrapperSizeStyles = { sm: { kzqmXN: "x1td3qas", kZKoxP: "x1qx5ct2", $$css: true as const }, md: { kzqmXN: "x100vrsf", kZKoxP: "xxk0z11", $$css: true as const } }; const inputSizeStyles = { sm: { kzqmXN: "x1td3qas", kZKoxP: "x1qx5ct2", $$css: true as const }, md: { kzqmXN: "x100vrsf", kZKoxP: "xxk0z11", $$css: true as const } }; const trackSizeStyles = { sm: { kzqmXN: "x1td3qas", kZKoxP: "x1qx5ct2", kmVPX3: "x1i3ajwb", $$css: true as const }, md: { kzqmXN: "x100vrsf", kZKoxP: "xxk0z11", kmVPX3: "xfawy5m", $$css: true as const } }; const thumbOffSizeStyles = { sm: { kzqmXN: "x6jxa94", kZKoxP: "x1v9usgg", k3aq6I: "xbryuvx", $$css: true as const }, md: { kzqmXN: "x1kky2od", kZKoxP: "xlup9mm", k3aq6I: "xbryuvx", $$css: true as const } }; const thumbOnSizeStyles = { sm: { kzqmXN: "x1kky2od", kZKoxP: "xlup9mm", k3aq6I: "xv5t66b xi9aj42", $$css: true as const }, md: { kzqmXN: "xw4jnvo", kZKoxP: "x1qx5ct2", k3aq6I: "x15481yp x12xz5k6", $$css: true as const } }; // The pressed overlay, painted as a gradient layer OVER the track's and // thumb's own fills so it composes with the on/off colors and the hover tint // instead of replacing them. Same token Button's overlay paints with. The // switch is a two-part control whose focusable input sits beside the track, // so the press is read off the shared scope marker (the row), the way the // hover tint already is: pressing the input, the track or the label all // activate the row. const pressedImage = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`; const labelWrapperSizeStyles = { sm: { kAzted: "xisnujt", $$css: true as const }, md: { kAzted: "xjwf9q1", $$css: true as const } }; const styles = { container: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", $$css: true as const }, containerLabelHidden: { kOIVth: "xxhr3t", $$css: true as const }, containerSpread: { kjj79g: "x1qughib", kzqmXN: "xh8yej3", $$css: true as const }, statusGap: { keoZOQ: "xtbrsbv", $$css: true as const }, switchWrapper: { kVAEAm: "x1n2onr6", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kmuXW: "x2lah0s", kHBbk8: "xc8icb0", $$css: true as const }, input: { kVAEAm: "x10l6tqk", k87sOh: "xwa60dl", kogj98: "x1ghz6dp", kmVPX3: "x1717udv", kSiTet: "xg01cxk", kkrTdU: "x1ypdohk x16khyan", kY2c9j: "x1vjfegm", $$css: true as const }, inputCoarse: { kjFi6P: "xkagaj0", kHzOjL: "x80b3aj", $$css: true as const }, inputDisabled: { kkrTdU: "xt0e3qv", $$css: true as const }, inputBusy: { kfzvcC: "x47corl", $$css: true as const }, track: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kaIpWk: "xjspbzw", k1ekBW: "x15406qy", kIyJzY: "xuedmi6 x12w9bfk", kAMwcw: "xlr8y92", kB7OPa: "x9f619", kMzoRj: "xc342km x13rsnn2", ksu8eU: "xng3xce xx61lck", kVAM5u: "x1w1tqly", kKwaWg: "x1xm682w", $$css: true as const }, trackFocus: { kI3sdo: "x1a2a7pz xafswnp", kInvED: "xwz5zln", $$css: true as const }, trackOff: { kWkggS: "xspzpui x1a8ae7b x9yvj25", $$css: true as const }, trackOn: { kWkggS: "x1ewilqj xcgwt1n x1jzqe4", $$css: true as const }, trackDisabled: { kSiTet: "xbyyjgo", kVAM5u: "xmnqt3w", $$css: true as const }, trackDisabledOff: { kWkggS: "xspzpui", $$css: true as const }, thumb: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kaIpWk: "xjspbzw", k1ekBW: "xh3q6i0", kIyJzY: "xuedmi6 x12w9bfk", kAMwcw: "xlr8y92", kKwaWg: "x1xm682w", $$css: true as const }, thumbOff: { kWkggS: "x10xzikg xwvh9j7", $$css: true as const }, thumbOn: { kWkggS: "x10xzikg x1sir5s4", $$css: true as const }, labelWrapper: { k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kjj79g: "xl56j7k", $$css: true as const } }; export type SwitchLabelPosition = 'start' | 'end'; export type SwitchLabelSpacing = 'hug' | 'spread'; export interface SwitchProps extends Omit { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Label text for the switch (always rendered for accessibility). */ label: string; /** * Whether to visually hide the label (still accessible to screen readers). * @default false */ isLabelHidden?: boolean; /** * Description text displayed below the label. */ description?: string; /** * Callback fired when the switch state changes. */ onChange?: (checked: boolean, e: ChangeEvent) => void; /** * Async action on change. Fires after onChange if not prevented. */ changeAction?: (checked: boolean, e: ChangeEvent) => void | Promise; /** * Whether the switch is in a loading state. * @default false */ isLoading?: boolean; /** * Whether the switch is on or off. */ value: boolean; /** * Whether the switch is disabled. * @default false */ isDisabled?: boolean; /** * The HTML name attribute for the underlying checkbox input. * Useful for form submissions. */ htmlName?: string; /** * Explains why the switch is disabled. When set together with `isDisabled`, * the switch shows a tooltip with this text on hover and keyboard focus, and * the control stays focusable (via `aria-disabled`) so the reason is * discoverable by keyboard and assistive technology. Activation stays * blocked. * * Use this instead of wrapping a disabled switch in `Tooltip` — disabled * controls don't emit the pointer events an external tooltip needs. * * @example * ``` * * ``` */ disabledMessage?: string; /** * Whether the field is optional. Mutually exclusive with isRequired. * @default false */ isOptional?: boolean; /** * Whether the switch is required. Mutually exclusive with isOptional. * @default false */ isRequired?: boolean; /** * Callback fired when the switch receives focus. */ onFocus?: (e: FocusEvent) => void; /** * Callback fired when the switch loses focus. */ onBlur?: (e: FocusEvent) => void; /** * Icon to display before the label text. */ labelIcon?: ReactNode | IconType; /** * Width of the field. Numbers are treated as pixels, strings are used as-is * (e.g. `'100%'`). Sizes the whole field (label, control, and status) so they * stay aligned, unlike setting width via `xstyle`/`className`/`style`. */ width?: SizeValue; /** * Tooltip text to display in an info icon at the end of the label. */ labelTooltip?: string; /** * Which side of the switch the label appears on. * - 'start': Label appears before the switch * - 'end': Label appears after the switch * @default 'end' */ labelPosition?: SwitchLabelPosition; /** * Spacing behavior between label and switch. * - 'hug': Label and switch are positioned next to each other * - 'spread': Label and switch are pushed to opposite ends * @default 'hug' */ labelSpacing?: SwitchLabelSpacing; /** * Status indicator for the switch. * When set with a message, displays a colored message box below the switch. */ status?: InputStatus; /** * Size variant controlling track and thumb dimensions. * - 'sm': 34x20px (matches sm checkbox/radio vertical rhythm) * - 'md': 40x24px (default, matches md checkbox/radio vertical rhythm) * @default 'md' */ size?: 'sm' | 'md'; } // Dynamic field width (number -> px, string used as-is). const dynamicWidthStyles = { 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) }] }; /** * A toggle switch component for boolean values. * * @example * ``` * * * ``` */ export function Switch({ label, isLabelHidden = false, description, onChange, changeAction, isLoading = false, value, isDisabled = false, htmlName, disabledMessage, isOptional = false, isRequired = false, onFocus, onBlur, labelIcon, labelTooltip, labelPosition = 'end', labelSpacing = 'hug', status, size = 'md', width, xstyle, className, style, ref, ...rest }: SwitchProps) { const id = useId(); const descriptionID = useId(); const statusMessageID = useId(); // Announce the effective required state (form default included) while the // native `required` stays bound to the explicit `isRequired` so a layout // default never switches on browser validation. const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); const [, startTransition] = useTransition(); const [optimisticValue, setOptimisticValue] = useOptimistic(value); const isBusy = isLoading || optimisticValue !== value; const isOn = optimisticValue === true; // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the switch row (which already exists) and the // native checkbox stays perceivable via aria-disabled instead of the disabled // attribute. Toggling is blocked by the isDisabled guard in onChange. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', // The container row is not naturally focusable; focusin bubbles up from the // native input, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); // Build aria-describedby from description and status message // Only include descriptionID when the element actually renders. // FieldLabel renders the description (with descriptionID) even when the // label is visually hidden — it's sr-only, so keep it linked. const describedByParts: string[] = []; if (description) { describedByParts.push(descriptionID); } if (status?.message) { describedByParts.push(statusMessageID); } if (showsDisabledMessage) { describedByParts.push(disabledMessageTooltip.describedBy); } const ariaDescribedBy = describedByParts.length > 0 ? describedByParts.join(' ') : undefined; const switchElement =
{ if (isDisabled || isBusy) { return; } const checked = e.target.checked; onChange?.(checked, e); if (changeAction && !e.defaultPrevented) { startTransition(async () => { setOptimisticValue(checked); await changeAction(checked, e); }); } }} onFocus={onFocus} onBlur={onBlur} aria-describedby={ariaDescribedBy} aria-invalid={status?.type === 'error' ? true : undefined} aria-busy={isBusy || undefined} {...stylex.props(styles.input, rtlStyles.centerInline('-50%'), styles.inputCoarse, inputSizeStyles[size], isDisabled && styles.inputDisabled, isBusy && styles.inputBusy)} /> {isBusy && Loading}
; const labelElement =
; return
{ // Interaction (hover/focus) listeners for the disabled-message // tooltip attach to the whole row for a larger trigger target; // positioning anchors on the switch itself (above) so the tooltip // appears next to the control, not the far edge of the row. // Handlers are gated internally by isEnabled, so attaching // unconditionally is safe. disabledMessageTooltip.interactionRef(el); }} {...stylex.props(styles.container, isLabelHidden && styles.containerLabelHidden, labelSpacing === 'spread' && styles.containerSpread, !isDisabled && switchScope)}> {' '} {labelPosition === 'start' ? <> {labelElement} {switchElement} : <> {switchElement} {labelElement} }
{status?.message && } {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)}
; } Switch.displayName = 'Switch';