/* * Copyright 2023 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import {CollectionBuilder} from '@react-stately/collections'; import {GridNode} from '@react-types/grid'; import {Key} from '@react-types/shared'; import {ReactElement, useMemo} from 'react'; import {TableCollection} from './TableCollection'; import {tableNestedRows} from '@react-stately/flags'; import {TableState, TableStateProps, useTableState} from './useTableState'; import {useControlledState} from '@react-stately/utils'; export interface TreeGridState extends TableState { /** A set of keys for items that are expanded. */ expandedKeys: 'all' | Set, /** Toggles the expanded state for a row by its key. */ toggleKey(key: Key): void, /** The key map containing nodes representing the collection's tree grid structure. */ keyMap: Map>, /** The number of leaf columns provided by the user. */ userColumnCount: number } export interface TreeGridStateProps extends Omit, 'collection'> { /** The currently expanded keys in the collection (controlled). */ UNSTABLE_expandedKeys?: 'all' | Iterable, /** The initial expanded keys in the collection (uncontrolled). */ UNSTABLE_defaultExpandedKeys?: 'all' | Iterable, /** Handler that is called when items are expanded or collapsed. */ UNSTABLE_onExpandedChange?: (keys: Set) => any } /** * Provides state management for a tree grid component. Handles building a collection * of columns and rows from props. In addition, it tracks and manages expanded rows, row selection, and sort order changes. */ export function UNSTABLE_useTreeGridState(props: TreeGridStateProps): TreeGridState { let { selectionMode = 'none', showSelectionCheckboxes, showDragButtons, UNSTABLE_expandedKeys: propExpandedKeys, UNSTABLE_defaultExpandedKeys: propDefaultExpandedKeys, UNSTABLE_onExpandedChange, children } = props; if (!tableNestedRows()) { throw new Error('Feature flag for table nested rows must be enabled to use useTreeGridState.'); } let [expandedKeys, setExpandedKeys] = useControlledState( propExpandedKeys ? convertExpanded(propExpandedKeys) : undefined, propDefaultExpandedKeys ? convertExpanded(propDefaultExpandedKeys) : new Set(), UNSTABLE_onExpandedChange ); let context = useMemo(() => ({ showSelectionCheckboxes: showSelectionCheckboxes && selectionMode !== 'none', showDragButtons: showDragButtons, selectionMode, columns: [] // eslint-disable-next-line react-hooks/exhaustive-deps }), [children, showSelectionCheckboxes, selectionMode, showDragButtons]); let builder = useMemo(() => new CollectionBuilder(), []); let nodes = useMemo(() => builder.build({children: children as ReactElement[]}, context), [builder, children, context]); let treeGridCollection = useMemo(() => { return generateTreeGridCollection(nodes, {showSelectionCheckboxes, showDragButtons, expandedKeys}); }, [nodes, showSelectionCheckboxes, showDragButtons, expandedKeys]); let onToggle = (key: Key) => { setExpandedKeys(toggleKey(expandedKeys, key, treeGridCollection)); }; let collection = useMemo(() => { return new TableCollection(treeGridCollection.tableNodes, null, context); }, [context, treeGridCollection.tableNodes]); let tableState = useTableState({...props, collection}); return { ...tableState, keyMap: treeGridCollection.keyMap, userColumnCount: treeGridCollection.userColumnCount, expandedKeys, toggleKey: onToggle }; } function toggleKey(currentExpandedKeys: 'all' | Set, key: Key, collection: TreeGridCollection): Set { let updatedExpandedKeys: Set; if (currentExpandedKeys === 'all') { updatedExpandedKeys = new Set(collection.flattenedRows.filter(row => row.props.UNSTABLE_childItems || row.props.children.length > collection.userColumnCount).map(row => row.key)); updatedExpandedKeys.delete(key); } else { updatedExpandedKeys = new Set(currentExpandedKeys); if (updatedExpandedKeys.has(key)) { updatedExpandedKeys.delete(key); } else { updatedExpandedKeys.add(key); } } return updatedExpandedKeys; } function convertExpanded(expanded: 'all' | Iterable): 'all' | Set { if (!expanded) { return new Set(); } return expanded === 'all' ? 'all' : new Set(expanded); } interface TreeGridCollectionOptions { showSelectionCheckboxes?: boolean, showDragButtons?: boolean, expandedKeys: 'all' | Set } interface TreeGridCollection { keyMap: Map>, tableNodes: GridNode[], flattenedRows: GridNode[], userColumnCount: number } function generateTreeGridCollection(nodes, opts: TreeGridCollectionOptions): TreeGridCollection { let { expandedKeys = new Set() } = opts; let body: GridNode | null = null; let flattenedRows: GridNode[] = []; let columnCount = 0; let userColumnCount = 0; let originalColumns: GridNode[] = []; let keyMap = new Map(); if (opts?.showSelectionCheckboxes) { columnCount++; } if (opts?.showDragButtons) { columnCount++; } let topLevelRows: GridNode[] = []; let visit = (node: GridNode) => { switch (node.type) { case 'body': body = node; keyMap.set(body.key, body); break; case 'column': if (!node.hasChildNodes) { userColumnCount++; } break; case 'item': topLevelRows.push(node); return; } for (let child of node.childNodes) { visit(child); } }; for (let node of nodes) { if (node.type === 'column') { originalColumns.push(node); } visit(node); } columnCount += userColumnCount; // Update each grid node in the treegrid table with values specific to a treegrid structure. Also store a set of flattened row nodes for TableCollection to consume let globalRowCount = 0; let visitNode = (node: GridNode, i?: number) => { // Clone row node and its children so modifications to the node for treegrid specific values aren't applied on the nodes provided // to TableCollection. Index, level, and parent keys are all changed to reflect a flattened row structure rather than the treegrid structure // values automatically calculated via CollectionBuilder if (node.type === 'item') { let childNodes: GridNode[] = []; for (let child of node.childNodes) { if (child.type === 'cell') { let cellClone = {...child}; if (cellClone.index + 1 === columnCount) { cellClone.nextKey = null; } childNodes.push({...cellClone}); } } let clone: GridNode = {...node, childNodes: childNodes, parentKey: body!.key, level: 1, index: globalRowCount++}; flattenedRows.push(clone); } let newProps = {}; // Assign indexOfType to cells and rows for aria-posinset if (node.type !== 'placeholder' && node.type !== 'column') { newProps['indexOfType'] = i; } // Use Object.assign instead of spread to preserve object reference for keyMap. Also ensures retrieving nodes // via .childNodes returns the same object as the one found via keyMap look up Object.assign(node, newProps); keyMap.set(node.key, node); let lastNode: GridNode | null = null; let rowIndex = 0; for (let child of node.childNodes) { if (!(child.type === 'item' && expandedKeys !== 'all' && !expandedKeys.has(node.key))) { if (child.parentKey == null) { // if child is a cell/expanded row/column and the parent key isn't already established by the collection, match child node to parent row child.parentKey = node.key; } if (lastNode) { lastNode.nextKey = child.key; child.prevKey = lastNode.key; } else { child.prevKey = null; } if (child.type === 'item') { visitNode(child, rowIndex++); } else { // We enforce that the cells come before rows so can just reuse cell index visitNode(child, child.index); } lastNode = child; } } if (lastNode) { lastNode.nextKey = null; } }; let last: GridNode | null = null; for (let [i, node] of topLevelRows.entries()) { visitNode(node as GridNode, i); if (last) { last.nextKey = node.key; node.prevKey = last.key; } else { node.prevKey = null; } last = node; } if (last) { last.nextKey = null; } return { keyMap, userColumnCount, flattenedRows, tableNodes: [...originalColumns, {...body!, childNodes: flattenedRows}] }; }