// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file useTableTreeData.tsx * @input React, StyleX, Icon, Table types, theme tokens, i18n (useTranslator) * @output Exports useTableTreeData hook + config/meta types * @position Tree plugin; consumed by Table via plugins prop. * Pairs with useTableTreeState (owns expansion state + flattening). * * ## Architecture (mirrors useTableSelection) * * The tree affordance decorates the tree column's cells in place — no * synthetic column. `transformColumns` wraps the tree column's renderCell * with a flex wrapper carrying per-level indentation and an expander * button (or a fixed-width spacer on leaves). Other columns get zero * extra DOM. * * Expansion state flows through an external store (TreeStore) so each * row's expander subscribes independently — a toggle re-renders only the * affected cells, not the whole body. Row ARIA (aria-level, * aria-expanded) is applied imperatively via a ref callback on each * , exactly like selection's row styling; each subscription * self-cleans when the row disconnects. * * When `hasExpandableRows` is false (flat data), every transform is a * pass-through: adopting the plugin ahead of hierarchical data is a * no-op. */ import { createContext, use, useCallback, useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from 'react'; import * as stylex from "../../../style-runtime.js"; import "../../../theme/tokens.stylex.js"; import { colorVars, radiusVars, spacingVars } from "../../../theme/tokens.stylex.js"; import { Icon } from "../../../Icon/index.js"; import { mergeRefs } from "../../../utils/index.js"; import type { TablePlugin, TableColumn, BodyRowRenderProps, HeaderCellRenderProps } from "../../types.js"; import { useTranslator } from "../../../i18n/index.js"; // ============================================================================= // Types // ============================================================================= /** Structural position of one visible row within the tree. */ export interface TableTreeRowMeta { /** The row's id (from `idKey`). */ id: string; /** 0-based depth: roots are level 0. */ level: number; /** Whether the row shows an expander. */ hasChildren: boolean; /** Whether the row is currently expanded. */ isExpanded: boolean; } /** * Configuration for useTableTreeData. `useTableTreeState` returns a * ready-made value (`treeConfig`); consumers with server-driven or * pre-flattened trees can construct one directly. */ export interface UseTableTreeDataConfig> { /** Structural meta for a visible row; undefined for unknown rows. */ getRowMeta: (item: T) => TableTreeRowMeta | undefined; /** Toggle a row's expansion. */ onToggleItem: (item: T) => void; /** * Whether any row in the dataset is expandable. When false the plugin is * a no-op: no expanders, no indent, no tree ARIA — flat data renders * identically to a Table without the plugin. */ hasExpandableRows: boolean; /** * Aggregate expansion state across every expandable row. When provided * together with `onExpandAll`/`onCollapseAll`, the tree column header shows * an expand-all toggle. `useTableTreeState` supplies all three. */ isAllExpanded?: boolean | 'indeterminate'; /** Expand every expandable row. Wired to the header expand-all control. */ onExpandAll?: () => void; /** Collapse every row. Wired to the header expand-all control. */ onCollapseAll?: () => void; /** * Show the expand-all/collapse-all toggle in the tree column header. Needs * `isAllExpanded` and `onExpandAll`/`onCollapseAll` to be present. * @default false */ hasExpandAllControl?: boolean; /** * Indent step per level, as spacing tokens. * @default 'md' */ indent?: 'sm' | 'md' | 'lg'; /** Column that carries the indent + expander. @default the first column */ treeColumnKey?: string; /** * When true, clicking anywhere on an expandable row toggles its expansion, * in addition to the chevron. Leaf rows stay inert. No-op on flat data. * * This is a pointer-only convenience layered over the chevron: keyboard and * assistive-tech users toggle via the chevron button (which stays the * accessible control). Clicks originating from interactive cell content * (buttons, links, form controls) or a text selection do not toggle. * @default false (only the chevron toggles expansion). */ hasRowClickExpansion?: boolean; } // ============================================================================= // Tree Store (external store for fine-grained row subscriptions) // ============================================================================= interface TreeStore> { subscribe: (listener: () => void) => () => void; notify: () => void; getConfig: () => UseTableTreeDataConfig; } function createTreeStore>(configRef: React.RefObject>): TreeStore { const listeners = new Set<() => void>(); return { subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); }, notify() { for (const listener of listeners) { listener(); } }, getConfig() { return configRef.current; } }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const TreeStoreContext = createContext | null>(null); TreeStoreContext.displayName = 'TreeStoreContext'; /** Indent token -> index, for the numeric row snapshot. */ const INDENT_INDEX = { sm: 0, md: 1, lg: 2 } as const; /** * Encode a row's tree meta as a primitive for useSyncExternalStore — * object snapshots would tear. The indent token participates so a * runtime `indent` change re-renders the affected cells. * Encoding: level * 16 + indentIndex * 4 + hasChildren * 2 + isExpanded; * -1 = no meta. */ function encodeRowMeta>(config: UseTableTreeDataConfig, item: T): number { const meta = config.getRowMeta(item); if (!meta) { return -1; } return meta.level * 16 + INDENT_INDEX[config.indent ?? 'md'] * 4 + (meta.hasChildren ? 2 : 0) + (meta.isExpanded ? 1 : 0); } function useRowMetaSnapshot>(store: TreeStore, item: T): number { const getSnapshot = useCallback(() => encodeRowMeta(store.getConfig(), item), [store, item]); return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); } // ============================================================================= // Row ARIA (imperative, mirrors selection's row styling) // ============================================================================= function applyRowTreeAria(el: HTMLTableRowElement, meta: TableTreeRowMeta | undefined): void { if (!meta) { el.removeAttribute('aria-level'); el.removeAttribute('aria-expanded'); return; } el.setAttribute('aria-level', String(meta.level + 1)); if (meta.hasChildren) { el.setAttribute('aria-expanded', String(meta.isExpanded)); } else { el.removeAttribute('aria-expanded'); } } // ============================================================================= // Styles // ============================================================================= /** Indent step per level, by the `indent` config token. */ const INDENT_STEP = { sm: spacingVars['--spacing-3'], md: spacingVars['--spacing-4'], lg: spacingVars['--spacing-6'] } as const; const treeStyles = { cell: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "xzye2dw", $$css: true as const }, indent: (paddingInlineStart: string) => [{ kZCmMZ: paddingInlineStart != null ? "xnvo3vl" : paddingInlineStart, $$css: true as const }, { "--x-paddingInlineStart": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(paddingInlineStart) }], chevronIcon: { k1ekBW: "x11xpdln", kIyJzY: "xx6bhzk", $$css: true as const }, chevronIconCollapsed: { k3aq6I: "x7p49u4 xaafvuh", $$css: true as const }, chevronIconExpanded: { k3aq6I: "x1iffjtl x1nxqau", $$css: true as const }, clickableRow: { kkrTdU: "x1ypdohk x16khyan", $$css: true as const } }; // ============================================================================= // Cell content // ============================================================================= function TreeExpander({ isExpanded, onToggle }: { isExpanded: boolean; onToggle: () => void; }) { const t = useTranslator(); return ; } /** * Header expand-all/collapse-all toggle. Rendered in the tree column header * when `hasExpandAllControl` is set and the state hook supplies the aggregate * `isAllExpanded` plus `onExpandAll`/`onCollapseAll`. Shares the chevron * affordance with the per-row expander; the chevron points down (expanded) * only when every expandable row is expanded, matching the row expander. */ function TreeExpandAllToggle({ isAllExpanded, onExpandAll, onCollapseAll }: { isAllExpanded: boolean | 'indeterminate'; onExpandAll: () => void; onCollapseAll: () => void; }) { const t = useTranslator(); const allExpanded = isAllExpanded === true; return ; } function TreeCellContent>({ item, children }: { item: T; children: ReactNode; }) { const store = use(TreeStoreContext); if (!store) { return <>{children}; } return {children} ; } function TreeCellContentInner>({ store, item, children }: { store: TreeStore; item: T; children: ReactNode; }) { // Subscribe to this row's structural meta so a toggle re-renders only // the affected cells. useRowMetaSnapshot(store, item); const config = store.getConfig(); const meta = config.getRowMeta(item); if (!meta) { return <>{children}; } const step = INDENT_STEP[config.indent ?? 'md']; const indent = `calc(${meta.level} * ${step})`; return
0 && treeStyles.indent(indent))}> {meta.hasChildren ? store.getConfig().onToggleItem(item)} /> : } {children}
; } // ============================================================================= // Hook // ============================================================================= export function useTableTreeData>(config: UseTableTreeDataConfig): TablePlugin { const configRef = useRef(config); configRef.current = config; const storeRef = useRef | null>(null); if (storeRef.current == null) { storeRef.current = createTreeStore(configRef); } const store = storeRef.current; // Notify subscribers on every render — useSyncExternalStore only // re-renders cells whose snapshot actually changed. Row ref subscribers // apply imperative ARIA independently. useEffect(() => { store.notify(); }); // transformColumns runs on every table render; wrapped column objects // must keep their identity across renders or the per-row memo breaks // and a toggle re-renders the whole body. Earlier plugins may rebuild // structurally-identical arrays each render, so the cache compares // input columns by shallow equality, not array identity. Held in a ref // (like rowExpansion's column tracking) — transforms run during // BaseTable's render, where a closure variable reassignment trips the // react-compiler lint. const columnsCacheRef = useRef<{ input: TableColumn[]; treeKey: string | undefined; wrapped: boolean; output: TableColumn[]; } | null>(null); // The resolved tree column key, written by transformColumns (pipeline step 1) // and read by transformHeaderCell (step 4) to place the expand-all toggle on // the same column that carries the row expanders. const treeKeyRef = useRef(undefined); // The plugin object is created once per store and never changes shape: // every transform reads the live config through the store, and // internally no-ops when hasExpandableRows is false. Swapping between // an empty and a context-wrapping plugin would change the element tree // and remount the whole table when flat data turns nested. return useMemo((): TablePlugin => { const getCachedColumns = (columns: TableColumn[], treeKey: string | undefined, wrapped: boolean): TableColumn[] | null => { const cache = columnsCacheRef.current; if (!cache || cache.treeKey !== treeKey || cache.wrapped !== wrapped) { return null; } const prev = cache.input; if (prev !== columns) { if (prev.length !== columns.length) { return null; } for (let i = 0; i < prev.length; i++) { if (prev[i] !== columns[i]) { return null; } } } return cache.output; }; return { transformTableContext(children: ReactNode) { return {children}; }, transformColumns(columns: TableColumn[]) { const { hasExpandableRows, treeColumnKey } = store.getConfig(); // Resolve the tree column: the configured key when present, else // the first non-synthetic column (a configured column may have // been hidden by columnSettings — the expander must not vanish). const configuredExists = treeColumnKey != null && columns.some(c => c.key === treeColumnKey); const treeKey = configuredExists ? treeColumnKey : columns.find(c => !c.key.startsWith('__'))?.key ?? columns[0]?.key; treeKeyRef.current = treeKey; // Migration guarantee: flat data renders identically to a Table // without the plugin. const wrapped = hasExpandableRows; const cached = getCachedColumns(columns, treeKey, wrapped); if (cached) { return cached; } const output = !wrapped ? columns : columns.map(col => { if (col.key !== treeKey) { return col; } const originalRenderCell = col.renderCell; return { ...col, renderCell: (item: T): ReactNode => {originalRenderCell ? originalRenderCell(item) : String(item[col.key] as string | number | null | undefined ?? '')} }; }); columnsCacheRef.current = { input: columns, treeKey, wrapped, output }; return output; }, transformHeaderCell(props: HeaderCellRenderProps, column: TableColumn): HeaderCellRenderProps { const { hasExpandableRows, hasExpandAllControl, isAllExpanded, onExpandAll, onCollapseAll } = store.getConfig(); // Only the tree column carries the toggle, and only when the control // is enabled, the data is actually hierarchical, and the state hook // supplied the aggregate state plus both handlers. Otherwise this is a // pass-through (flat data stays a no-op). if (!hasExpandAllControl || !hasExpandableRows || column.key !== treeKeyRef.current || isAllExpanded === undefined || !onExpandAll || !onCollapseAll) { return props; } // Wrap the header label + the toggle in one inline-flex row so the // chevron sits to the LEFT of the title on the same line. BaseTable // only applies its own flex row for the `after` slot, so a bare // `before` would stack above the label in the block-level . return { ...props, content: {props.content} }; }, transformBodyRow(props: BodyRowRenderProps, item: T) { // Attach a ref that subscribes to the store for imperative row // ARIA. The ref returns a cleanup so React unsubscribes on // detach — without it, every row re-render would leak one // subscription (toggles shift rowIndex and re-render rows, so // the listener set would grow on every toggle). The ref is // attached even when no row is expandable so tree ARIA is // removed if the data turns flat. const treeRef: React.RefCallback = el => { if (!el) { return; } const apply = () => { const cfg = store.getConfig(); applyRowTreeAria(el, cfg.hasExpandableRows ? cfg.getRowMeta(item) : undefined); }; apply(); const unsub = store.subscribe(apply); return () => { unsub(); }; }; const withRef = { ...props, ref: props.ref ? mergeRefs(props.ref, treeRef) : treeRef }; // Whole-row-click expansion (opt-in). Only expandable rows are // clickable; leaves and flat data stay inert. `hasExpandableRows` is // the feature-level flag (short-circuits the whole feature when off); // `hasChildren` is the per-row check — both are intentional. const cfg = store.getConfig(); const rowClickExpandable = cfg.hasRowClickExpansion === true && cfg.hasExpandableRows && cfg.getRowMeta(item)?.hasChildren === true; if (!rowClickExpandable) { return withRef; } return { ...withRef, htmlProps: { ...withRef.htmlProps, onClick: (event: React.MouseEvent) => { // Don't hijack clicks on interactive cell content (the chevron // already stops propagation, but a composed selection checkbox, // link, or action button does not) or a text selection. const target = event.target as HTMLElement; if (target.closest('button, a, input, select, textarea, [role="button"], [role="checkbox"], [contenteditable="true"]')) { return; } if ((window.getSelection()?.toString() ?? '') !== '') { return; } cfg.onToggleItem(item); } }, xstyle: [...withRef.xstyle, treeStyles.clickableRow] }; } }; }, [store]); }