// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file TreeListItem.tsx * @input Uses React, StyleX, theme tokens, TreeListBranches * @output Exports TreeListItem component (internal, not publicly exported) * @position Internal implementation; consumed by TreeList.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/TreeList/TreeList.doc.mjs * - /packages/core/src/TreeList/TreeList.tsx * - /packages/cli/assets/templates/blocks/components/TreeList/ (showcase blocks) */ import { useId, useMemo, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, radiusVars, spacingVars, durationVars, easeVars, typeScaleVars } from "../theme/tokens.stylex.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import { Icon } from "../Icon/index.js"; import { mergeProps } from "../utils/index.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import { TreeListBranches } from "./TreeListBranches.js"; import type { TreeListDensity, TreeListVariant } from "./TreeListTypes.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; // ============================================================================= // Styles // ============================================================================= const styles = { wrapper: { kH6xsr: "x3ct3a4", kogj98: "x1ghz6dp", kmVPX3: "x1717udv", kVAEAm: "x1n2onr6", kzqmXN: "xh8yej3", kI3sdo: "x1a2a7pz", $$css: true as const }, contentWrapper: { kaIpWk: "xh6dtrn", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", kg3NbH: "xf314gf", kVQacm: "xb3r6kr", kVAEAm: "x1n2onr6", kB7OPa: "x9f619", k9WMMc: "x1yc453h", keTefX: "x12vn87o", $$css: true as const }, interactive: { kkrTdU: "x1ypdohk x16khyan", k1ekBW: "x12qzo2w", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, disabled: { kkrTdU: "xt0e3qv", kSiTet: "xbyyjgo", kfzvcC: "x47corl", $$css: true as const }, selected: { kWkggS: "xgcxg3y", $$css: true as const }, description: { kMwMTN: "xv1l7n4", $$css: true as const }, chevronSvg: { k1xSpc: "x78zum5", kzqmXN: "x12xnipv", kZKoxP: "x6b6gus", kGuDYH: "xho7bvi", k1ekBW: "x11xpdln", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, chevronExpanded: { k3aq6I: "x1iffjtl x1nxqau", $$css: true as const }, chevronCollapsed: { k3aq6I: "x7p49u4 xaafvuh", $$css: true as const } }; const densityStyles = { compact: { k8WAf4: "xu0wf1k", kGuDYH: "xjm74w1", kLWn49: "xw6l6zx", $$css: true as const }, balanced: { k8WAf4: "xce4md1", kGuDYH: "xjm74w1", kLWn49: "xw6l6zx", $$css: true as const }, spacious: { k8WAf4: "x8o8v82", kGuDYH: "xjm74w1", kLWn49: "xw6l6zx", $$css: true as const } }; const descriptionSizeStyles = { compact: { kGuDYH: "x141an7d", kLWn49: "x1ltkj2j", $$css: true as const }, balanced: { kGuDYH: "x141an7d", kLWn49: "x1ltkj2j", $$css: true as const }, spacious: { kGuDYH: "x141an7d", kLWn49: "x1ltkj2j", $$css: true as const } }; // `CSSProperties` has no index signature for custom properties, so the row's // inline style — which publishes the computed indent distance as the private // `--_tree-indent` — needs this augmentation to typecheck. type IndentStyle = React.CSSProperties & Record<'--_tree-indent', string>; // ============================================================================= // Types // ============================================================================= export interface TreeListItemInternalProps { id: string; label: React.ReactNode; description?: string; startContent?: React.ReactNode; endContent?: React.ReactNode; onClick?: (e: React.MouseEvent) => void; href?: string; target?: string; isDisabled?: boolean; isSelected?: boolean; hasChildren: boolean; /** * Whether the tree contains at least one expandable item anywhere (i.e. a * caret exists somewhere to align labels under). A leaf reserves the chevron * column — the extra offset that lines its label up past an expandable * ancestor/sibling's caret — whenever this is true, so it stays indented * beyond its parent's label. Only a fully flat tree (no expandable items at * all) has no caret to align under, so its rows sit flush (no chevron-column * offset). Computed once for the whole tree by TreeList. */ hasExpandableItems: boolean; nestedLevel: number; isLast: boolean; ancestorsIsLast: ReadonlyArray; isExpanded: boolean; onToggle?: (id: string) => void; density: TreeListDensity; /** * Guide-line visual treatment. `noGuides` suppresses the connector lines; * indentation is unaffected (it lives on the row's `marginLeft`, not the * guide element). */ variant: TreeListVariant; /** Pre-rendered children subtree (rendered by the parent recursion) */ renderedChildren?: ReactNode; /** 1-based position of this item among its siblings (aria-posinset). */ posInSet: number; /** Number of siblings at this level (aria-setsize). */ setSize: number; /** * Whether this treeitem is the initial roving-tabindex seed. Exactly one * treeitem is seeded tabbable at mount; useTreeFocus (hasRovingTabIndex) * then owns the tab stop dynamically. */ isTabbable: boolean; } // ============================================================================= // Component // ============================================================================= export function TreeListItem({ id, label, description, startContent, endContent, onClick, href, target, isDisabled = false, isSelected = false, hasChildren, hasExpandableItems, nestedLevel, isLast, ancestorsIsLast, isExpanded, onToggle, density, variant, renderedChildren, posInSet, setSize, isTabbable }: TreeListItemInternalProps) { const t = useTranslator(); const labelId = useId(); const descriptionId = useId(); const LinkComponent = useLinkComponent(); const isInteractive = onClick != null || href != null; const handleToggle = useMemo(() => hasChildren && onToggle != null ? (e: React.MouseEvent) => { e.stopPropagation(); onToggle(id); } : undefined, [hasChildren, onToggle, id]); const handleClick = useMemo(() => { if (onClick != null || hasChildren && onToggle != null) { return (e: React.MouseEvent) => { if (isDisabled) { return; } const el = e.target as HTMLElement; if (el.closest('button, a, input, select, textarea')) { return; } if (onClick != null) { onClick(e); } else if (hasChildren && onToggle != null) { onToggle(id); } }; } return undefined; }, [onClick, hasChildren, onToggle, id, isDisabled]); // Per-level indent distance. The per-level step is the public, themeable // `--tree-list-indent` lever (default `--spacing-4`, set on the tree-list // root). A leaf adds a fixed chevron-column offset (chevron width + gap) so // its label lines up past an expandable ancestor/sibling's caret — but ONLY // when the tree actually contains an expandable item somewhere. In a fully // flat tree there is no caret to align under, so the offset is pointless and // every row sits flush, like a parent at that level. That offset is tied to // the chevron's own dimensions, not the indent step, so it does not scale // with the lever. Published as the private `--_tree-indent` and consumed by // `contentWrapper`'s stylesheet `margin-inline-start` (kept out of the inline // style so the theme layer can override it — see #4308). const reservesChevronColumn = !hasChildren && hasExpandableItems; const indentDistance = reservesChevronColumn ? `calc(${nestedLevel} * var(--tree-list-indent) + ${spacingVars['--spacing-4']} + ${spacingVars['--spacing-2']})` : `calc(${nestedLevel} * var(--tree-list-indent))`; const indentStyle: IndentStyle = { '--_tree-indent': indentDistance }; const labelAndDescription = <> {label} {description != null && {description} } ; // renders the glyph's span itself — carrying the pre-existing // astryx-icon target — so the rotation rides on that same element instead of // an extra wrapper: a theme can still restyle the mark and its open/closed // transform through one selector. const chevronIcon = ; const chevron = hasChildren ? handleToggle != null ? // Real toggle button whenever expand/collapse is supported, so the row // can be expanded from the keyboard even when the item has no onClick/href // (row-level onClick is the only click path in that case, but there is no // focusable element to receive Enter/Space). The row's handleClick ignores // clicks originating inside a : // Non-interactive chevron only when toggling is not wired up at all {chevronIcon} : null; const innerContent = <> {chevron} {startContent != null && {startContent}} {href != null ? {labelAndDescription} : onClick != null ? : {labelAndDescription}} {endContent != null && {endContent}} ; return
  • {variant !== 'noGuides' &&
    }
    {innerContent}
    {isExpanded && renderedChildren != null &&
      {renderedChildren}
    }
  • ; } TreeListItem.displayName = 'TreeListItem';