From 473d209ef81a16e6c52534c005722ccecb32fda3 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:08:32 -0700 Subject: [PATCH 01/20] initial tree stuff --- .../collections/src/useCachedChildren.ts | 6 +- .../gridlist/src/useGridListItem.ts | 3 +- .../@react-stately/layout/src/ListLayout.ts | 48 +++ .../react-aria-components/src/Collection.tsx | 2 + .../react-aria-components/src/GridList.tsx | 2 +- packages/react-aria-components/src/Tree.tsx | 177 ++++++++++- .../react-aria-components/src/Virtualizer.tsx | 5 + packages/react-aria-components/src/index.ts | 4 +- .../stories/Tree.stories.tsx | 287 +++++++++++++++++- 9 files changed, 517 insertions(+), 17 deletions(-) diff --git a/packages/@react-aria/collections/src/useCachedChildren.ts b/packages/@react-aria/collections/src/useCachedChildren.ts index 1ce6c9143be..421dfe755cf 100644 --- a/packages/@react-aria/collections/src/useCachedChildren.ts +++ b/packages/@react-aria/collections/src/useCachedChildren.ts @@ -39,17 +39,19 @@ export function useCachedChildren(props: CachedChildrenOptions return useMemo(() => { if (items && typeof children === 'function') { let res: ReactElement[] = []; + // console.log('blah', [...items]) for (let item of items) { + // console.log(item); let rendered = cache.get(item); if (!rendered) { 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) { key = idScope + ':' + key; } diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 2ecfc4f8125..cbc844229bf 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -101,10 +101,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) { + // console.log('yep', node, node.level) 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 = state.collection.getSiblings?.(parent.key)!; setSize = [...siblings].filter(row => row.type === 'item').length; } } else { diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 6dc8da975bc..094290f1fbf 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -140,7 +140,9 @@ export class ListLayout exte if (this.isVisible(node, rect)) { res.push(node.layoutInfo); + console.log('dog', node); if (node.children) { + console.log('puppy', node.children) addNodes(node.children); } } @@ -252,8 +254,10 @@ export class ListLayout exte } protected buildCollection(y: number = this.padding): LayoutNode[] { + console.log('buildCollection'); let collection = this.virtualizer!.collection; let collectionNodes = [...collection]; + console.log('bull', collectionNodes); let loaderNodes = collectionNodes.filter(node => node.type === 'loader'); let nodes: LayoutNode[] = []; let isEmptyOrLoading = collection?.size === 0; @@ -301,11 +305,16 @@ export class ListLayout exte y -= this.gap; y += isEmptyOrLoading ? 0 : this.padding; this.contentSize = new Size(this.virtualizer!.visibleRect.width, y); + console.log('cat', nodes); return nodes; } protected isValid(node: Node, y: number): boolean { let cached = this.layoutNodes.get(node.key); + + + // console.log('haha', [...this.virtualizer?.collection.getChildren(node.key)]); + // console.log('hehe', [...this.lastCollection?.getChildren(node.key)]) return ( !this.invalidateEverything && !!cached && @@ -318,6 +327,7 @@ export class ListLayout exte protected buildChild(node: Node, x: number, y: number, parentKey: Key | null): LayoutNode { if (this.isValid(node, y)) { + console.log('isValid'); return this.layoutNodes.get(node.key)!; } @@ -361,6 +371,7 @@ export class ListLayout exte } protected buildSection(node: Node, x: number, y: number): LayoutNode { + console.log('buildSection'); let collection = this.virtualizer!.collection; let width = this.virtualizer!.visibleRect.width - this.padding; let rect = new Rect(x, y, width - x, 0); @@ -393,6 +404,8 @@ export class ListLayout exte y -= this.gap; rect.height = y - startY; + console.log('crow', children); + return { layoutInfo, children, @@ -440,9 +453,12 @@ export class ListLayout exte } protected buildItem(node: Node, x: number, y: number): LayoutNode { + console.log('buildItem'); + // let collection = this.virtualizer!.collection; let width = this.virtualizer!.visibleRect.width - this.padding - x; let rectHeight = this.rowHeight; let isEstimated = false; + // let skipped = 0; // If no explicit height is available, use an estimated height. if (rectHeight == null) { @@ -466,6 +482,38 @@ export class ListLayout exte let rect = new Rect(x, y, width, rectHeight); let layoutInfo = new LayoutInfo(node.type, node.key, rect); layoutInfo.estimatedSize = isEstimated; + + + // let children: LayoutNode[] = []; + // for (let child of getChildNodes(node, collection)) { + // console.log('frog', child); + // 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. + // if (y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { + // y += rowHeight; + // skipped++; + // continue; + // } + + // let layoutNode = this.buildChild(child, x, y, layoutInfo.key); + // y = layoutNode.layoutInfo.rect.maxY + this.gap; + // console.log('tadpole', layoutNode); + // children.push(layoutNode); + + // // if (y > this.requestedRect.maxY) { + // // // Estimate the remaining height for rows that we don't need to layout right now. + // // y += ([...getChildNodes(node, collection)].length - (children.length + skipped)) * rowHeight; + // // break; + // // } + // } + + // console.log('chicken', children); + return { layoutInfo, children: [], diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index ed1bc25174b..c3d20692e6f 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -155,6 +155,8 @@ function useCollectionRender( parent: Node | null, renderDropIndicator?: (target: ItemDropTarget) => ReactNode ) { + console.log('collection', collection); + // console.log('parent', parent ? collection.getChildren!(parent.key) : null); return useCachedChildren({ items: parent ? collection.getChildren!(parent.key) : collection, dependencies: [renderDropIndicator], diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index d3a87620b09..0d455449654 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -615,7 +615,7 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, ); }); -const GridListHeaderContext = createContext | null>(null); +export const GridListHeaderContext = createContext | null>(null); export const GridListHeader = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, HeaderContext); diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 6073eaa05d3..071bacd96a5 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -10,25 +10,31 @@ * 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 {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, SectionContext, SectionProps} from './Collection'; import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils'; 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 React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {TreeDropTargetDelegate} from './TreeDropTargetDelegate'; import {useControlledState} from '@react-stately/utils'; +import {HeaderContext} from './Header'; +import {GridListHeader, GridListHeaderContext} from './GridList' +import NoImage from '../../@react-spectrum/s2/spectrum-illustrations/linear/NoImage'; class TreeCollection implements ICollection> { private flattenedRows: Node[]; private keyMap: Map> = new Map(); private itemCount: number = 0; + private firstKey; + private lastKey; + private expandedKeys; constructor(opts) { let {collection, expandedKeys} = opts; @@ -37,14 +43,53 @@ class TreeCollection implements ICollection> { // 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) this.keyMap = keyMap; this.itemCount = itemCount; + this.firstKey = [...this.keyMap.keys()][0]; + this.lastKey = [...this.keyMap.keys()][-1]; + this.expandedKeys = expandedKeys; } // 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](): IterableIterator> { + // let node: Node | undefined = this.firstKey != null ? this.keyMap.get(this.firstKey) : undefined; + // while (node) { + // // console.log('grr', node); + // yield node; + // node = node.nextKey != null ? this.keyMap.get(node.nextKey) : undefined; + // } + // } + *[Symbol.iterator]() { - yield* this.flattenedRows; + function* traverseDepthFirst(node: CollectionNode | null, expandedKeys: Set) { + if (!node) return; + + // Always yield the current node first + yield node.clone(); + + // If node is expanded, traverse its children + if (expandedKeys.has(node.key) && node.firstChildKey) { + let firstChild = keyMap.get(node.firstChildKey); + yield* traverseDepthFirst(keyMap.get(firstChild.nextKey), expandedKeys); + // yield* traverseDepthFirst(keyMap.get(node.firstChildKey), expandedKeys); + } + + // Then traverse to next sibling + if (node.nextKey) { + yield* traverseDepthFirst(keyMap.get(node.nextKey), expandedKeys); + } + } + + let keyMap = this.keyMap; + let expandedKeys = this.expandedKeys; + console.log('keys', expandedKeys); + let node: Node | undefined = this.firstKey != null ? this.keyMap.get(this.firstKey) : undefined; + yield* traverseDepthFirst(node, expandedKeys); } + // *[Symbol.iterator]() { + // yield* this.flattenedRows; + // } + get size() { return this.itemCount; } @@ -70,28 +115,82 @@ class TreeCollection implements ICollection> { } getKeyAfter(key: Key) { + // i wonder if i can keep using flattened rows here because for the keyboard nav bc nothing should really change since header + sections are not keyboard navigable? let index = this.flattenedRows.findIndex(row => row.key === key); return this.flattenedRows[index + 1]?.key; + // let node = this.keyMap.get(key); + // return node && node.nextKey != null ? this.keyMap.get(node.nextKey) : undefined; } getKeyBefore(key: Key) { let index = this.flattenedRows.findIndex(row => row.key === key); return this.flattenedRows[index - 1]?.key; + // let node = this.keyMap.get(key); + // return node && node.nextKey != null ? this.keyMap.get(node.prevKey) : undefined; } // Note that this will return Content nodes in addition to nested TreeItems getChildren(key: Key): Iterable> { + let keyMap = this.keyMap; + let expandedKeys = this.expandedKeys; + return { + // *[Symbol.iterator]() { + // let parent = keyMap.get(key); + // let node = parent?.firstChildKey != null ? keyMap.get(parent.firstChildKey) : null; + // while (node) { + // // console.log('keys', node.key); + // yield node as Node; + // node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; + // } + // } + *[Symbol.iterator]() { + function* traverseDepthFirst(node: CollectionNode | null, expandedKeys: Set) { + if (!node) return; + + // Always yield the current node first + yield node; + + // If node is expanded, traverse its children + if (expandedKeys.has(node.key) && node.firstChildKey) { + let firstChild = keyMap.get(node.firstChildKey); + yield* traverseDepthFirst(keyMap.get(firstChild.nextKey), expandedKeys); + // yield* traverseDepthFirst(keyMap.get(node.firstChildKey), expandedKeys); + } + + // Then traverse to next sibling + if (node.nextKey) { + yield* traverseDepthFirst(keyMap.get(node.nextKey), expandedKeys); + } + } + + let parent = keyMap.get(key); + let node = parent?.firstChildKey ? keyMap.get(parent.firstChildKey) : null; + if (parent.type === 'section') { + yield* traverseDepthFirst(node, expandedKeys); + } else { + while (node) { + // console.log('keys', node.key); + yield node as Node; + node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; + } + } + } + }; + } + + getSiblings(key: Key): Iterable> { let keyMap = this.keyMap; return { *[Symbol.iterator]() { let parent = keyMap.get(key); let node = parent?.firstChildKey != null ? keyMap.get(parent.firstChildKey) : null; while (node) { + // console.log('keys', node.key); yield node as Node; node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; } } - }; + } } getTextValue(key: Key): string { @@ -188,6 +287,7 @@ interface TreeInnerProps { } function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { + console.log('TreeInner', collection); const {dragAndDropHooks} = props; let {direction} = useLocale(); let collator = useCollator({usage: 'search', sensitivity: 'base'}); @@ -225,6 +325,8 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne return new TreeCollection({collection, expandedKeys}); }, [collection, expandedKeys]); + console.log('flattenedCollection', flattenedCollection); + let state = useTreeState({ ...props, selectionMode, @@ -598,6 +700,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, { @@ -819,7 +922,7 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO // 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 visitNode = (node: Node) => { + let visitNode = (node: Node, isInSection: boolean) => { if (node.type === 'item' || node.type === 'loader') { let parentKey = node?.parentKey; let clone = {...node}; @@ -831,6 +934,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') { @@ -856,14 +965,16 @@ 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'); } + // console.log('flattenedRows', flattenedRows); + return { flattenedRows, keyMap, @@ -964,3 +1075,51 @@ function RootDropIndicator() { ); } + +export interface GridListSectionProps extends SectionProps {} + +/** + * 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({ + defaultClassName: 'react-aria-TreeSection', + className: props.className, + style: props.style, + values: {} + }); + + let DOMProps = filterDOMProps(props as any, {global: true}); + delete DOMProps.id; + + return ( +
+ + + +
+ ); +}); + +export const TreeHeader = (props: HTMLAttributes): ReactNode => { + return ( + + {props.children} + + ) +} diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 1a38e8adbf1..f8af7572bf3 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -73,6 +73,7 @@ export function Virtualizer(props: VirtualizerProps): JSX.Element { function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicator}: CollectionRootProps) { let {layout, layoutOptions} = useContext(LayoutContext)!; let layoutOptions2 = layout.useLayoutOptions?.(); + console.log('cow', collection); let state = useVirtualizerState({ layout, collection, @@ -95,6 +96,8 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat }, [layoutOptions, layoutOptions2]) }); + console.log('meow', state.visibleViews); + let {contentProps} = useScrollView({ onVisibleRectChange: state.setVisibleRect, contentSize: state.contentSize, @@ -114,10 +117,12 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat function CollectionBranch({parent, renderDropIndicator}: CollectionBranchProps) { let virtualizer = useContext(VirtualizerContext); let parentView = virtualizer!.virtualizer.getVisibleView(parent.key)!; + console.log('bark', parentView.children); return renderChildren(parentView, Array.from(parentView.children), renderDropIndicator); } function renderChildren(parent: View | null, children: View[], renderDropIndicator?: (target: ItemDropTarget) => ReactNode) { + console.log('moo', children) return children.map(view => renderWrapper(parent, view, renderDropIndicator)); } diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 36cf3e61b43..b9e5ddb8241 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -39,7 +39,7 @@ export {DropZone, DropZoneContext} from './DropZone'; export {FieldError, FieldErrorContext} from './FieldError'; export {FileTrigger} from './FileTrigger'; export {Form, FormContext} from './Form'; -export {GridListLoadMoreItem, GridList, GridListItem, GridListContext, GridListHeader, GridListSection} from './GridList'; +export {GridListLoadMoreItem, GridList, GridListItem, GridListContext, GridListHeader, GridListHeaderContext, GridListSection} from './GridList'; export {Group, GroupContext} from './Group'; export {Header, HeaderContext} from './Header'; export {Heading} from './Heading'; @@ -75,7 +75,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 b21afa9adeb..bc072cc6e1b 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, TreeSection, TreeProps, 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) => ( + + + 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 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,39 @@ let rows = [ ]} ]; +let rowWithSections = [ + {id: 'section_1', name: 'Section 1', children: + [{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', children: + [{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 ( @@ -422,6 +534,67 @@ const DynamicTreeItem = (props: DynamicTreeItemProps) => { ); }; +const DynamicTreeItemSection = (props: DynamicTreeItemProps) => { + let {childItems, renderLoader, supportsDragging} = props; + + return ( + <> + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered, + 'drop-target': isDropTarget + })}> + + {({isExpanded, hasChildItems, level, selectionBehavior, selectionMode}) => ( + <> + {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( + + )} +
+ {hasChildItems && ( + + )} + {supportsDragging && } + {props.children} + + + + + + Foo + Bar + Baz + + + +
+ + )} +
+ + {(item: any) => ( + + {item.name} + + )} + + {renderLoader?.(props.id) && } +
+ {props.isLastInRoot && } + + ); +}; + let defaultExpandedKeys = new Set(['projects', 'project-2', 'project-5', 'reports', 'reports-1', 'reports-1A', 'reports-1AB']); const TreeExampleDynamicRender = (args: TreeProps): JSX.Element => { @@ -432,7 +605,7 @@ const TreeExampleDynamicRender = (args: TreeProps): JSX.Ele }); return ( - + {(item) => ( {item.value.name} @@ -442,12 +615,40 @@ const TreeExampleDynamicRender = (args: TreeProps): JSX.Ele ); }; +const TreeSectionExampleDynamicRender = (args: TreeProps): JSX.Element => { + return ( + + + {section => ( + + {section.name} + + {item => + + {item.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: { @@ -1196,3 +1397,85 @@ export const TreeWithDragAndDropVirtualized = { render: TreeDragAndDropVirtualizedRender, name: 'Tree with drag and drop (virtualized)' }; + + +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'] + } + }, +} From e896397a0d68c783b2236aa454b4705311ad783f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 6 Oct 2025 13:40:59 -0700 Subject: [PATCH 02/20] fix virtualized tree section expanding, update methods to remove flattenTree --- .../react-aria-components/src/Collection.tsx | 3 +- packages/react-aria-components/src/Tree.tsx | 188 ++++++++++++++---- .../stories/Tree.stories.tsx | 28 +-- 3 files changed, 161 insertions(+), 58 deletions(-) diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index c3d20692e6f..67fecedf666 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -155,8 +155,7 @@ function useCollectionRender( parent: Node | null, renderDropIndicator?: (target: ItemDropTarget) => ReactNode ) { - console.log('collection', collection); - // console.log('parent', parent ? collection.getChildren!(parent.key) : null); + // console.log('collection', collection); return useCachedChildren({ items: parent ? collection.getChildren!(parent.key) : collection, dependencies: [renderDropIndicator], diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 071bacd96a5..43cb6aaeb6a 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -26,7 +26,6 @@ import {TreeDropTargetDelegate} from './TreeDropTargetDelegate'; import {useControlledState} from '@react-stately/utils'; import {HeaderContext} from './Header'; import {GridListHeader, GridListHeaderContext} from './GridList' -import NoImage from '../../@react-spectrum/s2/spectrum-illustrations/linear/NoImage'; class TreeCollection implements ICollection> { private flattenedRows: Node[]; @@ -37,15 +36,51 @@ class TreeCollection implements ICollection> { private expandedKeys; constructor(opts) { - let {collection, expandedKeys} = opts; + let {collection, lastExpandedKeys, expandedKeys} = opts; let {flattenedRows, keyMap, itemCount} = flattenTree(collection, {expandedKeys}); this.flattenedRows = flattenedRows; // 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) this.keyMap = keyMap; this.itemCount = itemCount; this.firstKey = [...this.keyMap.keys()][0]; - this.lastKey = [...this.keyMap.keys()][-1]; + this.lastKey = [...this.keyMap.keys()][this.keyMap.size - 1]; this.expandedKeys = expandedKeys; + + // console.log(lastExpandedKeys, expandedKeys); + + // diff lastExpandedKeys and expandedKeys + for (let key of expandedKeys) { + if (!lastExpandedKeys.has(key)) { + // traverse upward until you hit a section, and clone it + let currentKey = key; + while (currentKey != null) { + let item = this.getItem(currentKey); + if (item?.type === 'section') { + // replace the item with a clone + this.keyMap.set(currentKey, item.clone()); + break; + } else { + currentKey = item?.parentKey; + } + } + } + } + + for (let key of lastExpandedKeys) { + if (!expandedKeys.has(key)) { + let currentKey = key; + while (currentKey != null) { + let item = this.getItem(currentKey); + if (item?.type === 'section') { + // replace the item with a clone + this.keyMap.set(currentKey, item.clone()); + break; + } else { + currentKey = item?.parentKey; + } + } + } + } } // TODO: should this collection's getters reflect the flattened structure or the original structure @@ -64,7 +99,7 @@ class TreeCollection implements ICollection> { if (!node) return; // Always yield the current node first - yield node.clone(); + yield node; // If node is expanded, traverse its children if (expandedKeys.has(node.key) && node.firstChildKey) { @@ -81,7 +116,7 @@ class TreeCollection implements ICollection> { let keyMap = this.keyMap; let expandedKeys = this.expandedKeys; - console.log('keys', expandedKeys); + // console.log('keys', expandedKeys); let node: Node | undefined = this.firstKey != null ? this.keyMap.get(this.firstKey) : undefined; yield* traverseDepthFirst(node, expandedKeys); } @@ -103,30 +138,104 @@ class TreeCollection implements ICollection> { } at(idx: number) { + // not sure how we would do this without flattenedRows since its parameter is an index? return this.flattenedRows[idx]; } getFirstKey() { - return this.flattenedRows[0]?.key; + let node = this.keyMap.get(this.firstKey); + if (!node) { + return null; + } + + // Skip over any nodes that aren't an item node (e.g. section or header node) + while (node) { + if (node.type !== 'item' && node.firstChildKey) { + node = this.keyMap.get(node.firstChildKey); + } else { + break; + } + } + + return node.key + // return this.firstKey + // return this.flattenedRows[0]?.key; + } getLastKey() { - return this.flattenedRows[this.flattenedRows.length - 1]?.key; + let node = this.lastKey != null ? this.keyMap.get(this.lastKey) : null; + + if (!node) { + return null; + } + + // If the node's parent is expanded, then we can assume that this is the actual last key + if (node.parentKey && this.expandedKeys.has(node.parentKey)) { + return node; + } + + // 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.keyMap.get(node.parentKey) : null; + while (parentNode && parentNode.type !== 'section' && !this.expandedKeys.has(parentNode.key)) { + node = this.keyMap.get(node.parentKey); + parentNode = node.parentKey ? this.keyMap.get(node.parentKey) : null; + } + + + return node?.key ?? null; + // return this.flattenedRows[this.flattenedRows.length - 1]?.key; + } getKeyAfter(key: Key) { - // i wonder if i can keep using flattened rows here because for the keyboard nav bc nothing should really change since header + sections are not keyboard navigable? - let index = this.flattenedRows.findIndex(row => row.key === key); - return this.flattenedRows[index + 1]?.key; - // let node = this.keyMap.get(key); - // return node && node.nextKey != null ? this.keyMap.get(node.nextKey) : undefined; + let node = this.keyMap.get(key); + 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.keyMap.get(node.parentKey); + } else { + return null; + } + } + + return null; + // let index = this.flattenedRows.findIndex(row => row.key === key); + // return this.flattenedRows[index + 1]?.key; } getKeyBefore(key: Key) { - let index = this.flattenedRows.findIndex(row => row.key === key); - return this.flattenedRows[index - 1]?.key; - // let node = this.keyMap.get(key); - // return node && node.nextKey != null ? this.keyMap.get(node.prevKey) : undefined; + let node = this.keyMap.get(key); + if (!node) { + return null; + } + + if (node.prevKey != null) { + node = this.keyMap.get(node.prevKey); + + while (node && node.type !== 'item' && node.lastChildKey != null) { + node = this.keyMap.get(node.lastChildKey); + } + + while (node && this.expandedKeys.has(node.key) && node.lastChildKey != null) { + node = this.keyMap.get(node.lastChildKey) + } + + return node?.key ?? null; + } + + return node.parentKey; } // Note that this will return Content nodes in addition to nested TreeItems @@ -134,15 +243,6 @@ class TreeCollection implements ICollection> { let keyMap = this.keyMap; let expandedKeys = this.expandedKeys; return { - // *[Symbol.iterator]() { - // let parent = keyMap.get(key); - // let node = parent?.firstChildKey != null ? keyMap.get(parent.firstChildKey) : null; - // while (node) { - // // console.log('keys', node.key); - // yield node as Node; - // node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; - // } - // } *[Symbol.iterator]() { function* traverseDepthFirst(node: CollectionNode | null, expandedKeys: Set) { if (!node) return; @@ -154,7 +254,6 @@ class TreeCollection implements ICollection> { if (expandedKeys.has(node.key) && node.firstChildKey) { let firstChild = keyMap.get(node.firstChildKey); yield* traverseDepthFirst(keyMap.get(firstChild.nextKey), expandedKeys); - // yield* traverseDepthFirst(keyMap.get(node.firstChildKey), expandedKeys); } // Then traverse to next sibling @@ -165,19 +264,20 @@ class TreeCollection implements ICollection> { let parent = keyMap.get(key); let node = parent?.firstChildKey ? keyMap.get(parent.firstChildKey) : null; - if (parent.type === 'section') { - yield* traverseDepthFirst(node, expandedKeys); - } else { - while (node) { - // console.log('keys', node.key); - yield node as Node; - node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; - } - } + // if (parent.type === 'section') { + // yield* traverseDepthFirst(node, expandedKeys); + // } else { + // while (node) { + // yield node as Node; + // node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; + // } + // } + yield* traverseDepthFirst(node, expandedKeys); } }; } + // We need a method to get a flattened list of all children at the same level (so no diving into a child if it is an expanded node) getSiblings(key: Key): Iterable> { let keyMap = this.keyMap; return { @@ -185,7 +285,6 @@ class TreeCollection implements ICollection> { let parent = keyMap.get(key); let node = parent?.firstChildKey != null ? keyMap.get(parent.firstChildKey) : null; while (node) { - // console.log('keys', node.key); yield node as Node; node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; } @@ -287,7 +386,7 @@ interface TreeInnerProps { } function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { - console.log('TreeInner', collection); + // console.log('TreeInner', collection); const {dragAndDropHooks} = props; let {direction} = useLocale(); let collator = useCollator({usage: 'search', sensitivity: 'base'}); @@ -321,11 +420,16 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne 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: lastExpandedKeys, expandedKeys})); + - console.log('flattenedCollection', flattenedCollection); + if (expandedKeys.size !== lastExpandedKeys.size || collection !== lastCollection){ + setFlattenedCollection(new TreeCollection({collection, lastExpandedKeys, expandedKeys})); + setLastCollection(collection); + setLastExpandedKeys(expandedKeys); + } let state = useTreeState({ ...props, @@ -700,7 +804,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, { diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index bc072cc6e1b..95224a9d4a8 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -270,6 +270,19 @@ export const TreeExampleStatic: StoryObj = { const TreeExampleSectionRender = (args) => ( + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + Reports + + Photo Header Photos @@ -292,19 +305,6 @@ const TreeExampleSectionRender = (args) => ( Project-4 - classNames(styles, 'tree-item', { - focused: isFocused, - 'focus-visible': isFocusVisible, - selected: isSelected, - hovered: isHovered - })}> - - Reports - - (args: TreeProps): JSX.Ele }); return ( - + {(item) => ( {item.value.name} From c3385c171adfac470bbc8d4126b25d2ec572d208 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:16:16 -0700 Subject: [PATCH 03/20] cleanup --- .../collections/src/useCachedChildren.ts | 2 - .../gridlist/src/useGridListItem.ts | 1 - .../@react-stately/layout/src/ListLayout.ts | 45 ------------------- .../react-aria-components/src/Virtualizer.tsx | 5 --- 4 files changed, 53 deletions(-) diff --git a/packages/@react-aria/collections/src/useCachedChildren.ts b/packages/@react-aria/collections/src/useCachedChildren.ts index 421dfe755cf..770e8aaf289 100644 --- a/packages/@react-aria/collections/src/useCachedChildren.ts +++ b/packages/@react-aria/collections/src/useCachedChildren.ts @@ -39,9 +39,7 @@ export function useCachedChildren(props: CachedChildrenOptions return useMemo(() => { if (items && typeof children === 'function') { let res: ReactElement[] = []; - // console.log('blah', [...items]) for (let item of items) { - // console.log(item); let rendered = cache.get(item); if (!rendered) { rendered = children(item); diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index cbc844229bf..fd832525ea7 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -101,7 +101,6 @@ 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) { - // console.log('yep', node, node.level) let parent = state.collection.getItem(node.parentKey); if (parent) { // siblings must exist because our original node exists diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 094290f1fbf..65e442969d0 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -140,9 +140,7 @@ export class ListLayout exte if (this.isVisible(node, rect)) { res.push(node.layoutInfo); - console.log('dog', node); if (node.children) { - console.log('puppy', node.children) addNodes(node.children); } } @@ -254,10 +252,8 @@ export class ListLayout exte } protected buildCollection(y: number = this.padding): LayoutNode[] { - console.log('buildCollection'); let collection = this.virtualizer!.collection; let collectionNodes = [...collection]; - console.log('bull', collectionNodes); let loaderNodes = collectionNodes.filter(node => node.type === 'loader'); let nodes: LayoutNode[] = []; let isEmptyOrLoading = collection?.size === 0; @@ -305,16 +301,11 @@ export class ListLayout exte y -= this.gap; y += isEmptyOrLoading ? 0 : this.padding; this.contentSize = new Size(this.virtualizer!.visibleRect.width, y); - console.log('cat', nodes); return nodes; } protected isValid(node: Node, y: number): boolean { let cached = this.layoutNodes.get(node.key); - - - // console.log('haha', [...this.virtualizer?.collection.getChildren(node.key)]); - // console.log('hehe', [...this.lastCollection?.getChildren(node.key)]) return ( !this.invalidateEverything && !!cached && @@ -327,7 +318,6 @@ export class ListLayout exte protected buildChild(node: Node, x: number, y: number, parentKey: Key | null): LayoutNode { if (this.isValid(node, y)) { - console.log('isValid'); return this.layoutNodes.get(node.key)!; } @@ -371,7 +361,6 @@ export class ListLayout exte } protected buildSection(node: Node, x: number, y: number): LayoutNode { - console.log('buildSection'); let collection = this.virtualizer!.collection; let width = this.virtualizer!.visibleRect.width - this.padding; let rect = new Rect(x, y, width - x, 0); @@ -404,8 +393,6 @@ export class ListLayout exte y -= this.gap; rect.height = y - startY; - console.log('crow', children); - return { layoutInfo, children, @@ -453,7 +440,6 @@ export class ListLayout exte } protected buildItem(node: Node, x: number, y: number): LayoutNode { - console.log('buildItem'); // let collection = this.virtualizer!.collection; let width = this.virtualizer!.visibleRect.width - this.padding - x; let rectHeight = this.rowHeight; @@ -483,37 +469,6 @@ export class ListLayout exte let layoutInfo = new LayoutInfo(node.type, node.key, rect); layoutInfo.estimatedSize = isEstimated; - - // let children: LayoutNode[] = []; - // for (let child of getChildNodes(node, collection)) { - // console.log('frog', child); - // 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. - // if (y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) { - // y += rowHeight; - // skipped++; - // continue; - // } - - // let layoutNode = this.buildChild(child, x, y, layoutInfo.key); - // y = layoutNode.layoutInfo.rect.maxY + this.gap; - // console.log('tadpole', layoutNode); - // children.push(layoutNode); - - // // if (y > this.requestedRect.maxY) { - // // // Estimate the remaining height for rows that we don't need to layout right now. - // // y += ([...getChildNodes(node, collection)].length - (children.length + skipped)) * rowHeight; - // // break; - // // } - // } - - // console.log('chicken', children); - return { layoutInfo, children: [], diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index f8af7572bf3..1a38e8adbf1 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -73,7 +73,6 @@ export function Virtualizer(props: VirtualizerProps): JSX.Element { function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicator}: CollectionRootProps) { let {layout, layoutOptions} = useContext(LayoutContext)!; let layoutOptions2 = layout.useLayoutOptions?.(); - console.log('cow', collection); let state = useVirtualizerState({ layout, collection, @@ -96,8 +95,6 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat }, [layoutOptions, layoutOptions2]) }); - console.log('meow', state.visibleViews); - let {contentProps} = useScrollView({ onVisibleRectChange: state.setVisibleRect, contentSize: state.contentSize, @@ -117,12 +114,10 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat function CollectionBranch({parent, renderDropIndicator}: CollectionBranchProps) { let virtualizer = useContext(VirtualizerContext); let parentView = virtualizer!.virtualizer.getVisibleView(parent.key)!; - console.log('bark', parentView.children); return renderChildren(parentView, Array.from(parentView.children), renderDropIndicator); } function renderChildren(parent: View | null, children: View[], renderDropIndicator?: (target: ItemDropTarget) => ReactNode) { - console.log('moo', children) return children.map(view => renderWrapper(parent, view, renderDropIndicator)); } From fb17656c10d4e88a1384a8e7363f208b5b8b8464 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:17:27 -0700 Subject: [PATCH 04/20] more cleanup --- packages/@react-stately/layout/src/ListLayout.ts | 3 --- packages/react-aria-components/src/Collection.tsx | 1 - 2 files changed, 4 deletions(-) diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 65e442969d0..6dc8da975bc 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -440,11 +440,9 @@ export class ListLayout exte } protected buildItem(node: Node, x: number, y: number): LayoutNode { - // let collection = this.virtualizer!.collection; let width = this.virtualizer!.visibleRect.width - this.padding - x; let rectHeight = this.rowHeight; let isEstimated = false; - // let skipped = 0; // If no explicit height is available, use an estimated height. if (rectHeight == null) { @@ -468,7 +466,6 @@ export class ListLayout exte let rect = new Rect(x, y, width, rectHeight); let layoutInfo = new LayoutInfo(node.type, node.key, rect); layoutInfo.estimatedSize = isEstimated; - return { layoutInfo, children: [], diff --git a/packages/react-aria-components/src/Collection.tsx b/packages/react-aria-components/src/Collection.tsx index 67fecedf666..ed1bc25174b 100644 --- a/packages/react-aria-components/src/Collection.tsx +++ b/packages/react-aria-components/src/Collection.tsx @@ -155,7 +155,6 @@ function useCollectionRender( parent: Node | null, renderDropIndicator?: (target: ItemDropTarget) => ReactNode ) { - // console.log('collection', collection); return useCachedChildren({ items: parent ? collection.getChildren!(parent.key) : collection, dependencies: [renderDropIndicator], From 89df42ff9297a30a3f7077e3e612b46d905ee9cc Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:58:10 -0700 Subject: [PATCH 05/20] update key after if its content node --- .../dnd/src/DropTargetKeyboardNavigation.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts index fee624a4567..2e634b177ea 100644 --- a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts +++ b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts @@ -21,6 +21,7 @@ export function navigate( case 'up': return previousDropTarget(keyboardDelegate, collection, target, wrap); case 'down': + // console.log('keyboard', nextDropTarget(keyboardDelegate, collection, target, wrap)); return nextDropTarget(keyboardDelegate, collection, target, wrap); } } @@ -57,8 +58,14 @@ function nextDropTarget( nextKey = horizontal === 'right' ? keyboardDelegate.getKeyRightOf?.(target.key) : keyboardDelegate.getKeyLeftOf?.(target.key); } else { nextKey = keyboardDelegate.getKeyBelow?.(target.key); + // console.log('nextKey', nextKey) } let nextCollectionKey = collection.getKeyAfter(target.key); + if (collection.getItem(nextCollectionKey).type === 'content') { + nextCollectionKey = collection.getKeyAfter(nextCollectionKey); + } + console.log('next keys', nextKey, nextCollectionKey); + debugger; // 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 @@ -100,10 +107,12 @@ function nextDropTarget( } case 'after': { // If this is the last sibling in a level, traverse to the parent. - let targetNode = collection.getItem(target.key); + let targetNode = collection.getItem(target.key); + // console.log('targetNode', targetNode); if (targetNode && targetNode.nextKey == null && targetNode.parentKey != null) { // If the parent item has an item after it, use the "before" position. let parentNode = collection.getItem(targetNode.parentKey); + console.log('parentNode', parentNode); if (parentNode?.nextKey != null) { return { type: 'item', @@ -250,6 +259,7 @@ function getLastChild(collection: Collection>, key: Key): DropTarg // Checking if the next item has a greater level is a silly way to determine if the item is expanded. let targetNode = collection.getItem(key); let nextKey = collection.getKeyAfter(key); + // console.log('next', nextKey); let nextNode = nextKey != null ? collection.getItem(nextKey) : null; if (targetNode && nextNode && nextNode.level > targetNode.level) { let children = getChildNodes(targetNode, collection); From 8491826a45cdfba189f84f1b6eb64bbf573ec13f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:39:45 -0700 Subject: [PATCH 06/20] fix types when checking content node --- .../@react-aria/dnd/src/DropTargetKeyboardNavigation.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts index 2e634b177ea..73c6fda376f 100644 --- a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts +++ b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts @@ -58,14 +58,12 @@ function nextDropTarget( nextKey = horizontal === 'right' ? keyboardDelegate.getKeyRightOf?.(target.key) : keyboardDelegate.getKeyLeftOf?.(target.key); } else { nextKey = keyboardDelegate.getKeyBelow?.(target.key); - // console.log('nextKey', nextKey) } let nextCollectionKey = collection.getKeyAfter(target.key); - if (collection.getItem(nextCollectionKey).type === 'content') { - nextCollectionKey = collection.getKeyAfter(nextCollectionKey); + let nextCollectionNode = nextCollectionKey && collection.getItem(nextCollectionKey); + if (nextCollectionNode && nextCollectionNode.type === 'content') { + nextCollectionKey = nextCollectionKey ? collection.getKeyAfter(nextCollectionKey) : null; } - console.log('next keys', nextKey, nextCollectionKey); - debugger; // 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 From 6f78fe73ea90fb7713623dc748b2f7dbc934856a Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:41:08 -0700 Subject: [PATCH 07/20] fix spacing --- packages/@react-stately/data/src/useTreeData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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--; From 43f2895fd86b6e7eb6fdd469a1b922ed98fb327e Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:12:45 -0700 Subject: [PATCH 08/20] update dynamic story --- .../stories/Tree.stories.tsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 10eaeea095f..11474bc28a8 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -406,7 +406,7 @@ let rows = [ ]; let rowWithSections = [ - {id: 'section_1', name: 'Section 1', children: + {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: [ @@ -422,7 +422,7 @@ let rowWithSections = [ {id: 'project-5C', name: 'Project 5C'} ]} ]}]}, - {id: 'section_2', name: 'Section 2', children: + {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: [ @@ -616,17 +616,23 @@ const TreeExampleDynamicRender = (args: TreeProps): JSX.Ele }; const TreeSectionExampleDynamicRender = (args: TreeProps): JSX.Element => { + let treeData = useTreeData({ + initialItems: args.items as any ?? rowWithSections, + getKey: item => item.id, + getChildren: item => item.childItems + }); + return ( - - + + {section => ( - {section.name} - + {section.value.name} + {item => - - {item.name} - + + {item.value.name} + } @@ -1402,11 +1408,11 @@ export const TreeWithDragAndDropVirtualized = { const VirtualizedTreeExampleSectionRender = (args) => ( - {/* + Photo Header Photos Edited Photos - */} + Project Header @@ -1424,7 +1430,7 @@ const VirtualizedTreeExampleSectionRender = (args) => ( Project-4 - {/* classNames(styles, 'tree-item', { @@ -1451,7 +1457,7 @@ const VirtualizedTreeExampleSectionRender = (args) => ( {`${isFocused} Tests`} )} - */} + ); From 99d4169a01737cef64ca2ca2ce3eb4abc91c3f78 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:17:40 -0700 Subject: [PATCH 09/20] update setSize with added getDirectChildren function --- .../@react-aria/gridlist/src/useGridListItem.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index fd832525ea7..cc7b2d6cf5f 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -11,7 +11,8 @@ */ import {chain, getScrollParent, mergeProps, 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 {CollectionNode} from '@react-aria/collections'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; import {getRowId, listMap} from './utils'; import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; @@ -104,7 +105,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt let parent = state.collection.getItem(node.parentKey); if (parent) { // siblings must exist because our original node exists - let siblings = state.collection.getSiblings?.(parent.key)!; + let siblings = getDirectChildren(parent as CollectionNode, state.collection as Collection>); setSize = [...siblings].filter(row => row.type === 'item').length; } } else { @@ -324,3 +325,13 @@ function last(walker: TreeWalker) { } while (last); return next; } + +function getDirectChildren(parent: CollectionNode, collection: Collection>) { + let node = parent?.firstChildKey != null ? collection.getItem(parent.firstChildKey) : null; + let siblings: CollectionNode[] = []; + while (node) { + siblings.push(node); + node = node.nextKey != null ? collection.getItem(node.nextKey) : null; + } + return siblings; +} From 049f2a503c717a30b48a7d6d2e1bcb6431b5ad95 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:21:51 -0700 Subject: [PATCH 10/20] fix types, update at method --- packages/react-aria-components/src/Tree.tsx | 184 ++++++++++++-------- 1 file changed, 110 insertions(+), 74 deletions(-) diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 5aa7bcb3a69..aed09ac4b3b 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -25,14 +25,14 @@ 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 React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TreeDropTargetDelegate} from './TreeDropTargetDelegate'; @@ -41,7 +41,7 @@ import {HeaderContext} from './Header'; import {GridListHeader, GridListHeaderContext} from './GridList' class TreeCollection implements ICollection> { - private flattenedRows: Node[]; + // private flattenedRows: Node[]; private keyMap: Map> = new Map(); private itemCount: number = 0; private firstKey; @@ -50,8 +50,7 @@ class TreeCollection implements ICollection> { constructor(opts) { let {collection, lastExpandedKeys, expandedKeys} = opts; - let {flattenedRows, keyMap, itemCount} = flattenTree(collection, {expandedKeys}); - this.flattenedRows = flattenedRows; + let {keyMap, itemCount} = flattenTree(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) this.keyMap = keyMap; this.itemCount = itemCount; @@ -59,15 +58,13 @@ class TreeCollection implements ICollection> { this.lastKey = [...this.keyMap.keys()][this.keyMap.size - 1]; this.expandedKeys = expandedKeys; - // console.log(lastExpandedKeys, expandedKeys); - // diff lastExpandedKeys and expandedKeys for (let key of expandedKeys) { if (!lastExpandedKeys.has(key)) { // traverse upward until you hit a section, and clone it let currentKey = key; while (currentKey != null) { - let item = this.getItem(currentKey); + let item = this.getItem(currentKey) as CollectionNode; if (item?.type === 'section') { // replace the item with a clone this.keyMap.set(currentKey, item.clone()); @@ -83,7 +80,7 @@ class TreeCollection implements ICollection> { if (!expandedKeys.has(key)) { let currentKey = key; while (currentKey != null) { - let item = this.getItem(currentKey); + let item = this.getItem(currentKey) as CollectionNode; if (item?.type === 'section') { // replace the item with a clone this.keyMap.set(currentKey, item.clone()); @@ -96,17 +93,6 @@ class TreeCollection implements ICollection> { } } - // 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](): IterableIterator> { - // let node: Node | undefined = this.firstKey != null ? this.keyMap.get(this.firstKey) : undefined; - // while (node) { - // // console.log('grr', node); - // yield node; - // node = node.nextKey != null ? this.keyMap.get(node.nextKey) : undefined; - // } - // } - *[Symbol.iterator]() { function* traverseDepthFirst(node: CollectionNode | null, expandedKeys: Set) { if (!node) return; @@ -117,26 +103,25 @@ class TreeCollection implements ICollection> { // If node is expanded, traverse its children if (expandedKeys.has(node.key) && node.firstChildKey) { let firstChild = keyMap.get(node.firstChildKey); - yield* traverseDepthFirst(keyMap.get(firstChild.nextKey), expandedKeys); - // yield* traverseDepthFirst(keyMap.get(node.firstChildKey), expandedKeys); + let nextNode = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : null + if (nextNode) { + yield* traverseDepthFirst(nextNode, expandedKeys); + } } // Then traverse to next sibling - if (node.nextKey) { - yield* traverseDepthFirst(keyMap.get(node.nextKey), expandedKeys); + let nextNode = node && node.nextKey ? keyMap.get(node.nextKey) : null + if (nextNode) { + yield* traverseDepthFirst(nextNode, expandedKeys); } } let keyMap = this.keyMap; let expandedKeys = this.expandedKeys; - // console.log('keys', expandedKeys); let node: Node | undefined = this.firstKey != null ? this.keyMap.get(this.firstKey) : undefined; - yield* traverseDepthFirst(node, expandedKeys); + yield* traverseDepthFirst(node as CollectionNode, expandedKeys); } - // *[Symbol.iterator]() { - // yield* this.flattenedRows; - // } get size() { return this.itemCount; @@ -152,7 +137,47 @@ class TreeCollection implements ICollection> { at(idx: number) { // not sure how we would do this without flattenedRows since its parameter is an index? - return this.flattenedRows[idx]; + let keyMap = this.keyMap + let expandedKeys = this.expandedKeys + + function getKeyAfter(key: Key) { + let node = keyMap.get(key); + if (!node) { + return null; + } + + if ((expandedKeys.has(node.key) || node.type !== 'item') && node.firstChildKey != null) { + node = keyMap.get(node.firstChildKey); + while (node && node.type === 'content' && node.nextKey != null) { + node = keyMap.get(node.nextKey) + } + return node ? node.key : null + } + + while (node) { + if (node.nextKey != null) { + return node.nextKey; + } + + if (node.parentKey != null) { + node = keyMap.get(node.parentKey); + } else { + return null; + } + } + + return null; + } + + let firstKey = this.getFirstKey(); + let node = firstKey ? keyMap.get(firstKey) : null; + for (let i = 0; i < idx; i++) { + if (node) { + let keyAfter = getKeyAfter(node.key); + node = keyAfter ? keyMap.get(keyAfter) : null; + } + } + return node as Node; } getFirstKey() { @@ -170,10 +195,7 @@ class TreeCollection implements ICollection> { } } - return node.key - // return this.firstKey - // return this.flattenedRows[0]?.key; - + return node ? node.key : null; } getLastKey() { @@ -185,20 +207,18 @@ class TreeCollection implements ICollection> { // If the node's parent is expanded, then we can assume that this is the actual last key if (node.parentKey && this.expandedKeys.has(node.parentKey)) { - return node; + 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.keyMap.get(node.parentKey) : null; - while (parentNode && parentNode.type !== 'section' && !this.expandedKeys.has(parentNode.key)) { + while (parentNode && parentNode.type !== 'section' && node && node.parentKey && !this.expandedKeys.has(parentNode.key)) { node = this.keyMap.get(node.parentKey); - parentNode = node.parentKey ? this.keyMap.get(node.parentKey) : null; + parentNode = node && node.parentKey ? this.keyMap.get(node.parentKey) : null; } return node?.key ?? null; - // return this.flattenedRows[this.flattenedRows.length - 1]?.key; - } getKeyAfter(key: Key) { @@ -224,8 +244,6 @@ class TreeCollection implements ICollection> { } return null; - // let index = this.flattenedRows.findIndex(row => row.key === key); - // return this.flattenedRows[index + 1]?.key; } getKeyBefore(key: Key) { @@ -241,6 +259,7 @@ class TreeCollection implements ICollection> { node = this.keyMap.get(node.lastChildKey); } + // if the lastChildKey is expanded, check its lastChildKey while (node && this.expandedKeys.has(node.key) && node.lastChildKey != null) { node = this.keyMap.get(node.lastChildKey) } @@ -266,26 +285,29 @@ class TreeCollection implements ICollection> { // If node is expanded, traverse its children if (expandedKeys.has(node.key) && node.firstChildKey) { let firstChild = keyMap.get(node.firstChildKey); - yield* traverseDepthFirst(keyMap.get(firstChild.nextKey), expandedKeys); + let nextNode = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : null + if (nextNode) { + yield* traverseDepthFirst(nextNode, expandedKeys); + } } // Then traverse to next sibling - if (node.nextKey) { - yield* traverseDepthFirst(keyMap.get(node.nextKey), expandedKeys); + let nextNode = node && node.nextKey ? keyMap.get(node.nextKey) : null + if (nextNode) { + yield* traverseDepthFirst(nextNode, expandedKeys); } } let parent = keyMap.get(key); let node = parent?.firstChildKey ? keyMap.get(parent.firstChildKey) : null; - // if (parent.type === 'section') { - // yield* traverseDepthFirst(node, expandedKeys); - // } else { - // while (node) { - // yield node as Node; - // node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; - // } - // } - yield* traverseDepthFirst(node, expandedKeys); + if (parent && parent.type === 'section' && node) { + yield* traverseDepthFirst(node, expandedKeys); + } else { + while (node) { + yield node as Node; + node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; + } + } } }; } @@ -404,7 +426,6 @@ interface TreeInnerProps { } function TreeInner({props, collection, treeRef: ref}: TreeInnerProps) { - // console.log('TreeInner', collection); const {dragAndDropHooks} = props; let {direction} = useLocale(); let collator = useCollator({usage: 'search', sensitivity: 'base'}); @@ -433,17 +454,21 @@ 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 [lastCollection, setLastCollection] = useState(collection); let [lastExpandedKeys, setLastExpandedKeys] = useState(expandedKeys); - let [flattenedCollection, setFlattenedCollection] = useState(() => new TreeCollection({collection, lastExpandedKeys: lastExpandedKeys, expandedKeys})); + let [flattenedCollection, setFlattenedCollection] = useState(() => new TreeCollection({collection, lastExpandedKeys: new Set(), expandedKeys})); - if (expandedKeys.size !== lastExpandedKeys.size || collection !== lastCollection){ + // if the lastExpandedKeys is not the same as the currentExpandedKeys or the collection has changed, then run this! + if (!areSetsEqual(lastExpandedKeys, expandedKeys) || collection !== lastCollection){ + // console.log(expandedKeys.size === lastExpandedKeys.size); + // console.log(collection !== lastCollection); + // console.log(expandedKeys.size, lastExpandedKeys.size); setFlattenedCollection(new TreeCollection({collection, lastExpandedKeys, expandedKeys})); setLastCollection(collection); setLastExpandedKeys(expandedKeys); @@ -514,7 +539,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); } @@ -1032,21 +1057,21 @@ export const TreeLoadMoreItem = createLeafComponent(LoaderNode, function TreeLoa ); }); -function convertExpanded(expanded: 'all' | Iterable): 'all' | Set { - if (!expanded) { - return new Set(); - } +// function convertExpanded(expanded: 'all' | Iterable): 'all' | Set { +// if (!expanded) { +// return new Set(); +// } - return expanded === 'all' - ? 'all' - : new Set(expanded); -} +// return expanded === 'all' +// ? 'all' +// : new Set(expanded); +// } interface TreeGridCollectionOptions { expandedKeys: Set } interface FlattenedTree { - flattenedRows: Node[], + // flattenedRows: Node[], keyMap: Map>, itemCount: number } @@ -1056,7 +1081,7 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO expandedKeys = new Set() } = opts; let keyMap: Map> = new Map(); - let flattenedRows: Node[] = []; + // 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(); @@ -1097,7 +1122,7 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO itemCount++; } - flattenedRows.push(modifiedNode); + // flattenedRows.push(modifiedNode); parentLookup.set(modifiedNode.key, true); } } else if (node.type !== null) { @@ -1113,10 +1138,8 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO visitNode(node, node.type === 'section'); } - // console.log('flattenedRows', flattenedRows); - return { - flattenedRows, + // flattenedRows, keyMap, itemCount }; @@ -1263,3 +1286,16 @@ export const TreeHeader = (props: HTMLAttributes): ReactNode => { ) } + +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; +} From fce9dce0540483b84bec106853b420959afab9a8 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:23:13 -0700 Subject: [PATCH 11/20] remove console logs --- packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts index 73c6fda376f..7c370aaa569 100644 --- a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts +++ b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts @@ -21,7 +21,6 @@ export function navigate( case 'up': return previousDropTarget(keyboardDelegate, collection, target, wrap); case 'down': - // console.log('keyboard', nextDropTarget(keyboardDelegate, collection, target, wrap)); return nextDropTarget(keyboardDelegate, collection, target, wrap); } } @@ -106,11 +105,9 @@ function nextDropTarget( case 'after': { // If this is the last sibling in a level, traverse to the parent. let targetNode = collection.getItem(target.key); - // console.log('targetNode', targetNode); if (targetNode && targetNode.nextKey == null && targetNode.parentKey != null) { // If the parent item has an item after it, use the "before" position. let parentNode = collection.getItem(targetNode.parentKey); - console.log('parentNode', parentNode); if (parentNode?.nextKey != null) { return { type: 'item', @@ -257,7 +254,6 @@ function getLastChild(collection: Collection>, key: Key): DropTarg // Checking if the next item has a greater level is a silly way to determine if the item is expanded. let targetNode = collection.getItem(key); let nextKey = collection.getKeyAfter(key); - // console.log('next', nextKey); let nextNode = nextKey != null ? collection.getItem(nextKey) : null; if (targetNode && nextNode && nextNode.level > targetNode.level) { let children = getChildNodes(targetNode, collection); From f138f6a94057d5b95d080149fe24e895cc54a19e Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:53:17 -0700 Subject: [PATCH 12/20] add collection dependency to gridlist --- packages/@react-aria/gridlist/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@react-aria/gridlist/package.json b/packages/@react-aria/gridlist/package.json index 1ded8da028d..8e03ba0af23 100644 --- a/packages/@react-aria/gridlist/package.json +++ b/packages/@react-aria/gridlist/package.json @@ -26,6 +26,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/collections": "^3.0.0", "@react-aria/focus": "^3.21.2", "@react-aria/grid": "^3.14.5", "@react-aria/i18n": "^3.12.13", From 900d69eb5eae807959f3a73d6682007c0d07f191 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 9 Oct 2025 22:41:10 -0700 Subject: [PATCH 13/20] fix lint --- .../react-aria-components/src/GridList.tsx | 2 +- packages/react-aria-components/src/Tree.tsx | 67 +++++-------- .../stories/Tree.stories.tsx | 96 ++++--------------- 3 files changed, 44 insertions(+), 121 deletions(-) diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index ec837fabffe..d435699d7a9 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -646,7 +646,7 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, }); export const GridListHeaderContext = createContext, HTMLDivElement>>({}); -const GridListHeaderInnerContext = createContext | null>(null); +export const GridListHeaderInnerContext = createContext | null>(null); export const GridListHeader = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes, 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 aed09ac4b3b..20c51171861 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -32,13 +32,12 @@ import {DragAndDropContext, DropIndicatorContext, useDndPersistedKeys, useRender 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} from './GridList'; import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, 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'; -import {HeaderContext} from './Header'; -import {GridListHeader, GridListHeaderContext} from './GridList' class TreeCollection implements ICollection> { // private flattenedRows: Node[]; @@ -95,22 +94,24 @@ class TreeCollection implements ICollection> { *[Symbol.iterator]() { function* traverseDepthFirst(node: CollectionNode | null, expandedKeys: Set) { - if (!node) return; - + if (!node) { + return; + } + // Always yield the current node first yield node; // If node is expanded, traverse its children if (expandedKeys.has(node.key) && node.firstChildKey) { let firstChild = keyMap.get(node.firstChildKey); - let nextNode = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : null + let nextNode = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : null; if (nextNode) { yield* traverseDepthFirst(nextNode, expandedKeys); } } // Then traverse to next sibling - let nextNode = node && node.nextKey ? keyMap.get(node.nextKey) : null + let nextNode = node && node.nextKey ? keyMap.get(node.nextKey) : null; if (nextNode) { yield* traverseDepthFirst(nextNode, expandedKeys); } @@ -136,9 +137,8 @@ class TreeCollection implements ICollection> { } at(idx: number) { - // not sure how we would do this without flattenedRows since its parameter is an index? - let keyMap = this.keyMap - let expandedKeys = this.expandedKeys + let keyMap = this.keyMap; + let expandedKeys = this.expandedKeys; function getKeyAfter(key: Key) { let node = keyMap.get(key); @@ -149,9 +149,9 @@ class TreeCollection implements ICollection> { if ((expandedKeys.has(node.key) || node.type !== 'item') && node.firstChildKey != null) { node = keyMap.get(node.firstChildKey); while (node && node.type === 'content' && node.nextKey != null) { - node = keyMap.get(node.nextKey) + node = keyMap.get(node.nextKey); } - return node ? node.key : null + return node ? node.key : null; } while (node) { @@ -217,7 +217,6 @@ class TreeCollection implements ICollection> { parentNode = node && node.parentKey ? this.keyMap.get(node.parentKey) : null; } - return node?.key ?? null; } @@ -228,7 +227,7 @@ class TreeCollection implements ICollection> { } if ((this.expandedKeys.has(node.key) || node.type !== 'item') && node.firstChildKey != null) { - return node.firstChildKey + return node.firstChildKey; } while (node) { @@ -261,7 +260,7 @@ class TreeCollection implements ICollection> { // if the lastChildKey is expanded, check its lastChildKey while (node && this.expandedKeys.has(node.key) && node.lastChildKey != null) { - node = this.keyMap.get(node.lastChildKey) + node = this.keyMap.get(node.lastChildKey); } return node?.key ?? null; @@ -277,7 +276,9 @@ class TreeCollection implements ICollection> { return { *[Symbol.iterator]() { function* traverseDepthFirst(node: CollectionNode | null, expandedKeys: Set) { - if (!node) return; + if (!node) { + return; + } // Always yield the current node first yield node; @@ -285,14 +286,14 @@ class TreeCollection implements ICollection> { // If node is expanded, traverse its children if (expandedKeys.has(node.key) && node.firstChildKey) { let firstChild = keyMap.get(node.firstChildKey); - let nextNode = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : null + let nextNode = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : null; if (nextNode) { yield* traverseDepthFirst(nextNode, expandedKeys); } } // Then traverse to next sibling - let nextNode = node && node.nextKey ? keyMap.get(node.nextKey) : null + let nextNode = node && node.nextKey ? keyMap.get(node.nextKey) : null; if (nextNode) { yield* traverseDepthFirst(nextNode, expandedKeys); } @@ -304,29 +305,14 @@ class TreeCollection implements ICollection> { yield* traverseDepthFirst(node, expandedKeys); } else { while (node) { - yield node as Node; - node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; + yield node as Node; + node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; } } } }; } - // We need a method to get a flattened list of all children at the same level (so no diving into a child if it is an expanded node) - getSiblings(key: Key): Iterable> { - let keyMap = this.keyMap; - 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; - } - } - } - } - getTextValue(key: Key): string { let item = this.getItem(key); return item ? item.textValue : ''; @@ -465,10 +451,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne // if the lastExpandedKeys is not the same as the currentExpandedKeys or the collection has changed, then run this! - if (!areSetsEqual(lastExpandedKeys, expandedKeys) || collection !== lastCollection){ - // console.log(expandedKeys.size === lastExpandedKeys.size); - // console.log(collection !== lastCollection); - // console.log(expandedKeys.size, lastExpandedKeys.size); + if (!areSetsEqual(lastExpandedKeys, expandedKeys) || collection !== lastCollection) { setFlattenedCollection(new TreeCollection({collection, lastExpandedKeys, expandedKeys})); setLastCollection(collection); setLastExpandedKeys(expandedKeys); @@ -1268,8 +1251,8 @@ export const TreeSection = /*#__PURE__*/ createBranchComponent(SectionNode, ): ReactNode => { {props.children} - ) -} + ); +}; function areSetsEqual(a: Set, b: Set) { if (a.size !== b.size) { diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 11474bc28a8..234a22d863e 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, TreeHeader, TreeItem, TreeItemContent, TreeItemProps, TreeSection, 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'; @@ -270,7 +270,7 @@ export const TreeExampleStatic: StoryObj = { const TreeExampleSectionRender = (args) => ( - classNames(styles, 'tree-item', { @@ -344,8 +344,8 @@ export const TreeExampleSection = { control: 'radio', options: ['selection', 'all'] } - }, -} + } +}; export const TreeExampleStaticNoActions: StoryObj = { render: (args) => , @@ -406,8 +406,8 @@ let rows = [ ]; let rowWithSections = [ - {id: 'section_1', name: 'Section 1', childItems: - [{id: 'projects', name: 'Projects', childItems: [ + {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'}, @@ -420,10 +420,11 @@ let rowWithSections = [ {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: '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: [ @@ -434,9 +435,9 @@ let rowWithSections = [ {id: 'reports-1C', name: 'Reports 1C'} ]}, {id: 'reports-2', name: 'Reports 2'} - ]}] - } -] + ]} + ]} +]; const MyTreeLoader = (props) => { let {omitChildren} = props; @@ -534,67 +535,6 @@ const DynamicTreeItem = (props: DynamicTreeItemProps) => { ); }; -const DynamicTreeItemSection = (props: DynamicTreeItemProps) => { - let {childItems, renderLoader, supportsDragging} = props; - - return ( - <> - classNames(styles, 'tree-item', { - focused: isFocused, - 'focus-visible': isFocusVisible, - selected: isSelected, - hovered: isHovered, - 'drop-target': isDropTarget - })}> - - {({isExpanded, hasChildItems, level, selectionBehavior, selectionMode}) => ( - <> - {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( - - )} -
- {hasChildItems && ( - - )} - {supportsDragging && } - {props.children} - - - - - - Foo - Bar - Baz - - - -
- - )} -
- - {(item: any) => ( - - {item.name} - - )} - - {renderLoader?.(props.id) && } -
- {props.isLastInRoot && } - - ); -}; - let defaultExpandedKeys = new Set(['projects', 'project-2', 'project-5', 'reports', 'reports-1', 'reports-1A', 'reports-1AB']); const TreeExampleDynamicRender = (args: TreeProps): JSX.Element => { @@ -630,9 +570,9 @@ const TreeSectionExampleDynamicRender = (args: TreeProps): {section.value.name} {item => - - {item.value.name} - + ( + {item.value.name} + ) } @@ -1483,8 +1423,8 @@ export const VirtualizedTreeSectionRender = { control: 'radio', options: ['selection', 'all'] } - }, -} + } +}; interface ITreeItem { id: string, name: string, From ae36ec505062fe3eb57516460cbb02dfa1a8a3e1 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Thu, 9 Oct 2025 22:44:41 -0700 Subject: [PATCH 14/20] update yarn lock --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 72cc56dbf8d..7bb383f5c5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5759,6 +5759,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-aria/gridlist@workspace:packages/@react-aria/gridlist" dependencies: + "@react-aria/collections": "npm:^3.0.0" "@react-aria/focus": "npm:^3.21.2" "@react-aria/grid": "npm:^3.14.5" "@react-aria/i18n": "npm:^3.12.13" From cff1b4ca5b97da7d363bec9ba9f8c2a55ccf8448 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:03:09 -0700 Subject: [PATCH 15/20] rename dynamic row section array --- packages/react-aria-components/stories/Tree.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 234a22d863e..fff4cc523c7 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -405,7 +405,7 @@ let rows = [ ]} ]; -let rowWithSections = [ +let rowsWithSections = [ {id: 'section_1', name: 'Section 1', childItems: [ {id: 'projects', name: 'Projects', childItems: [ {id: 'project-1', name: 'Project 1'}, @@ -557,7 +557,7 @@ const TreeExampleDynamicRender = (args: TreeProps): JSX.Ele const TreeSectionExampleDynamicRender = (args: TreeProps): JSX.Element => { let treeData = useTreeData({ - initialItems: args.items as any ?? rowWithSections, + initialItems: args.items as any ?? rowsWithSections, getKey: item => item.id, getChildren: item => item.childItems }); From e6752ca74ba2083e25149d081db88ed412e1921c Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:04:59 -0700 Subject: [PATCH 16/20] add tests, fix setSize for top level nodes inside a section --- .../gridlist/src/useGridListItem.ts | 2 +- .../react-aria-components/test/Tree.test.tsx | 206 +++++++++++++++++- 2 files changed, 206 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index cc7b2d6cf5f..d8861560516 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -101,7 +101,7 @@ 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 diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index cd1c893fdfc..0b82794811d 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})), @@ -1882,6 +1959,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({ From fb703b29ab1bf6f3acf8ce57f6a10918b70cc332 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:07:53 -0700 Subject: [PATCH 17/20] fix lint --- packages/react-aria-components/test/Tree.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 0b82794811d..f0406e9b681 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -208,7 +208,7 @@ let DynamicSectionTree = ({treeProps = {}, rowProps = {}}) => ( {section.name} {item => ( - + {item.name} )} @@ -1962,7 +1962,7 @@ describe('Tree', () => { describe('sections', () => { it('should support sections', () => { - let {getAllByRole} = render() + let {getAllByRole} = render(); let groups = getAllByRole('rowgroup'); expect(groups).toHaveLength(2); @@ -1981,7 +1981,7 @@ describe('Tree', () => { }); it('should have the expected attributes on the rows in sections', () => { - let {getAllByRole} = render() + let {getAllByRole} = render(); let rows = getAllByRole('row'); let rowNoChild = rows[0]; From 98289cefcdcb496f5f2e3ef7b13541779a0629eb Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:16:39 -0700 Subject: [PATCH 18/20] remove comments --- packages/react-aria-components/src/Tree.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 20c51171861..63ce4072fac 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -40,7 +40,6 @@ 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 firstKey; @@ -1054,7 +1053,6 @@ interface TreeGridCollectionOptions { } interface FlattenedTree { - // flattenedRows: Node[], keyMap: Map>, itemCount: number } @@ -1064,7 +1062,6 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO 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(); @@ -1105,7 +1102,6 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO itemCount++; } - // flattenedRows.push(modifiedNode); parentLookup.set(modifiedNode.key, true); } } else if (node.type !== null) { @@ -1122,7 +1118,6 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO } return { - // flattenedRows, keyMap, itemCount }; From ed553883a9c9ec68b23c76a6838f6fa01186b55e Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:37:29 -0700 Subject: [PATCH 19/20] more cleanup --- packages/react-aria-components/src/Tree.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 63ce4072fac..176d8c1703f 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -839,7 +839,6 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, { @@ -1039,15 +1038,6 @@ 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 } From 3facc4a6e679049983e9ca998742c9407b50fd60 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:16:23 -0700 Subject: [PATCH 20/20] comments and stuff --- packages/react-aria-components/src/Tree.tsx | 23 +++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 176d8c1703f..3570112d6e0 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -56,7 +56,7 @@ class TreeCollection implements ICollection> { this.lastKey = [...this.keyMap.keys()][this.keyMap.size - 1]; this.expandedKeys = expandedKeys; - // diff lastExpandedKeys and expandedKeys + // diff lastExpandedKeys and expandedKeys so we only clone what has changed for (let key of expandedKeys) { if (!lastExpandedKeys.has(key)) { // traverse upward until you hit a section, and clone it @@ -103,9 +103,12 @@ class TreeCollection implements ICollection> { // If node is expanded, traverse its children if (expandedKeys.has(node.key) && node.firstChildKey) { let firstChild = keyMap.get(node.firstChildKey); - let nextNode = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : null; - if (nextNode) { - yield* traverseDepthFirst(nextNode, expandedKeys); + // Skip content nodes + while (firstChild && firstChild.type === 'content') { + firstChild = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : undefined; + } + if (firstChild) { + yield* traverseDepthFirst(firstChild, expandedKeys); } } @@ -257,7 +260,7 @@ class TreeCollection implements ICollection> { node = this.keyMap.get(node.lastChildKey); } - // if the lastChildKey is expanded, check its lastChildKey + // If the lastChildKey is expanded, check its lastChildKey while (node && this.expandedKeys.has(node.key) && node.lastChildKey != null) { node = this.keyMap.get(node.lastChildKey); } @@ -268,7 +271,6 @@ class TreeCollection implements ICollection> { return node.parentKey; } - // Note that this will return Content nodes in addition to nested TreeItems getChildren(key: Key): Iterable> { let keyMap = this.keyMap; let expandedKeys = this.expandedKeys; @@ -285,9 +287,12 @@ class TreeCollection implements ICollection> { // If node is expanded, traverse its children if (expandedKeys.has(node.key) && node.firstChildKey) { let firstChild = keyMap.get(node.firstChildKey); - let nextNode = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : null; - if (nextNode) { - yield* traverseDepthFirst(nextNode, expandedKeys); + // Skip content nodes + while (firstChild && firstChild.type === 'content') { + firstChild = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : undefined; + } + if (firstChild) { + yield* traverseDepthFirst(firstChild, expandedKeys); } }