From 5847e26da46ecbfd672c6cb2e5105f3538ece152 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:37:14 -0800 Subject: [PATCH] feat: add support for tree sections (#9013) * initial tree stuff * fix virtualized tree section expanding, update methods to remove flattenTree * cleanup * more cleanup * update key after if its content node * fix types when checking content node * fix spacing * update dynamic story * update setSize with added getDirectChildren function * fix types, update at method * remove console logs * add collection dependency to gridlist * fix lint * update yarn lock * rename dynamic row section array * add tests, fix setSize for top level nodes inside a section * fix lint * remove comments * more cleanup * comments and stuff * fix tests * these changes were made in oct and it's now jan so... * lots of comments regarding skipping content nodes in tree * skip content nodes is listlayout and collections, update keyboard delegate * update yarn lock * update direct children function * comments * update direct children functiona and remove collectionode type from gridlist * update comments * updates * simplify logic for visible/non disabled item * comments, small fixes * make guarding against content node more robust * optimize filtering content nodes in list layout * optimize getting child array * add helper function for cloning * add comments --- .../collections/src/useCachedChildren.ts | 4 +- .../dnd/src/DropTargetKeyboardNavigation.ts | 5 + .../gridlist/src/useGridListItem.ts | 20 +- .../selection/src/ListKeyboardDelegate.ts | 56 +++- .../@react-stately/data/src/useTreeData.ts | 2 +- .../@react-stately/layout/src/ListLayout.ts | 19 +- .../react-aria-components/src/Collection.tsx | 6 + .../react-aria-components/src/GridList.tsx | 2 +- packages/react-aria-components/src/Tree.tsx | 277 ++++++++++++++---- packages/react-aria-components/src/index.ts | 2 +- .../stories/Tree.stories.tsx | 229 ++++++++++++++- .../react-aria-components/test/Tree.test.tsx | 206 ++++++++++++- 12 files changed, 756 insertions(+), 72 deletions(-) diff --git a/packages/@react-aria/collections/src/useCachedChildren.ts b/packages/@react-aria/collections/src/useCachedChildren.ts index 0e0d6487af5..fc0cfbc64d7 100644 --- a/packages/@react-aria/collections/src/useCachedChildren.ts +++ b/packages/@react-aria/collections/src/useCachedChildren.ts @@ -45,11 +45,11 @@ export function useCachedChildren(props: CachedChildrenOptions rendered = children(item); // @ts-ignore let key = rendered.props.id ?? item.key ?? item.id; - + if (key == null) { throw new Error('Could not determine key for item'); } - + if (idScope != null) { key = idScope + ':' + key; } diff --git a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts index d3e24953de2..7fbec801b48 100644 --- a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts +++ b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts @@ -59,6 +59,11 @@ function nextDropTarget( nextKey = keyboardDelegate.getKeyBelow?.(target.key); } let nextCollectionKey = collection.getKeyAfter(target.key); + let nextCollectionNode = nextCollectionKey && collection.getItem(nextCollectionKey); + while (nextCollectionNode && nextCollectionNode.type === 'content') { + nextCollectionKey = nextCollectionKey ? collection.getKeyAfter(nextCollectionKey) : null; + nextCollectionNode = nextCollectionKey && collection.getItem(nextCollectionKey); + } // If the keyboard delegate did not move to the next key in the collection, // jump to that key with the same drop position. Otherwise, try the other diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index d6280b13874..b3ff34350c8 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -11,7 +11,7 @@ */ import {chain, getActiveElement, getEventTarget, getScrollParent, isFocusWithin, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; -import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; +import {Collection, DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; import {getRowId, listMap} from './utils'; import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; @@ -100,11 +100,11 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt let isExpanded = hasChildRows ? state.expandedKeys.has(node.key) : undefined; let setSize = 1; - if (node.level > 0 && node?.parentKey != null) { + if (node.level >= 0 && node?.parentKey != null) { let parent = state.collection.getItem(node.parentKey); if (parent) { // siblings must exist because our original node exists - let siblings = state.collection.getChildren?.(parent.key)!; + let siblings = getDirectChildren(parent, state.collection); setSize = [...siblings].filter(row => row.type === 'item').length; } } else { @@ -326,3 +326,17 @@ function last(walker: TreeWalker) { } while (last); return next; } + +function getDirectChildren(parent: RSNode, collection: Collection>) { + // We can't assume that we can use firstChildKey because if a person builds a tree using hooks, they would not have access to that property (using type Node vs CollectionNode) + // Instead, get all children and start at the first node (rather than just using firstChildKey) and only look at its siblings + let children = collection.getChildren?.(parent.key); + let childArray = children ? Array.from(children) : []; + let node = childArray.length > 0 ? childArray[0] : null; + let siblings: RSNode[] = []; + while (node) { + siblings.push(node); + node = node.nextKey != null ? collection.getItem(node.nextKey) : null; + } + return siblings; +} diff --git a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts index f76d740f5af..11f4422c044 100644 --- a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts +++ b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts @@ -23,7 +23,8 @@ interface ListKeyboardDelegateOptions { direction?: Direction, disabledKeys?: Set, disabledBehavior?: DisabledBehavior, - layoutDelegate?: LayoutDelegate + layoutDelegate?: LayoutDelegate, + expandedKeys?: Set } export class ListKeyboardDelegate implements KeyboardDelegate { @@ -36,8 +37,9 @@ export class ListKeyboardDelegate implements KeyboardDelegate { private orientation?: Orientation; private direction?: Direction; private layoutDelegate: LayoutDelegate; + private expandedKeys?: Set; - constructor(collection: Collection>, disabledKeys: Set, ref: RefObject, collator?: Intl.Collator); + constructor(collection: Collection>, disabledKeys: Set, ref: RefObject, collator?: Intl.Collator, expandedKeys?: Set); constructor(options: ListKeyboardDelegateOptions); constructor(...args: any[]) { if (args.length === 1) { @@ -51,6 +53,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { this.direction = opts.direction; this.layout = opts.layout || 'stack'; this.layoutDelegate = opts.layoutDelegate || new DOMLayoutDelegate(opts.ref); + this.expandedKeys = opts.expandedKeys; } else { this.collection = args[0]; this.disabledKeys = args[1]; @@ -60,6 +63,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { this.orientation = 'vertical'; this.disabledBehavior = 'all'; this.layoutDelegate = new DOMLayoutDelegate(this.ref); + this.expandedKeys = args[4]; } // If this is a vertical stack, remove the left/right methods completely @@ -88,6 +92,50 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return null; } + // Returns the first key that's visible starting from and inclusive of the provided key + private findNextVisible(key: Key | null): Key | null { + let node = key ? this.collection.getItem(key) : null; + if (!node) { + return null; + } + + // If the node's parent is expanded, then we can assume that this is a visible node + if (node.parentKey && this.expandedKeys?.has(node.parentKey)) { + return node.key; + } + + // If the node's parent is not expanded, find the top-most non-expanded node since it's possible for them to be nested + let parentNode = node.parentKey ? this.collection.getItem(node.parentKey) : null; + // if the the parent node is not a section, and the parent node is not included in expanded keys + while (parentNode && parentNode.type !== 'section' && node && node.parentKey && this.expandedKeys && !this.expandedKeys.has(parentNode.key)) { + node = this.collection.getItem(node.parentKey); + parentNode = node && node.parentKey ? this.collection.getItem(node.parentKey) : null; + } + + return node?.key ?? null; + } + + // Returns the first key that's visible and non-disabled starting from and inclusive of the provided key + private findNextNonDisabledVisible(key: Key | null, getNext: (key: Key) => Key | null) { + let nextKey = key; + while (nextKey !== null) { + let visibleKey = this.findNextVisible(nextKey); + // If visibleKey is null, that means there are no visibleKeys (don't feel like this is a real use case though, I would assume that there is always one visible node) + if (visibleKey == null) { + return null; + } + + let node = this.collection.getItem(visibleKey); + if (node?.type === 'item' && !this.isDisabled(node)) { + return visibleKey; + } + + nextKey = getNext(visibleKey); + } + + return null; + } + getNextKey(key: Key): Key | null { let nextKey: Key | null = key; nextKey = this.collection.getKeyAfter(nextKey); @@ -201,6 +249,10 @@ export class ListKeyboardDelegate implements KeyboardDelegate { getLastKey(): Key | null { let key = this.collection.getLastKey(); + // we only need to check for visible keys if items can be expanded/collapsed + if (this.expandedKeys) { + return this.findNextNonDisabledVisible(key, key => this.collection.getKeyBefore(key)); + } return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key)); } diff --git a/packages/@react-stately/data/src/useTreeData.ts b/packages/@react-stately/data/src/useTreeData.ts index 5baf6d8f3f9..2efd5082eec 100644 --- a/packages/@react-stately/data/src/useTreeData.ts +++ b/packages/@react-stately/data/src/useTreeData.ts @@ -504,7 +504,7 @@ function moveItems( // decrement the index if the child being removed is in the target parent and before the target index // the root node is special, it is null, and will not have a key, however, a parentKey can still point to it if ((child.parentKey === toParent - || child.parentKey === toParent?.key) + || child.parentKey === toParent?.key) && keyArray.includes(child.key) && (toParent?.children ? toParent.children.indexOf(child) : items.indexOf(child)) < originalToIndex) { toIndex--; diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 6dc8da975bc..7060ad08edc 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -253,7 +253,9 @@ export class ListLayout exte protected buildCollection(y: number = this.padding): LayoutNode[] { let collection = this.virtualizer!.collection; - let collectionNodes = [...collection]; + // filter out content nodes since we don't want them to affect the height + // Tree specific for now, if we add content nodes to other collection items, we might need to reconsider this + let collectionNodes = toArray(collection, (node) => node.type !== 'content'); let loaderNodes = collectionNodes.filter(node => node.type === 'loader'); let nodes: LayoutNode[] = []; let isEmptyOrLoading = collection?.size === 0; @@ -370,6 +372,11 @@ export class ListLayout exte let skipped = 0; let children: LayoutNode[] = []; for (let child of getChildNodes(node, collection)) { + // skip if it is a content node, Tree specific for now, if we add content nodes to other collection items, we might need to reconsider this + if (child.type === 'content') { + continue; + } + let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap; // Skip rows before the valid rectangle unless they are already cached. @@ -617,3 +624,13 @@ export class ListLayout exte return new LayoutInfo('dropIndicator', target.key + ':' + target.dropPosition, rect); } } + +function toArray(collection: Collection>, predicate: (node: Node) => boolean): Node[] { + const result: Node[] = []; + for (const node of collection) { + if (predicate(node)) { + result.push(node); + } + } + return result; +} diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index d0e27e11e02..70f4d8266c3 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -159,6 +159,12 @@ function useCollectionRender( items: parent ? collection.getChildren!(parent.key) : collection, dependencies: [renderDropIndicator], children(node) { + // Return a empty fragment since we don't want to render the content twice + // If we don't skip the content node here, we end up rendering them twice in a Tree since we also render the content node in TreeItem + if (node.type === 'content') { + return <>; + } + let rendered = node.render!(node); if (!renderDropIndicator || node.type !== 'item') { return rendered; diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 57aab3bab5a..afeeeef82b9 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -653,7 +653,7 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, export interface GridListHeaderProps extends DOMRenderProps<'div', undefined>, DOMProps, GlobalDOMAttributes {} export const GridListHeaderContext = createContext>({}); -const GridListHeaderInnerContext = createContext | null>(null); +export const GridListHeaderInnerContext = createContext | null>(null); export const GridListHeader = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: GridListHeaderProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, GridListHeaderContext); diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 53a0794e324..0336e312bcc 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaTreeItemOptions, AriaTreeProps, DraggableItemResult, DropIndicatorAria, DropIndicatorProps, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridListSelectionCheckbox, useHover, useId, useLocale, useTree, useTreeItem, useVisuallyHidden} from 'react-aria'; +import {AriaTreeItemOptions, AriaTreeProps, DraggableItemResult, DropIndicatorAria, DropIndicatorProps, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridListSection, useGridListSelectionCheckbox, useHover, useId, useLocale, useTree, useTreeItem, useVisuallyHidden} from 'react-aria'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; import { @@ -19,6 +19,7 @@ import { ContextValue, DEFAULT_SLOT, dom, + DOMRenderProps, Provider, RenderProps, SlotProps, @@ -26,37 +27,78 @@ import { useContextProps, useRenderProps } from './utils'; -import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, LoaderNode, useCachedChildren} from '@react-aria/collections'; -import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; +import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, LoaderNode, SectionNode, useCachedChildren} from '@react-aria/collections'; +import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, SectionProps} from './Collection'; import {DisabledBehavior, DragPreviewRenderer, Expandable, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, MultipleSelection, PressEvents, RefObject, SelectionMode} from '@react-types/shared'; import {DragAndDropContext, DropIndicatorContext, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, Node, SelectionBehavior, TreeState, useTreeState} from 'react-stately'; import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; +import {GridListHeader, GridListHeaderContext, GridListHeaderInnerContext, GridListHeaderProps} from './GridList'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TreeDropTargetDelegate} from './TreeDropTargetDelegate'; import {useControlledState} from '@react-stately/utils'; - class TreeCollection implements ICollection> { - private flattenedRows: Node[]; private keyMap: Map> = new Map(); private itemCount: number = 0; + private expandedKeys; + private collection; constructor(opts) { - let {collection, expandedKeys} = opts; - let {flattenedRows, keyMap, itemCount} = flattenTree(collection, {expandedKeys}); - this.flattenedRows = flattenedRows; + let {collection, lastExpandedKeys, expandedKeys} = opts; + let {keyMap, itemCount} = generateKeyMap(collection, {expandedKeys}); // Use generated keyMap because it contains the modified collection nodes (aka it adjusts the indexes so that they ignore the existence of the Content items) + // Also adjusts the levels of node inside of a section this.keyMap = keyMap; + this.collection = collection; this.itemCount = itemCount; + this.expandedKeys = expandedKeys; + // We do this so React knows to re-render since the same item won't cause a new render but a clone creating a new object with the same value will + // Without this change, the items won't expand and collapse when virtualized inside a section + TreeCollection.cloneAncestorSections(expandedKeys, lastExpandedKeys, this.keyMap, (k) => this.getItem(k)); + TreeCollection.cloneAncestorSections(lastExpandedKeys, expandedKeys, this.keyMap, (k) => this.getItem(k)); + } + + // diff lastExpandedKeys and expandedKeys so we only clone what has changed + private static cloneAncestorSections( + keys: Iterable, + excludeSet: Set, + keyMap: Map>, + getItem: (key: Key) => Node | null + ) { + for (let key of keys) { + if (!excludeSet.has(key)) { + let currentKey: Key | null = key; + while (currentKey != null) { + let item = getItem(currentKey) as CollectionNode; + if (item?.type === 'section') { + keyMap.set(currentKey, item.clone()); + break; + } else { + currentKey = item?.parentKey ?? null; + } + } + } + } } - // TODO: should this collection's getters reflect the flattened structure or the original structure - // If we respresent the flattened structure, it is easier for the keyboard nav but harder to find all the nodes *[Symbol.iterator]() { - yield* this.flattenedRows; + let firstKey = this.getFirstKey(); + let node: Node | null = firstKey != null ? this.getItem(firstKey) : null; + + while (node) { + yield node as Node; + if (node.type === 'section') { + node = node.nextKey ? this.getItem(node.nextKey) : null; + } else { + // This will include both item and content nodes + // We handle the content nodes in useCollectionRenderer and ListLayout + let key = this.getKeyAfter(node.key); + node = key ? this.getItem(key) : null; + } + } } get size() { @@ -71,38 +113,87 @@ class TreeCollection implements ICollection> { return this.keyMap.get(key) || null; } - at(idx: number) { - return this.flattenedRows[idx]; + at(): Node { + throw new Error('Not implemented'); } getFirstKey() { - return this.flattenedRows[0]?.key; + return this.collection.getFirstKey(); } getLastKey() { - return this.flattenedRows[this.flattenedRows.length - 1]?.key; + return this.collection.getLastKey(); } getKeyAfter(key: Key) { - let index = this.flattenedRows.findIndex(row => row.key === key); - return this.flattenedRows[index + 1]?.key; + let node = this.getItem(key) as CollectionNode; + if (!node) { + return null; + } + + if ((this.expandedKeys.has(node.key) || node.type !== 'item') && node.firstChildKey != null) { + return node.firstChildKey; + } + + while (node) { + if (node.nextKey != null) { + return node.nextKey; + } + + if (node.parentKey != null) { + node = this.getItem(node.parentKey) as CollectionNode; + } else { + return null; + } + } + + return null; } getKeyBefore(key: Key) { - let index = this.flattenedRows.findIndex(row => row.key === key); - return this.flattenedRows[index - 1]?.key; + let node = this.getItem(key) as CollectionNode; + if (!node) { + return null; + } + + if (node.prevKey != null) { + node = this.getItem(node.prevKey) as CollectionNode; + + while (node && node.type !== 'item' && node.lastChildKey != null) { + node = this.getItem(node.lastChildKey) as CollectionNode; + } + + // If the lastChildKey is expanded, check its lastChildKey + while (node && this.expandedKeys.has(node.key) && node.lastChildKey != null) { + node = this.getItem(node.lastChildKey) as CollectionNode; + } + + return node?.key ?? null; + } + + return node.parentKey; } - // Note that this will return Content nodes in addition to nested TreeItems getChildren(key: Key): Iterable> { let keyMap = this.keyMap; + let self = this; return { *[Symbol.iterator]() { let parent = keyMap.get(key); - let node = parent?.firstChildKey != null ? keyMap.get(parent.firstChildKey) : null; - while (node) { - yield node as Node; - node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; + let node = parent?.firstChildKey ? keyMap.get(parent.firstChildKey) : null; + if (parent && parent.type === 'section' && node) { + // Stop once either the node is null or the node is the parent's sibling + while (node && node.key !== parent.nextKey) { + yield keyMap.get(node.key) as Node; + // This will include content nodes which we skip in ListLayout + let key = self.getKeyAfter(node.key); + node = key ? keyMap.get(key) : undefined; + } + } else { + while (node) { + yield node as Node; + node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; + } } } }; @@ -235,14 +326,22 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne // Kinda annoying that we have to replicate this code here as well as in useTreeState, but don't want to add // flattenCollection stuff to useTreeState. Think about this later let [expandedKeys, setExpandedKeys] = useControlledState( - propExpandedKeys ? convertExpanded(propExpandedKeys) : undefined, - propDefaultExpandedKeys ? convertExpanded(propDefaultExpandedKeys) : new Set(), + propExpandedKeys ? new Set(propExpandedKeys) : undefined, + propDefaultExpandedKeys ? new Set(propDefaultExpandedKeys) : new Set(), onExpandedChange ); - let flattenedCollection = useMemo(() => { - return new TreeCollection({collection, expandedKeys}); - }, [collection, expandedKeys]); + let [lastCollection, setLastCollection] = useState(collection); + let [lastExpandedKeys, setLastExpandedKeys] = useState(expandedKeys); + let [flattenedCollection, setFlattenedCollection] = useState(() => new TreeCollection({collection, lastExpandedKeys: new Set(), expandedKeys})); + + + // if the lastExpandedKeys is not the same as the currentExpandedKeys or the collection has changed, then run this + if (!areSetsEqual(lastExpandedKeys, expandedKeys) || collection !== lastCollection) { + setFlattenedCollection(new TreeCollection({collection, lastExpandedKeys, expandedKeys})); + setLastCollection(collection); + setLastExpandedKeys(expandedKeys); + } let state = useTreeState({ ...props, @@ -254,10 +353,26 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne disabledBehavior }); + // useSelectableList is not aware of expandedKeys, so create a new ListKeyboardDelegate which will handle that + let keyboardDelegate = useMemo(() => + new ListKeyboardDelegate({ + collection: state.collection, + collator, + ref, + disabledKeys: state.selectionManager.disabledKeys, + disabledBehavior: state.selectionManager.disabledBehavior, + direction, + layoutDelegate, + expandedKeys + }), + [state.collection, collator, ref, state.selectionManager.disabledKeys, state.selectionManager.disabledBehavior, direction, layoutDelegate, expandedKeys] + ); + let {gridProps} = useTree({ ...props, isVirtualized, - layoutDelegate + layoutDelegate, + keyboardDelegate }, state, ref); let dragState: DraggableCollectionState | undefined = undefined; @@ -289,17 +404,6 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne }); let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(state.collection, ref, {direction}); treeDropTargetDelegate.setup(dropTargetDelegate, state, direction); - - let keyboardDelegate = - new ListKeyboardDelegate({ - collection: state.collection, - collator, - ref, - disabledKeys: state.selectionManager.disabledKeys, - disabledBehavior: state.selectionManager.disabledBehavior, - direction, - layoutDelegate - }); droppableCollection = dragAndDropHooks.useDroppableCollection!( { keyboardDelegate, @@ -309,7 +413,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne if (e.target.type === 'item') { let key = e.target.key; let item = state.collection.getItem(key); - let isExpanded = expandedKeys !== 'all' && expandedKeys.has(key); + let isExpanded = expandedKeys.has(key); if (item && item.hasChildNodes && (!isExpanded || dragAndDropHooks?.isVirtualDragging?.())) { state.toggleKey(key); } @@ -826,36 +930,25 @@ export const TreeLoadMoreItem = createLeafComponent(LoaderNode, function TreeLoa ); }); -function convertExpanded(expanded: 'all' | Iterable): 'all' | Set { - if (!expanded) { - return new Set(); - } - - return expanded === 'all' - ? 'all' - : new Set(expanded); -} interface TreeGridCollectionOptions { expandedKeys: Set } interface FlattenedTree { - flattenedRows: Node[], keyMap: Map>, itemCount: number } -function flattenTree(collection: TreeCollection, opts: TreeGridCollectionOptions): FlattenedTree { +function generateKeyMap(collection: TreeCollection, opts: TreeGridCollectionOptions): FlattenedTree { let { expandedKeys = new Set() } = opts; let keyMap: Map> = new Map(); - let flattenedRows: Node[] = []; // Need to count the items here because BaseCollection will return the full item count regardless if items are hidden via collapsed rows let itemCount = 0; let parentLookup: Map = new Map(); - let visitNode = (node: Node) => { + let visitNode = (node: Node, isInSection: boolean) => { if (node.type === 'item' || node.type === 'loader') { let parentKey = node?.parentKey; let clone = {...node}; @@ -867,6 +960,12 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO clone.index = node?.index != null ? node?.index - 1 : 0; } + if (isInSection) { + if (node.type === 'item') { + clone.level = node?.level != null ? node?.level - 1 : 0; + } + } + // For loader nodes that have a parent (aka non-root level loaders), these need their levels incremented by 1 for parity with their sibiling rows // (Collection only increments the level if it is a "item" type node). if (node.type === 'loader') { @@ -885,7 +984,6 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO itemCount++; } - flattenedRows.push(modifiedNode); parentLookup.set(modifiedNode.key, true); } } else if (node.type !== null) { @@ -893,16 +991,15 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO } for (let child of collection.getChildren(node.key)) { - visitNode(child); + visitNode(child, isInSection); } }; for (let node of collection) { - visitNode(node); + visitNode(node, node.type === 'section'); } return { - flattenedRows, keyMap, itemCount }; @@ -1001,3 +1098,65 @@ function RootDropIndicator() { ); } + +export interface GridListSectionProps extends SectionProps, DOMRenderProps<'section', undefined> {} + +/** + * A TreeSection represents a section within a Tree. + */ +export const TreeSection = /*#__PURE__*/ createBranchComponent(SectionNode, (props: GridListSectionProps, ref: ForwardedRef, item: Node) => { + let state = useContext(TreeStateContext)!; + let {CollectionBranch} = useContext(CollectionRendererContext); + let headingRef = useRef(null); + ref = useObjectRef(ref); + let {rowHeaderProps, rowProps, rowGroupProps} = useGridListSection({ + 'aria-label': props['aria-label'] ?? undefined + }, state, ref); + let renderProps = useRenderProps({ + ...props, + id: undefined, + children: undefined, + defaultClassName: 'react-aria-TreeSection', + values: undefined + }); + + let DOMProps = filterDOMProps(props as any, {global: true}); + delete DOMProps.id; + + return ( + + + + + + ); +}); + +export const TreeHeader = (props: GridListHeaderProps): ReactNode => { + return ( + + {props.children} + + ); +}; + +function areSetsEqual(a: Set, b: Set) { + if (a.size !== b.size) { + return false; + } + + for (let item of a) { + if (!b.has(item)) { + return false; + } + } + return true; +} diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 21cec9cb7c4..6070a0d37c1 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -77,7 +77,7 @@ export {ToggleButton, ToggleButtonContext} from './ToggleButton'; export {ToggleButtonGroup, ToggleButtonGroupContext, ToggleGroupStateContext} from './ToggleButtonGroup'; export {Toolbar, ToolbarContext} from './Toolbar'; export {TooltipTrigger, Tooltip, TooltipTriggerStateContext, TooltipContext} from './Tooltip'; -export {TreeLoadMoreItem, Tree, TreeItem, TreeContext, TreeItemContent, TreeStateContext} from './Tree'; +export {TreeLoadMoreItem, Tree, TreeItem, TreeContext, TreeItemContent, TreeHeader, TreeSection, TreeStateContext} from './Tree'; export {useDrag, useDrop} from '@react-aria/dnd'; export {useDragAndDrop} from './useDragAndDrop'; export {DropIndicator, DropIndicatorContext, DragAndDropContext} from './DragAndDrop'; diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index f086ed99b07..fff4cc523c7 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Checkbox, CheckboxProps, Collection, DroppableCollectionReorderEvent, isTextDropItem, Key, ListLayout, Menu, MenuTrigger, Popover, Text, Tree, TreeItem, TreeItemContent, TreeItemProps, TreeProps, useDragAndDrop, Virtualizer} from 'react-aria-components'; +import {Button, Checkbox, CheckboxProps, Collection, DroppableCollectionReorderEvent, isTextDropItem, Key, ListLayout, Menu, MenuTrigger, Popover, Text, Tree, TreeHeader, TreeItem, TreeItemContent, TreeItemProps, TreeProps, TreeSection, useDragAndDrop, Virtualizer} from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {MyMenuItem} from './utils'; @@ -268,6 +268,85 @@ export const TreeExampleStatic: StoryObj = { } }; +const TreeExampleSectionRender = (args) => ( + + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + Reports + + + + Photo Header + Photos + Edited Photos + + + Project Header + + + + Projects-1A + + + + Projects-2 + + + Projects-3 + + + Project-4 + + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + {({isFocused}) => ( + {`${isFocused} Tests`} + )} + + + +); + +export const TreeExampleSection = { + render: TreeExampleSectionRender, + args: { + selectionMode: 'none', + selectionBehavior: 'toggle', + disabledBehavior: 'selection', + disallowClearAll: false + }, + argTypes: { + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + }, + disabledBehavior: { + control: 'radio', + options: ['selection', 'all'] + } + } +}; + export const TreeExampleStaticNoActions: StoryObj = { render: (args) => , args: { @@ -326,6 +405,40 @@ let rows = [ ]} ]; +let rowsWithSections = [ + {id: 'section_1', name: 'Section 1', childItems: [ + {id: 'projects', name: 'Projects', childItems: [ + {id: 'project-1', name: 'Project 1'}, + {id: 'project-2', name: 'Project 2', childItems: [ + {id: 'project-2A', name: 'Project 2A'}, + {id: 'project-2B', name: 'Project 2B'}, + {id: 'project-2C', name: 'Project 2C'} + ]}, + {id: 'project-3', name: 'Project 3'}, + {id: 'project-4', name: 'Project 4'}, + {id: 'project-5', name: 'Project 5', childItems: [ + {id: 'project-5A', name: 'Project 5A'}, + {id: 'project-5B', name: 'Project 5B'}, + {id: 'project-5C', name: 'Project 5C'} + ]} + ]} + ]}, + {id: 'section_2', name: 'Section 2', childItems: [ + {id: 'reports', name: 'Reports', childItems: [ + {id: 'reports-1', name: 'Reports 1', childItems: [ + {id: 'reports-1A', name: 'Reports 1A', childItems: [ + {id: 'reports-1AB', name: 'Reports 1AB', childItems: [ + {id: 'reports-1ABC', name: 'Reports 1ABC'} + ]} + ]}, + {id: 'reports-1B', name: 'Reports 1B'}, + {id: 'reports-1C', name: 'Reports 1C'} + ]}, + {id: 'reports-2', name: 'Reports 2'} + ]} + ]} +]; + const MyTreeLoader = (props) => { let {omitChildren} = props; return ( @@ -442,12 +555,46 @@ const TreeExampleDynamicRender = (args: TreeProps): JSX.Ele ); }; +const TreeSectionExampleDynamicRender = (args: TreeProps): JSX.Element => { + let treeData = useTreeData({ + initialItems: args.items as any ?? rowsWithSections, + getKey: item => item.id, + getChildren: item => item.childItems + }); + + return ( + + + {section => ( + + {section.value.name} + + {item => + ( + {item.value.name} + ) + } + + + )} + + + ); +}; + + export const TreeExampleDynamic: StoryObj = { ...TreeExampleStatic, render: (args) => , parameters: undefined }; +export const TreeSectionDynamic: StoryObj = { + ...TreeExampleStatic, + render: (args) => , + parameters: undefined +}; + export const WithActions: StoryObj = { ...TreeExampleDynamic, args: { @@ -1198,6 +1345,86 @@ export const TreeWithDragAndDropVirtualized = { }; +const VirtualizedTreeExampleSectionRender = (args) => ( + + + + Photo Header + Photos + Edited Photos + + + Project Header + + + + Projects-1A + + + + Projects-2 + + + Projects-3 + + Project-4 + + + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + Reports + + + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + {({isFocused}) => ( + {`${isFocused} Tests`} + )} + + + + +); + +export const VirtualizedTreeSectionRender = { + render: VirtualizedTreeExampleSectionRender, + args: { + selectionMode: 'none', + selectionBehavior: 'toggle', + disabledBehavior: 'selection', + disallowClearAll: false + }, + argTypes: { + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + }, + disabledBehavior: { + control: 'radio', + options: ['selection', 'all'] + } + } +}; interface ITreeItem { id: string, name: string, diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 69636a8b7af..9f5c483ffec 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -12,7 +12,7 @@ import {act, fireEvent, mockClickDefault, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; import {AriaTreeTests} from './AriaTree.test-util'; -import {Button, Checkbox, Collection, DropIndicator, ListLayout, Text, Tree, TreeItem, TreeItemContent, TreeLoadMoreItem, useDragAndDrop, Virtualizer} from '../'; +import {Button, Checkbox, Collection, DropIndicator, ListLayout, Text, Tree, TreeHeader, TreeItem, TreeItemContent, TreeLoadMoreItem, TreeSection, useDragAndDrop, Virtualizer} from '../'; import {composeStories} from '@storybook/react'; // @ts-ignore import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks'; @@ -71,6 +71,30 @@ let StaticTree = ({treeProps = {}, rowProps = {}}) => ( ); +let StaticSectionTree = ({treeProps = {}, rowProps = {}}) => ( + + + Photos + + + Section 2 + + + + Projects-1A + + + + Projects-2 + + + Projects-3 + + + + +); + let rows = [ {id: 'projects', name: 'Projects', childItems: [ {id: 'project-1', name: 'Project 1'}, @@ -101,6 +125,40 @@ let rows = [ ]} ]; +let rowsWithSections = [ + {id: 'section_1', name: 'Section 1', childItems: [ + {id: 'projects', name: 'Projects', childItems: [ + {id: 'project-1', name: 'Project 1'}, + {id: 'project-2', name: 'Project 2', childItems: [ + {id: 'project-2A', name: 'Project 2A'}, + {id: 'project-2B', name: 'Project 2B'}, + {id: 'project-2C', name: 'Project 2C'} + ]}, + {id: 'project-3', name: 'Project 3'}, + {id: 'project-4', name: 'Project 4'}, + {id: 'project-5', name: 'Project 5', childItems: [ + {id: 'project-5A', name: 'Project 5A'}, + {id: 'project-5B', name: 'Project 5B'}, + {id: 'project-5C', name: 'Project 5C'} + ]} + ]} + ]}, + {id: 'section_2', name: 'Section 2', childItems: [ + {id: 'reports', name: 'Reports', childItems: [ + {id: 'reports-1', name: 'Reports 1', childItems: [ + {id: 'reports-1A', name: 'Reports 1A', childItems: [ + {id: 'reports-1AB', name: 'Reports 1AB', childItems: [ + {id: 'reports-1ABC', name: 'Reports 1ABC'} + ]} + ]}, + {id: 'reports-1B', name: 'Reports 1B'}, + {id: 'reports-1C', name: 'Reports 1C'} + ]}, + {id: 'reports-2', name: 'Reports 2'} + ]} + ]} +]; + let DynamicTreeItem = (props) => { return ( @@ -142,6 +200,25 @@ let DynamicTree = ({treeProps = {}, rowProps = {}}) => ( ); +let DynamicSectionTree = ({treeProps = {}, rowProps = {}}) => ( + + + {section => ( + + {section.name} + + {item => ( + + {item.name} + + )} + + + )} + + +); + let DraggableTree = (props) => { let {dragAndDropHooks} = useDragAndDrop({ getItems: (keys) => [...keys].map((key) => ({'text/plain': key})), @@ -1892,6 +1969,133 @@ describe('Tree', () => { expect(onClick).toHaveBeenCalledTimes(1); }); }); + + describe('sections', () => { + it('should support sections', () => { + let {getAllByRole} = render(); + + let groups = getAllByRole('rowgroup'); + expect(groups).toHaveLength(2); + + expect(groups[0]).toHaveClass('react-aria-TreeSection'); + expect(groups[1]).toHaveClass('react-aria-TreeSection'); + + expect(groups[0].getAttribute('aria-label')).toEqual('Section 1'); + + expect(groups[1]).toHaveAttribute('aria-labelledby'); + const labelId = groups[1].getAttribute('aria-labelledby'); + const labelElement = labelId ? document.getElementById(labelId) : null; + expect(labelElement).not.toBeNull(); + expect(labelElement).toHaveTextContent('Section 2'); + }); + }); + + it('should have the expected attributes on the rows in sections', () => { + let {getAllByRole} = render(); + + let rows = getAllByRole('row'); + let rowNoChild = rows[0]; + expect(rowNoChild).toHaveAttribute('aria-label', 'Photos'); + expect(rowNoChild).not.toHaveAttribute('aria-expanded'); + expect(rowNoChild).not.toHaveAttribute('data-expanded'); + expect(rowNoChild).toHaveAttribute('data-level', '1'); + expect(rowNoChild).not.toHaveAttribute('data-has-child-items'); + expect(rowNoChild).toHaveAttribute('data-rac'); + + let header = rows[1]; + expect(header).toHaveClass('react-aria-TreeHeader'); + expect(within(header).getByRole('rowheader')).toHaveTextContent('Section 2'); + + let rowWithChildren = rows[2]; + // Row has action since it is expandable but not selectable. + expect(rowWithChildren).toHaveAttribute('aria-label', 'Projects'); + expect(rowWithChildren).toHaveAttribute('data-expanded', 'true'); + expect(rowWithChildren).toHaveAttribute('data-level', '1'); + expect(rowWithChildren).toHaveAttribute('data-has-child-items', 'true'); + expect(rowWithChildren).toHaveAttribute('data-rac'); + + let level2ChildRow = rows[3]; + expect(level2ChildRow).toHaveAttribute('aria-label', 'Projects-1'); + expect(level2ChildRow).toHaveAttribute('data-expanded', 'true'); + expect(level2ChildRow).toHaveAttribute('data-level', '2'); + expect(level2ChildRow).toHaveAttribute('data-has-child-items', 'true'); + expect(level2ChildRow).toHaveAttribute('data-rac'); + + let level3ChildRow = rows[4]; + expect(level3ChildRow).toHaveAttribute('aria-label', 'Projects-1A'); + expect(level3ChildRow).not.toHaveAttribute('data-expanded'); + expect(level3ChildRow).toHaveAttribute('data-level', '3'); + expect(level3ChildRow).not.toHaveAttribute('data-has-child-items'); + expect(level3ChildRow).toHaveAttribute('data-rac'); + + let level2ChildRow2 = rows[5]; + expect(level2ChildRow2).toHaveAttribute('aria-label', 'Projects-2'); + expect(level2ChildRow2).not.toHaveAttribute('data-expanded'); + expect(level2ChildRow2).toHaveAttribute('data-level', '2'); + expect(level2ChildRow2).not.toHaveAttribute('data-has-child-items'); + expect(level2ChildRow2).toHaveAttribute('data-rac'); + + let level2ChildRow3 = rows[6]; + expect(level2ChildRow3).toHaveAttribute('aria-label', 'Projects-3'); + expect(level2ChildRow3).not.toHaveAttribute('data-expanded'); + expect(level2ChildRow3).toHaveAttribute('data-level', '2'); + expect(level2ChildRow3).not.toHaveAttribute('data-has-child-items'); + expect(level2ChildRow3).toHaveAttribute('data-rac'); + }); + + it('should support dynamic trees with sections', () => { + let {getByRole, getAllByRole} = render(); + let tree = getByRole('treegrid'); + expect(tree).toHaveAttribute('class', 'react-aria-Tree'); + + let rows = getAllByRole('row'); + expect(rows).toHaveLength(22); + + + let header = rows[0]; + expect(header).toHaveClass('react-aria-TreeHeader'); + expect(within(header).getByRole('rowheader')).toHaveTextContent('Section 1'); + + // Check the rough structure to make sure dynamic rows are rendering as expected (just checks the expandable rows and their attributes) + expect(rows[1]).toHaveAttribute('aria-label', 'Projects'); + expect(rows[1]).toHaveAttribute('aria-expanded', 'true'); + expect(rows[1]).toHaveAttribute('aria-level', '1'); + expect(rows[1]).toHaveAttribute('aria-posinset', '1'); // aria-posinset value is relative to their section + expect(rows[1]).toHaveAttribute('aria-setsize', '1'); // aria-setsize value is relative to their section + expect(rows[1]).toHaveAttribute('data-has-child-items', 'true'); + + expect(rows[3]).toHaveAttribute('aria-label', 'Project 2'); + expect(rows[3]).toHaveAttribute('aria-expanded', 'true'); + expect(rows[3]).toHaveAttribute('aria-level', '2'); + expect(rows[3]).toHaveAttribute('aria-posinset', '2'); + expect(rows[3]).toHaveAttribute('aria-setsize', '5'); + expect(rows[3]).toHaveAttribute('data-has-child-items', 'true'); + + expect(rows[9]).toHaveAttribute('aria-label', 'Project 5'); + expect(rows[9]).toHaveAttribute('aria-expanded', 'true'); + expect(rows[9]).toHaveAttribute('aria-level', '2'); + expect(rows[9]).toHaveAttribute('aria-posinset', '5'); + expect(rows[9]).toHaveAttribute('aria-setsize', '5'); + expect(rows[9]).toHaveAttribute('data-has-child-items', 'true'); + + header = rows[13]; + expect(header).toHaveClass('react-aria-TreeHeader'); + expect(within(header).getByRole('rowheader')).toHaveTextContent('Section 2'); + + expect(rows[14]).toHaveAttribute('aria-label', 'Reports'); + expect(rows[14]).toHaveAttribute('aria-expanded', 'true'); + expect(rows[14]).toHaveAttribute('aria-level', '1'); + expect(rows[14]).toHaveAttribute('aria-posinset', '1'); + expect(rows[14]).toHaveAttribute('aria-setsize', '1'); + expect(rows[14]).toHaveAttribute('data-has-child-items', 'true'); + + expect(rows[18]).toHaveAttribute('aria-label', 'Reports 1ABC'); + expect(rows[18]).toHaveAttribute('aria-level', '5'); + expect(rows[18]).toHaveAttribute('aria-posinset', '1'); + expect(rows[18]).toHaveAttribute('aria-setsize', '1'); + }); + + }); AriaTreeTests({