// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file useTableGroupedRows.tsx * @input React, StyleX, Icon, Table types + the flat data array * @output Exports useTableGroupedRows hook + config/result types * @position Grouped-rows plugin; consumed by Table via plugins prop * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Table/index.ts (exports) */ import { useCallback, useMemo, type ReactNode } from 'react'; import * as stylex from "../../../style-runtime.js"; import "../../../theme/tokens.stylex.js"; import { spacingVars, colorVars, fontWeightVars } from "../../../theme/tokens.stylex.js"; import { Icon } from "../../../Icon/index.js"; import type { TablePlugin } from "../../types.js"; import { useTranslator } from "../../../i18n/index.js"; // A synthetic group-header row injected into the flattened data. Real rows // never carry this marker. const GROUP_HEADER = Symbol('tableGroupHeader'); interface GroupHeader { [GROUP_HEADER]: true; groupKey: string; count: number; } function isGroupHeader(item: unknown): item is GroupHeader { return typeof item === 'object' && item !== null && (item as Record)[GROUP_HEADER] === true; } // Proxy handler: any field access beyond the marker fields resolves to `''`, // which keeps anything that reads a header's data — the sortable and filtering // plugins, a consumer's own row handler — off `undefined`. Cell renderers are // handled at render time instead (see `transformBodyCell` below); the Proxy // never protected them, because `''` fails a lookup exactly as `undefined` does. const HEADER_PROXY_HANDLER: ProxyHandler> = { // eslint-disable-next-line @typescript-eslint/promise-function-async -- Proxy get trap, not a promise-returning fn get(t: Record, prop: string | symbol): unknown { if (prop === GROUP_HEADER || prop === 'groupKey' || prop === 'count') { return t[prop]; } return prop in t ? t[prop] : ''; } }; /** * Build a synthetic header row wrapped in a Proxy so field access resolves to * `''` instead of `undefined`. `transformBodyRow` then discards the row's * cells and renders a single full-width header cell. */ function makeHeader>(groupKey: string, count: number): T { const target: Record = { [GROUP_HEADER]: true, groupKey, count }; return new Proxy(target, HEADER_PROXY_HANDLER) as unknown as T; } /** Configuration for {@link useTableGroupedRows}. */ export interface UseTableGroupedRowsConfig> { /** The flat data to group. */ data: T[]; /** Derive the group key for a row. Rows with the same key share a section. */ groupBy: (item: T) => string; /** Set of currently-collapsed group keys. */ collapsedGroups: Set; /** Called with a group key when its header is toggled. */ onToggleGroup: (groupKey: string) => void; /** * Custom renderer for a group header's content (right of the chevron). * Defaults to ` ()`. */ renderGroupHeader?: (groupKey: string, count: number, collapsed: boolean) => ReactNode; /** Stable key for a real row. Falls back to a positional key when omitted. */ getRowKey?: (item: T) => string; /** Explicit group ordering; groups not listed keep first-seen order after these. */ groupOrder?: string[]; } export interface UseTableGroupedRowsResult> { /** Ready-to-use plugin for ``. */ plugin: TablePlugin; /** Flattened rows: `[header, ...visibleRows, header, ...visibleRows]`. */ data: T[]; /** * Row-key resolver (also keys synthetic headers as `__group_`). Pass to * `
` — named for parallelism with the Table prop: * `
`. */ idKey: (item: T) => string; } const styles = { headerRow: { kkrTdU: "x1ypdohk x16khyan", kfSwDN: "x87ps6o", kWkggS: "xwmxj5m", kt9PQ7: "xso031l", kfdmCh: "x1q0q8m5", kL6WhQ: "xw8gpjh", $$css: true as const }, 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 } }; /** * Groups a flat data array into collapsible section rows. Each distinct * `groupBy` value becomes a full-width section-header row with a chevron * toggle, the group label, and a member count; collapsing hides that group's * data rows while keeping the header visible. * * Mirrors other controlled Table state helpers: the consumer owns the * `collapsedGroups` set and this hook returns `{data, plugin, idKey}`: * pass all three to `
`. * * @example * ``` * const [collapsed, setCollapsed] = useState>(new Set()); * const grouped = useTableGroupedRows({ * data: rows, * groupBy: r => r.team, * collapsedGroups: collapsed, * onToggleGroup: key => * setCollapsed(prev => { * const next = new Set(prev); * next.has(key) ? next.delete(key) : next.add(key); * return next; * }), * getRowKey: r => r.id, * }); *
; * ``` * * ## Pairing with pagination * * Grouping runs on the rows it is handed, so the order is filter, sort, slice, * then group. Sort by the group key first and the reader's own keys second — * the same `ORDER BY group, sort` a backend would run. That keeps a section's * rows contiguous in the result set, so pages fill sections in order and new * rows always land at the bottom of the table. Slice a differently-ordered * list and every page holds a scatter of sections instead, so loading more * splices rows into the middle and new headings appear above the fold. * * @example * ``` * const ordered = useMemo( * () => [...filtered].sort((a, b) => byTeam(a, b) || bySortKeys(a, b)), * [filtered, sortKeys], * ); * const page = ordered.slice(0, loadedCount); * * const grouped = useTableGroupedRows({ * data: page, * groupBy: r => r.team, * collapsedGroups, * onToggleGroup, * }); * ``` */ export function useTableGroupedRows>(config: UseTableGroupedRowsConfig): UseTableGroupedRowsResult { const t = useTranslator(); const { data, groupBy, collapsedGroups, onToggleGroup, renderGroupHeader, getRowKey: getRowKeyProp, groupOrder } = config; const flattened = useMemo((): T[] => { if (data.length === 0) { return []; } // Group preserving first-seen order. const groups = new Map(); for (const item of data) { const key = groupBy(item); const bucket = groups.get(key); if (bucket) { bucket.push(item); } else { groups.set(key, [item]); } } // Determine iteration order. let keys = [...groups.keys()]; if (groupOrder && groupOrder.length > 0) { const ordered = groupOrder.filter(k => groups.has(k)); const rest = keys.filter(k => !groupOrder.includes(k)); keys = [...ordered, ...rest]; } const out: T[] = []; for (const key of keys) { const rows = groups.get(key) ?? []; out.push(makeHeader(key, rows.length)); if (!collapsedGroups.has(key)) { out.push(...rows); } } return out; }, [data, groupBy, collapsedGroups, groupOrder]); // Positional fallback index, built once per flattened array so key lookup // stays O(1) instead of O(n) per row (which would make table keying O(n²)). const positionByItem = useMemo(() => { if (getRowKeyProp) { return null; } const map = new Map(); for (let i = 0; i < flattened.length; i++) { map.set(flattened[i], i); } return map; }, [flattened, getRowKeyProp]); const idKey = useCallback((item: T): string => { if (isGroupHeader(item)) { return `__group_${item.groupKey}`; } if (getRowKeyProp) { return getRowKeyProp(item); } return String(positionByItem?.get(item) ?? -1); }, [getRowKeyProp, positionByItem]); const plugin = useMemo((): TablePlugin => ({ // A header row is not one of the caller's rows, so running a cell // renderer against it can only misread it or throw — and the cells are // discarded by `transformBodyRow` below regardless. transformBodyCell(props, _column, item) { if (!isGroupHeader(item)) { return props; } return { ...props, isContentSuppressed: true }; }, // Replace a header row's pre-rendered cells with one full-width cell. transformBodyRow(props, item) { if (!isGroupHeader(item)) { return props; } const header = item as unknown as GroupHeader; const collapsed = collapsedGroups.has(header.groupKey); const toggle = () => onToggleGroup(header.groupKey); const content: ReactNode = renderGroupHeader ? renderGroupHeader(header.groupKey, header.count, collapsed) : {header.groupKey}{' '} ({header.count}) ; return { ...props, htmlProps: { ...props.htmlProps, // Convenience: clicking anywhere on the row toggles it. The chevron // button below is the accessible, keyboard-operable control, so the // row keeps its implicit `row` role (no role override here). onClick: toggle, 'aria-expanded': !collapsed }, xstyle: [...props.xstyle, styles.headerRow], children: // colSpan larger than the column count is clamped by the browser // to the actual number of columns, so the header always spans the // full width without the plugin knowing the column count. }; } }), [collapsedGroups, onToggleGroup, renderGroupHeader, t]); return { plugin, data: flattened, idKey }; }
{/* Standalone chevron button, flush with the table's start edge (no heavy button chrome) — the keyboard control. */} {content}