// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file CheckboxList.tsx * @input Uses React, useId, useOptimistic, useTransition, Field, List, CheckboxListContext * @output Exports CheckboxList component, CheckboxListProps * @position Core implementation; consumed by index.ts, tested by CheckboxList.test.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/CheckboxList/CheckboxList.doc.mjs * - /packages/core/src/CheckboxList/CheckboxList.test.tsx * - /packages/core/src/CheckboxList/index.ts * - /apps/storybook/stories/CheckboxList.stories.tsx * - /packages/cli/assets/templates/blocks/components/CheckboxList/ (showcase blocks) */ import { useCallback, useId, useMemo, useOptimistic, useTransition, type ReactNode } from 'react'; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.js"; import { Field } from "../Field/Field.js"; import type { InputStatus } from "../Field/types.js"; import { List } from "../List/List.js"; import type { ListDensity } from "../List/ListContext.js"; import { useTooltip } from "../Tooltip/index.js"; import { mergeProps } from "../utils/index.js"; import { themeProps } from "../utils/themeProps.js"; import { CheckboxListContext, type CheckboxListContextValue } from "./CheckboxListContext.js"; const EMPTY_ARRAY: string[] = []; export interface CheckboxListProps extends Omit, 'onChange'> { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Label text for the checkbox group (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; /** * Status indicator for the checkbox group. * When set with a message, displays a colored message box below the group. */ status?: InputStatus; /** * The currently selected values (collection mode). */ value?: string[]; /** * Callback fired when the selected values change (collection mode). */ onChange?: (values: string[]) => void; /** * Async action on change. Fires after onChange. * While the returned promise is pending, the toggled item shows a spinner * inside its checkbox and is marked `aria-busy`, and re-toggling it is * blocked. Other items remain interactive. */ changeAction?: (values: string[]) => void | Promise; /** * Spacing density for list items. * @default 'balanced' */ density?: ListDensity; /** * Whether to show dividers between list items. * @default false */ hasDividers?: boolean; /** * Whether all checkbox items are disabled. * @default false */ isDisabled?: boolean; /** * Explains why the checkbox group is disabled. Applies to the whole-group * disabled state (`isDisabled`), not individual items. When set together with * `isDisabled`, the group shows a tooltip with this text on hover and keyboard * focus, and its checkboxes stay focusable (via `aria-disabled`) so the reason * is discoverable by keyboard and assistive technology. Toggling stays * blocked. * * Use this instead of wrapping a disabled group in `Tooltip` — disabled * controls don't emit the pointer events an external tooltip needs. */ disabledMessage?: string; /** * Whether all checkbox items are 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; /** * 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; /** * Checkbox list items to render. */ children: ReactNode; } /** * A checkbox group component for multi-value selection. * * Composes Field (for label, description, status) and List * (for density, dividers) with a context provider for collection mode. * * @example * ``` * * * * * * ``` */ export function CheckboxList({ label, isLabelHidden = false, description, status, value, onChange, changeAction, density = 'balanced', hasDividers = false, isDisabled = false, disabledMessage, isReadOnly = false, children, ref, width, xstyle, className, style, 'data-testid': dataTestId, ...rest }: CheckboxListProps) { const inputID = useId(); const labelID = useId(); const descriptionID = useId(); const statusMessageID = useId(); const [, startTransition] = useTransition(); const isCollectionMode = value !== undefined; const effectiveValue = value ?? EMPTY_ARRAY; const [optimisticValue, setOptimisticValue] = useOptimistic(effectiveValue); // Tracks which item has a pending `changeAction`. Auto-reverts to null when // the transition settles, so the spinner clears without manual cleanup. const [loadingValue, setLoadingValue] = useOptimistic(null); // Disabled-reason tooltip. Applies to the whole-group disabled state. Disabled // controls swallow pointer events, so the tooltip listeners attach to the // group container and the checkboxes stay perceivable via aria-disabled // instead of the disabled attribute. Toggling is blocked in the item. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', // The group container is not naturally focusable; focusin bubbles up from // the checkboxes, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); const handleChange = useCallback((newValues: string[], toggledValue?: string) => { onChange?.(newValues); if (changeAction) { startTransition(async () => { setOptimisticValue(newValues); if (toggledValue !== undefined) { setLoadingValue(toggledValue); } await changeAction(newValues); }); } }, [onChange, changeAction, startTransition, setOptimisticValue, setLoadingValue]); const contextValue = useMemo(() => ({ value: isCollectionMode ? optimisticValue : undefined, onChange: isCollectionMode ? handleChange : undefined, isDisabled, hasDisabledMessage: showsDisabledMessage, isReadOnly, loadingValue }), [isCollectionMode, optimisticValue, handleChange, isDisabled, showsDisabledMessage, isReadOnly, loadingValue]); return
{ // Anchor + hover/focus listeners for the disabled-message tooltip. // Handlers are gated internally by isEnabled, so attaching // unconditionally is safe. disabledMessageTooltip.ref(el); }} role="group" aria-labelledby={labelID} aria-describedby={[description ? descriptionID : null, status?.message ? statusMessageID : null, showsDisabledMessage ? disabledMessageTooltip.describedBy : null].filter(Boolean).join(' ') || undefined}> {children}
{showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)}
; } CheckboxList.displayName = 'CheckboxList';