// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file CheckboxInput.tsx * @input Uses React, useId, ChangeEvent, FieldLabel, FieldStatus, IconType, InputStatus, useTooltip * @output Exports CheckboxInput component, CheckboxInputProps * @position Core implementation; consumed by index.ts, tested by CheckboxInput.test.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/CheckboxInput/CheckboxInput.doc.mjs (props table, features, implementation notes) * - /packages/core/src/CheckboxInput/CheckboxInput.test.tsx (tests for new/changed behavior) * - /packages/core/src/CheckboxInput/index.ts (exports if types change) * - /apps/storybook/stories/CheckboxInput.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/CheckboxInput/ (showcase blocks) */ import { useId, useCallback, use, useOptimistic, useRef, 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, typographyVars, typeScaleVars, fontWeightVars } from "../theme/tokens.stylex.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.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, rtlStyles } from "../utils/index.js"; import "../Indicator/indicator.markers.stylex.js"; import { indicatorScope } from "../Indicator/indicator.markers.stylex.js"; import { useIndicatorFocusRing } from "../hooks/useIndicatorFocusRing.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import { useIndicator } from "../Indicator/index.js"; import { themeProps } from "../utils/themeProps.js"; import { CheckboxListContext } from "../CheckboxList/CheckboxListContext.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; const styles = { container: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", $$css: true as const }, containerLabelHidden: { kOIVth: "xxhr3t", $$css: true as const }, checkboxWrapper: { kVAEAm: "x1n2onr6", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmuXW: "x2lah0s", kHBbk8: "xc8icb0", $$css: true as const }, indicatorPressOverlay: { k5JduY: "x1s928wv", kwXMNM: "x1j6awrg", kv0HGH: "xarstr8", kUfP38: "xtb6wbx", kloYau: "x2q1x1w", ks3ayO: "xyhc2n1 xzywxsk", $$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 } }; const wrapperSizeStyles = { sm: { kzqmXN: "xw4jnvo", kZKoxP: "x1qx5ct2", $$css: true as const }, md: { kzqmXN: "xvy4d1p", kZKoxP: "xxk0z11", $$css: true as const } }; export type CheckboxInputSize = keyof typeof wrapperSizeStyles; export interface CheckboxInputProps extends Omit { /** Ref forwarded to the underlying `` element */ ref?: React.Ref; /** * Label text for the checkbox (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 checkbox 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 checkbox is in a loading state. * @default false */ isLoading?: boolean; /** * Whether the checkbox is checked, unchecked, or indeterminate. */ value: boolean | 'indeterminate'; /** * Whether the checkbox is disabled. * @default false */ isDisabled?: boolean; /** * The HTML name attribute for the underlying checkbox input. * Useful for form submissions. */ htmlName?: string; /** * Explains why the checkbox is disabled. When set together with * `isDisabled`, the checkbox 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 checkbox in `Tooltip` — disabled * controls don't emit the pointer events an external tooltip needs. * * @example * ``` * * ``` */ disabledMessage?: string; /** * Whether the checkbox is read-only. * Displays the current state at full opacity but prevents interaction. * Unlike `isDisabled`, read-only checkboxes are not visually dimmed. * @default false */ isReadOnly?: boolean; /** * Whether the field is optional. Mutually exclusive with isRequired. * @default false */ isOptional?: boolean; /** * Whether the checkbox is required. Mutually exclusive with isOptional. * @default false */ isRequired?: boolean; /** * 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; /** * The size of the checkbox. * - 'sm': Compact size (28px row height) * - 'md': Default size (36px row height) * @default 'md' */ size?: CheckboxInputSize; /** * Callback fired when the checkbox receives focus. */ onFocus?: (e: FocusEvent) => void; /** * Callback fired when the checkbox loses focus. */ onBlur?: (e: FocusEvent) => void; /** * Icon to display before the label text. */ labelIcon?: ReactNode | IconType; /** * Status indicator for the checkbox. * When set with a message, displays a colored message box below the checkbox. */ status?: InputStatus; } // 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 checkbox input component for toggling boolean values. * * @example * ``` * * * ``` */ export function CheckboxInput({ label, isLabelHidden = false, description, onChange, changeAction, isLoading = false, value, isDisabled = false, htmlName, disabledMessage, isReadOnly = false, isOptional = false, isRequired = false, size = 'md', onFocus, onBlur, labelIcon, status, width, xstyle, className, style, ref, 'aria-describedby': ariaDescribedByProp, ...rest }: CheckboxInputProps) { 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; // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the checkbox row (which already exists) and the // native checkbox stays perceivable via aria-disabled instead of the disabled // attribute. Value mutation is blocked by the isDisabled guard in onChange. const showsDisabledMessage = isDisabled && !!disabledMessage; // Keep the native checkbox focusable via aria-disabled either when it renders // its own reason tooltip, or when it sits in a CheckboxList whose whole-group // `disabledMessage` (shown on the group container) needs each checkbox to // stay keyboard-perceivable. The group signals this through context rather // than a public prop. const checkboxListContext = use(CheckboxListContext); const isFocusableDisabled = isDisabled && (showsDisabledMessage || (checkboxListContext?.hasDisabledMessage ?? false)); const disabledMessageTooltip = useTooltip({ placement: 'above', // The container row is not naturally focusable; focusin bubbles up from the // native checkbox, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); // The checkbox visual is an indicator: a theme can restyle it through the // `checkbox` target or replace the component outright. const CheckboxControl = useIndicator('checkbox'); // The ring is drawn on the indicator itself: the native input is // `opacity: 0`, and only the indicator's own element can shape the outline // to match it. See useIndicatorFocusRing. const indicatorRef = useRef(null); const { focusProps } = useIndicatorFocusRing(indicatorRef, isDisabled); const isIndeterminate = optimisticValue === 'indeterminate'; const isChecked = optimisticValue === true; // Sync the native indeterminate DOM property (can't be set via JSX // attribute). On a native checkbox this is the authoritative way to expose // the mixed state — a separate aria-checked="mixed" would be redundant and // can desync from / override the native state (forms-16), so it is omitted. const indeterminateRef = useCallback((el: HTMLInputElement | null) => { if (el) { el.indeterminate = isIndeterminate; } }, [isIndeterminate]); // 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. // A consumer's own `aria-describedby` (CheckboxListItem points the control // at its visible row description) comes first, then the input's own ids — // the explicit attribute below would otherwise replace it via `...rest`. const describedByParts: string[] = []; if (ariaDescribedByProp) { describedByParts.push(ariaDescribedByProp); } 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; return
{ // Interaction (hover/focus) listeners for the disabled-message // tooltip attach to the whole row for a larger trigger target; // positioning anchors on the checkbox itself (below) 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, // Hover and focus reach the checkbox visual through this ancestor // marker rather than props, so the whole row drives it. !isDisabled && indicatorScope)}>
{ if (isDisabled || isBusy || isReadOnly) { 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-readonly={isReadOnly || undefined} aria-describedby={ariaDescribedBy} aria-invalid={status?.type === 'error' ? true : undefined} aria-busy={isBusy || undefined} {...stylex.props(styles.input, rtlStyles.centerInline('-50%'), styles.inputCoarse, wrapperSizeStyles[size], isDisabled && styles.inputDisabled)} /> {/* * A container holding ONLY the indicator, so the focus ring has an * unambiguous target whatever a theme renders. `display: contents` * keeps it out of layout entirely. */} {isBusy ? : null}
{status?.message && } {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)}
; } CheckboxInput.displayName = 'CheckboxInput';