// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Collapsible.tsx * @input Uses React, StyleX, useCollapsible hook, CollapsibleGroupPresentationContext, getIcon, theme tokens * @output Exports Collapsible component and CollapsibleProps * @position Collapsible content primitive — trigger toggles visibility of children * * Collapsible is a standalone primitive that makes any content collapsible. * It renders a trigger area (always visible) and a content area that toggles. * Handles state management, accessibility (aria-expanded + aria-controls linking * the trigger to its content region), and chevron indicator. * * Works standalone or coordinated by CollapsibleGroup via the `value` prop. * When the surrounding CollapsibleGroup sets `hasDividers`, each Collapsible * draws its own row chrome (borderBlockStart suppressed on :first-child, plus * density padding) from CollapsibleGroupPresentationContext — StyleX has no * child selectors, so the group cannot draw it from outside. The presentation * context is reset around children so nested collapsibles stay chrome-free. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Collapsible/index.ts (exports) * - /packages/core/src/Collapsible/Collapsible.doc.mjs * - /apps/storybook/stories/Collapsible.stories.tsx * - /packages/cli/assets/templates/blocks/components/Collapsible/ (showcase blocks) */ import { use, useId, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { borderVars, colorVars, typographyVars, fontWeightVars, spacingVars, typeScaleVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import { useCollapsible } from "./useCollapsible.js"; import { CollapsibleGroupPresentationContext } from "./CollapsibleGroupContext.js"; import type { CollapsibleChevronPosition } from "./CollapsibleGroupContext.js"; import { Icon } from "../Icon/index.js"; import { mergeProps } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; const styles = { root: { kzqmXN: "xh8yej3", $$css: true as const }, trigger: { kB7OPa: "x9f619", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "x1qughib", kzqmXN: "xh8yej3", kkrTdU: "x1ypdohk x16khyan", kMv6JI: "x9ynric", kGuDYH: "x18juvz8", k63SB2: "x2mo6ok", kMwMTN: "x1tgivj0", k9WMMc: "x1yc453h", k8WAf4: "xt970qd", $$css: true as const }, triggerDisabled: { kkrTdU: "xt0e3qv", kSiTet: "xbyyjgo", $$css: true as const }, chevron: { k1xSpc: "x3nfvp2", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmuXW: "x2lah0s", kzqmXN: "xta46cu", kZKoxP: "x1seg80s", kGuDYH: "x18juvz8", k1ekBW: "x11xpdln", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, chevronOpen: { k3aq6I: "x19jd1h0", $$css: true as const }, chevronClosed: { k3aq6I: "x7p49u4", $$css: true as const }, chevronStart: { k71WvV: "x1pezmd8", $$css: true as const }, chevronStartOpen: { k3aq6I: "x1iffjtl x1nxqau", $$css: true as const }, chevronStartClosed: { k3aq6I: "x7p49u4 xaafvuh", $$css: true as const }, contentHidden: { k1xSpc: "x1s85apg", $$css: true as const }, content: { kLKAdn: "xfsso4q", kMv6JI: "x9ynric", kGuDYH: "xjm74w1", k63SB2: "xxovm9e", kLWn49: "xw6l6zx", kMwMTN: "x1tgivj0", $$css: true as const }, divided: { kEafiO: "x11xkdxz x1g31smg", kPef9Z: "x13fuv20", kLZC3w: "x1pc3f07", $$css: true as const } }; // Density padding for divided/padded accordion rows. paddingBlock mapping // follows Table's density scale (spacing-1/2/3); content only pads its end // so text doesn't sit on the divider below (block-start stays spacing-1). const densityStyles = { triggerCompact: { k8WAf4: "xu0wf1k", $$css: true as const }, triggerBalanced: { k8WAf4: "xce4md1", $$css: true as const }, triggerSpacious: { k8WAf4: "x8o8v82", $$css: true as const }, contentCompact: { kGO01o: "xy143xn", $$css: true as const }, contentBalanced: { kGO01o: "x1wesfrj", $$css: true as const }, contentSpacious: { kGO01o: "xvmdzux", $$css: true as const } }; const triggerDensity = { compact: densityStyles.triggerCompact, balanced: densityStyles.triggerBalanced, spacious: densityStyles.triggerSpacious } as const; const contentDensity = { compact: densityStyles.contentCompact, balanced: densityStyles.contentBalanced, spacious: densityStyles.contentSpacious } as const; export interface CollapsibleProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Content shown in the trigger area (always visible). * Rendered inside a button with aria-expanded and a chevron indicator. */ trigger: ReactNode; /** * Content that collapses/expands when the trigger is clicked. */ children?: ReactNode; /** * Default open state for uncontrolled usage. * @default true */ defaultIsOpen?: boolean; /** * Controlled open state. When provided, the component is fully controlled. */ isOpen?: boolean; /** * Whether the collapsible is disabled. A disabled item can't be toggled — * its trigger is non-interactive and dimmed. Following the system-wide * disabled convention, the trigger uses `aria-disabled` (not the native * `disabled` attribute) and drops out of the tab order, staying perceivable * to assistive tech. The content stays in whatever open state it was; * disabling doesn't collapse an already-open item. * @default false */ isDisabled?: boolean; /** * Callback when the open state changes. */ onOpenChange?: (isOpen: boolean) => void; /** * Logical position of the disclosure chevron. * * - `end` (default): a trailing indicator that points down when collapsed and * up when expanded. * - `start`: a leading disclosure arrow ahead of the label. It points inward * toward the content when collapsed, mirrors under RTL, and points down when * expanded. * * Inside a CollapsibleGroup this defaults to the group's `chevronPosition`. * * @default 'end' */ chevronPosition?: CollapsibleChevronPosition; /** * Unique identifier for this collapsible within an CollapsibleGroup. * Required when using inside a group for coordination. */ value?: string; /** * Test ID for the collapsible element. */ 'data-testid'?: string; } /** * A primitive that makes any content collapsible. * * Renders a trigger area (always visible) with a chevron indicator, * and a content area that toggles visibility on click. * Handles its own state by default, or defers to CollapsibleGroup * when a `value` prop is provided and a group is present. * * Use inside Card for elevated collapsible sections. * Wrap multiple instances in CollapsibleGroup for accordion behavior. * * @example * ``` * * Collapsible content * * * * * * * * * * * * * * * * * * * * * ``` */ export function Collapsible({ trigger, children, defaultIsOpen, isOpen: controlledIsOpen, isDisabled = false, onOpenChange, chevronPosition, value, ref, xstyle, className, style, ...props }: CollapsibleProps) { // Build the config for the hook const collapsibleConfig = controlledIsOpen !== undefined ? { isOpen: controlledIsOpen, onOpenChange } : { defaultIsOpen: defaultIsOpen ?? true, onOpenChange }; const { isOpen, toggle } = useCollapsible({ isCollapsible: collapsibleConfig, value }); // Activation is blocked by this guard rather than the native `disabled` // attribute, so the trigger keeps `aria-disabled` semantics and stays // discoverable. A native `disabled` button would silently swallow events // (e.g. a wrapping tooltip's hover) — the system-wide disabled convention. const handleToggle = () => { if (isDisabled) { return; } toggle(); }; const presentation = use(CollapsibleGroupPresentationContext); const isDivided = presentation?.hasDividers ?? false; const density = presentation?.density ?? null; // The item wins over the group so a single row can differ, but the group is // the level this is normally set at — mixed sides in one list read as a bug. const position = chevronPosition ?? presentation?.chevronPosition ?? 'end'; const isChevronAtStart = position === 'start'; // Links the trigger to the region it shows/hides so assistive tech can move // from the button to its controlled content (disclosure pattern). const contentId = useId(); const chevron = ; return
{presentation != null ? {children} : children}
; } Collapsible.displayName = 'Collapsible';