Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
473d209
initial tree stuff
yihuiliao Sep 23, 2025
e896397
fix virtualized tree section expanding, update methods to remove flat…
yihuiliao Oct 6, 2025
c3385c1
cleanup
yihuiliao Oct 6, 2025
fb17656
more cleanup
yihuiliao Oct 6, 2025
a1c8b17
Merge branch 'main' into tree-section
yihuiliao Oct 6, 2025
89df42f
update key after if its content node
yihuiliao Oct 7, 2025
8491826
fix types when checking content node
yihuiliao Oct 7, 2025
6f78fe7
fix spacing
yihuiliao Oct 7, 2025
43f2895
update dynamic story
yihuiliao Oct 9, 2025
99d4169
update setSize with added getDirectChildren function
yihuiliao Oct 9, 2025
049f2a5
fix types, update at method
yihuiliao Oct 9, 2025
fce9dce
remove console logs
yihuiliao Oct 9, 2025
f138f6a
add collection dependency to gridlist
yihuiliao Oct 9, 2025
900d69e
fix lint
yihuiliao Oct 10, 2025
ae36ec5
update yarn lock
yihuiliao Oct 10, 2025
cff1b4c
rename dynamic row section array
yihuiliao Oct 10, 2025
e6752ca
add tests, fix setSize for top level nodes inside a section
yihuiliao Oct 10, 2025
fb703b2
fix lint
yihuiliao Oct 10, 2025
98289ce
remove comments
yihuiliao Oct 10, 2025
ed55388
more cleanup
yihuiliao Oct 10, 2025
3facc4a
comments and stuff
yihuiliao Oct 10, 2025
2674d5b
Merge branch 'main' into tree-section
yihuiliao Oct 21, 2025
19151b4
Merge branch 'main' into tree-section
yihuiliao Jan 29, 2026
91cb778
fix tests
yihuiliao Jan 29, 2026
49cb60c
these changes were made in oct and it's now jan so...
yihuiliao Jan 29, 2026
13baafd
lots of comments regarding skipping content nodes in tree
yihuiliao Feb 6, 2026
100befa
skip content nodes is listlayout and collections, update keyboard del…
yihuiliao Feb 7, 2026
2d98707
Merge branch 'main' into tree-section
yihuiliao Feb 7, 2026
fa3da98
update yarn lock
yihuiliao Feb 7, 2026
2410143
update direct children function
yihuiliao Feb 8, 2026
92e1840
comments
yihuiliao Feb 9, 2026
8191e58
update direct children functiona and remove collectionode type from g…
yihuiliao Feb 9, 2026
229092c
update comments
yihuiliao Feb 9, 2026
bee7804
Merge branch 'main' into tree-section
yihuiliao Feb 11, 2026
46d3c76
updates
yihuiliao Feb 11, 2026
8f668a2
simplify logic for visible/non disabled item
yihuiliao Feb 11, 2026
29e729d
comments, small fixes
yihuiliao Feb 12, 2026
564646d
make guarding against content node more robust
yihuiliao Feb 12, 2026
3d8b288
optimize filtering content nodes in list layout
yihuiliao Feb 12, 2026
d209f52
optimize getting child array
yihuiliao Feb 12, 2026
f1088a1
add helper function for cloning
yihuiliao Feb 12, 2026
421f22f
add comments
yihuiliao Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/@react-aria/collections/src/useCachedChildren.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ export function useCachedChildren<T extends object>(props: CachedChildrenOptions
rendered = children(item);
// @ts-ignore
let key = rendered.props.id ?? item.key ?? item.id;

if (key == null) {
throw new Error('Could not determine key for item');
}

if (idScope != null) {
key = idScope + ':' + key;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ function nextDropTarget(
nextKey = keyboardDelegate.getKeyBelow?.(target.key);
}
let nextCollectionKey = collection.getKeyAfter(target.key);
let nextCollectionNode = nextCollectionKey && collection.getItem(nextCollectionKey);
if (nextCollectionNode && nextCollectionNode.type === 'content') {
nextCollectionKey = nextCollectionKey ? collection.getKeyAfter(nextCollectionKey) : null;
}

// 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
Expand Down
19 changes: 16 additions & 3 deletions packages/@react-aria/gridlist/src/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {chain, getScrollParent, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils';
import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared';
import {Collection, DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared';
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
import {getRowId, listMap} from './utils';
import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react';
Expand Down Expand Up @@ -100,11 +100,11 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt

let isExpanded = hasChildRows ? state.expandedKeys.has(node.key) : undefined;
let setSize = 1;
if (node.level > 0 && node?.parentKey != null) {
if (node.level >= 0 && node?.parentKey != null) {
let parent = state.collection.getItem(node.parentKey);
if (parent) {
// siblings must exist because our original node exists
let siblings = state.collection.getChildren?.(parent.key)!;
let siblings = getDirectChildren(parent, state.collection);
setSize = [...siblings].filter(row => row.type === 'item').length;
}
} else {
Expand Down Expand Up @@ -324,3 +324,16 @@ function last(walker: TreeWalker) {
} while (last);
return next;
}

function getDirectChildren<T>(parent: RSNode<T>, collection: Collection<RSNode<T>>) {
// We can't assume that we can use firstChildKey because if a person builds a tree using hooks, they would not have access to that property (using type Node vs CollectionNode)
// Instead, get all children and start at the first node (rather than just using firstChildKey) and only look at its siblings
let children = collection.getChildren?.(parent.key);
let node = children && Array.from(children).length > 0 ? Array.from(children)[0] : null;
let siblings: RSNode<T>[] = [];
while (node) {
siblings.push(node);
node = node.nextKey != null ? collection.getItem(node.nextKey) : null;
}
return siblings;
}
55 changes: 53 additions & 2 deletions packages/@react-aria/selection/src/ListKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ interface ListKeyboardDelegateOptions<T> {
direction?: Direction,
disabledKeys?: Set<Key>,
disabledBehavior?: DisabledBehavior,
layoutDelegate?: LayoutDelegate
layoutDelegate?: LayoutDelegate,
expandedKeys?: Set<Key>
}

export class ListKeyboardDelegate<T> implements KeyboardDelegate {
Expand All @@ -36,8 +37,9 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
private orientation?: Orientation;
private direction?: Direction;
private layoutDelegate: LayoutDelegate;
private expandedKeys?: Set<Key>;

constructor(collection: Collection<Node<T>>, disabledKeys: Set<Key>, ref: RefObject<HTMLElement | null>, collator?: Intl.Collator);
constructor(collection: Collection<Node<T>>, disabledKeys: Set<Key>, ref: RefObject<HTMLElement | null>, collator?: Intl.Collator, expandedKeys?: Set<Key>);
constructor(options: ListKeyboardDelegateOptions<T>);
constructor(...args: any[]) {
if (args.length === 1) {
Expand All @@ -51,6 +53,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
this.direction = opts.direction;
this.layout = opts.layout || 'stack';
this.layoutDelegate = opts.layoutDelegate || new DOMLayoutDelegate(opts.ref);
this.expandedKeys = opts.expandedKeys;
} else {
this.collection = args[0];
this.disabledKeys = args[1];
Expand All @@ -60,6 +63,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
this.orientation = 'vertical';
this.disabledBehavior = 'all';
this.layoutDelegate = new DOMLayoutDelegate(this.ref);
this.expandedKeys = args[4];
}

// If this is a vertical stack, remove the left/right methods completely
Expand Down Expand Up @@ -88,6 +92,49 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
return null;
}

private findNextVisible(key: Key | null): Key | null {
let node = key ? this.collection.getItem(key) : null;
if (!node) {
return null;
}

// If the node's parent is expanded, then we can assume that this is a visible node
if (node.parentKey && this.expandedKeys?.has(node.parentKey)) {
return node.key;
}

// If the node's parent is not expanded, find the top-most non-expanded node since it's possible for them to be nested
let parentNode = node.parentKey ? this.collection.getItem(node.parentKey) : null;
// if the the parent node is not a section, and the parent node is not included in expanded keys
while (parentNode && parentNode.type !== 'section' && node && node.parentKey && this.expandedKeys && !this.expandedKeys.has(parentNode.key)) {
node = this.collection.getItem(node.parentKey);
parentNode = node && node.parentKey ? this.collection.getItem(node.parentKey) : null;
}

return node?.key ?? null;
}

private findNextNonDisabledVisible(key: Key | null, getNext: (key: Key) => Key | null) {
let nextKey = key;
while (nextKey !== null) {
let visibleKey = this.findNextVisible(nextKey);
// If visibleKey is null, that means there are no visibleKeys (don't feel like this is a real use case though, I would assume that there is always one visible node)
if (visibleKey == null) {
return null;
}

let nonDisabledKey = this.findNextNonDisabled(visibleKey, getNext);
// If the keys are equal, then we know that the node is both visible and non-disabled
if (visibleKey === nonDisabledKey) {
return visibleKey;
}

nextKey = getNext(visibleKey);
}

return null;
}

getNextKey(key: Key): Key | null {
let nextKey: Key | null = key;
nextKey = this.collection.getKeyAfter(nextKey);
Expand Down Expand Up @@ -201,6 +248,10 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {

getLastKey(): Key | null {
let key = this.collection.getLastKey();
// we only need to check for visible keys if items can be expanded/collapsed
if (this.expandedKeys) {
return this.findNextNonDisabledVisible(key, key => this.collection.getKeyBefore(key));
}
return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key));
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@react-stately/data/src/useTreeData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ function moveItems<T extends object>(
// 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--;
Expand Down
9 changes: 8 additions & 1 deletion packages/@react-stately/layout/src/ListLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,9 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte

protected buildCollection(y: number = this.padding): LayoutNode[] {
let collection = this.virtualizer!.collection;
let collectionNodes = [...collection];
// filter out content nodes since we don't want them to affect the height
// Tree specific for now, if we add content nodes to other collection items, we might need to reconsider this
let collectionNodes = [...collection].filter((node) => node.type !== 'content');
let loaderNodes = collectionNodes.filter(node => node.type === 'loader');
let nodes: LayoutNode[] = [];
let isEmptyOrLoading = collection?.size === 0;
Expand Down Expand Up @@ -370,6 +372,11 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
let skipped = 0;
let children: LayoutNode[] = [];
for (let child of getChildNodes(node, collection)) {
// skip if it is a content node, Tree specific for now, if we add content nodes to other collection items, we might need to reconsider this
if (child.type === 'content') {
continue;
}

let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap;

// Skip rows before the valid rectangle unless they are already cached.
Expand Down
6 changes: 6 additions & 0 deletions packages/react-aria-components/src/Collection.tsx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

w/ regards to rendering the drop indicator, did we test DnD with Tree sections yet? I think I remember a potential problem being the drop indicators rendered after the last row in a section and the first row in the next section and those being conflated as being the same when they aren't in practice

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah dnd is unlikely to work (it also doesn't work in listbox w/ sections and gridlist w/ sections). we decided for gridlist to leave it as a follow-up since we knew we were releasing it as an alpha and i think the same would apply here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how are we releasing this as an alpha?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess the package itself wouldn't say anything about it being alpha but with gridlist we just marked it as an alpha on the docs. we could also add unstable to the export

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do the UNSTABLE, eventually we have to remove it but also keep it around
Ok, so we're just doing it via the docs

Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ function useCollectionRender(
items: parent ? collection.getChildren!(parent.key) : collection,
dependencies: [renderDropIndicator],
children(node) {
// Return a empty fragment since we don't want to render the content twice
// If we don't skip the content node here, we end up rendering them twice in a Tree since we also render the content node in TreeItem
if (node.type === 'content') {
return <></>;
}

let rendered = node.render!(node);
if (!renderDropIndicator || node.type !== 'item') {
return rendered;
Expand Down
2 changes: 1 addition & 1 deletion packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode,
export interface GridListHeaderProps extends DOMRenderProps<'div', undefined>, DOMProps, GlobalDOMAttributes<HTMLElement> {}

export const GridListHeaderContext = createContext<ContextValue<GridListHeaderProps, HTMLDivElement>>({});
const GridListHeaderInnerContext = createContext<HTMLAttributes<HTMLElement> | null>(null);
export const GridListHeaderInnerContext = createContext<HTMLAttributes<HTMLElement> | null>(null);

export const GridListHeader = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: GridListHeaderProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, GridListHeaderContext);
Expand Down
Loading