Skip to content

Commit 5847e26

Browse files
authored
feat: add support for tree sections (adobe#9013)
* initial tree stuff * fix virtualized tree section expanding, update methods to remove flattenTree * cleanup * more cleanup * update key after if its content node * fix types when checking content node * fix spacing * update dynamic story * update setSize with added getDirectChildren function * fix types, update at method * remove console logs * add collection dependency to gridlist * fix lint * update yarn lock * rename dynamic row section array * add tests, fix setSize for top level nodes inside a section * fix lint * remove comments * more cleanup * comments and stuff * fix tests * these changes were made in oct and it's now jan so... * lots of comments regarding skipping content nodes in tree * skip content nodes is listlayout and collections, update keyboard delegate * update yarn lock * update direct children function * comments * update direct children functiona and remove collectionode type from gridlist * update comments * updates * simplify logic for visible/non disabled item * comments, small fixes * make guarding against content node more robust * optimize filtering content nodes in list layout * optimize getting child array * add helper function for cloning * add comments
1 parent 88fc6bd commit 5847e26

File tree

12 files changed

+756
-72
lines changed

12 files changed

+756
-72
lines changed

packages/@react-aria/collections/src/useCachedChildren.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ export function useCachedChildren<T extends object>(props: CachedChildrenOptions
4545
rendered = children(item);
4646
// @ts-ignore
4747
let key = rendered.props.id ?? item.key ?? item.id;
48-
48+
4949
if (key == null) {
5050
throw new Error('Could not determine key for item');
5151
}
52-
52+
5353
if (idScope != null) {
5454
key = idScope + ':' + key;
5555
}

packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ function nextDropTarget(
5959
nextKey = keyboardDelegate.getKeyBelow?.(target.key);
6060
}
6161
let nextCollectionKey = collection.getKeyAfter(target.key);
62+
let nextCollectionNode = nextCollectionKey && collection.getItem(nextCollectionKey);
63+
while (nextCollectionNode && nextCollectionNode.type === 'content') {
64+
nextCollectionKey = nextCollectionKey ? collection.getKeyAfter(nextCollectionKey) : null;
65+
nextCollectionNode = nextCollectionKey && collection.getItem(nextCollectionKey);
66+
}
6267

6368
// If the keyboard delegate did not move to the next key in the collection,
6469
// jump to that key with the same drop position. Otherwise, try the other

packages/@react-aria/gridlist/src/useGridListItem.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

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

101101
let isExpanded = hasChildRows ? state.expandedKeys.has(node.key) : undefined;
102102
let setSize = 1;
103-
if (node.level > 0 && node?.parentKey != null) {
103+
if (node.level >= 0 && node?.parentKey != null) {
104104
let parent = state.collection.getItem(node.parentKey);
105105
if (parent) {
106106
// siblings must exist because our original node exists
107-
let siblings = state.collection.getChildren?.(parent.key)!;
107+
let siblings = getDirectChildren(parent, state.collection);
108108
setSize = [...siblings].filter(row => row.type === 'item').length;
109109
}
110110
} else {
@@ -326,3 +326,17 @@ function last(walker: TreeWalker) {
326326
} while (last);
327327
return next;
328328
}
329+
330+
function getDirectChildren<T>(parent: RSNode<T>, collection: Collection<RSNode<T>>) {
331+
// 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)
332+
// Instead, get all children and start at the first node (rather than just using firstChildKey) and only look at its siblings
333+
let children = collection.getChildren?.(parent.key);
334+
let childArray = children ? Array.from(children) : [];
335+
let node = childArray.length > 0 ? childArray[0] : null;
336+
let siblings: RSNode<T>[] = [];
337+
while (node) {
338+
siblings.push(node);
339+
node = node.nextKey != null ? collection.getItem(node.nextKey) : null;
340+
}
341+
return siblings;
342+
}

packages/@react-aria/selection/src/ListKeyboardDelegate.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ interface ListKeyboardDelegateOptions<T> {
2323
direction?: Direction,
2424
disabledKeys?: Set<Key>,
2525
disabledBehavior?: DisabledBehavior,
26-
layoutDelegate?: LayoutDelegate
26+
layoutDelegate?: LayoutDelegate,
27+
expandedKeys?: Set<Key>
2728
}
2829

2930
export class ListKeyboardDelegate<T> implements KeyboardDelegate {
@@ -36,8 +37,9 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
3637
private orientation?: Orientation;
3738
private direction?: Direction;
3839
private layoutDelegate: LayoutDelegate;
40+
private expandedKeys?: Set<Key>;
3941

40-
constructor(collection: Collection<Node<T>>, disabledKeys: Set<Key>, ref: RefObject<HTMLElement | null>, collator?: Intl.Collator);
42+
constructor(collection: Collection<Node<T>>, disabledKeys: Set<Key>, ref: RefObject<HTMLElement | null>, collator?: Intl.Collator, expandedKeys?: Set<Key>);
4143
constructor(options: ListKeyboardDelegateOptions<T>);
4244
constructor(...args: any[]) {
4345
if (args.length === 1) {
@@ -51,6 +53,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
5153
this.direction = opts.direction;
5254
this.layout = opts.layout || 'stack';
5355
this.layoutDelegate = opts.layoutDelegate || new DOMLayoutDelegate(opts.ref);
56+
this.expandedKeys = opts.expandedKeys;
5457
} else {
5558
this.collection = args[0];
5659
this.disabledKeys = args[1];
@@ -60,6 +63,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
6063
this.orientation = 'vertical';
6164
this.disabledBehavior = 'all';
6265
this.layoutDelegate = new DOMLayoutDelegate(this.ref);
66+
this.expandedKeys = args[4];
6367
}
6468

6569
// If this is a vertical stack, remove the left/right methods completely
@@ -88,6 +92,50 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
8892
return null;
8993
}
9094

95+
// Returns the first key that's visible starting from and inclusive of the provided key
96+
private findNextVisible(key: Key | null): Key | null {
97+
let node = key ? this.collection.getItem(key) : null;
98+
if (!node) {
99+
return null;
100+
}
101+
102+
// If the node's parent is expanded, then we can assume that this is a visible node
103+
if (node.parentKey && this.expandedKeys?.has(node.parentKey)) {
104+
return node.key;
105+
}
106+
107+
// If the node's parent is not expanded, find the top-most non-expanded node since it's possible for them to be nested
108+
let parentNode = node.parentKey ? this.collection.getItem(node.parentKey) : null;
109+
// if the the parent node is not a section, and the parent node is not included in expanded keys
110+
while (parentNode && parentNode.type !== 'section' && node && node.parentKey && this.expandedKeys && !this.expandedKeys.has(parentNode.key)) {
111+
node = this.collection.getItem(node.parentKey);
112+
parentNode = node && node.parentKey ? this.collection.getItem(node.parentKey) : null;
113+
}
114+
115+
return node?.key ?? null;
116+
}
117+
118+
// Returns the first key that's visible and non-disabled starting from and inclusive of the provided key
119+
private findNextNonDisabledVisible(key: Key | null, getNext: (key: Key) => Key | null) {
120+
let nextKey = key;
121+
while (nextKey !== null) {
122+
let visibleKey = this.findNextVisible(nextKey);
123+
// 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)
124+
if (visibleKey == null) {
125+
return null;
126+
}
127+
128+
let node = this.collection.getItem(visibleKey);
129+
if (node?.type === 'item' && !this.isDisabled(node)) {
130+
return visibleKey;
131+
}
132+
133+
nextKey = getNext(visibleKey);
134+
}
135+
136+
return null;
137+
}
138+
91139
getNextKey(key: Key): Key | null {
92140
let nextKey: Key | null = key;
93141
nextKey = this.collection.getKeyAfter(nextKey);
@@ -201,6 +249,10 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
201249

202250
getLastKey(): Key | null {
203251
let key = this.collection.getLastKey();
252+
// we only need to check for visible keys if items can be expanded/collapsed
253+
if (this.expandedKeys) {
254+
return this.findNextNonDisabledVisible(key, key => this.collection.getKeyBefore(key));
255+
}
204256
return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key));
205257
}
206258

packages/@react-stately/data/src/useTreeData.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ function moveItems<T extends object>(
504504
// decrement the index if the child being removed is in the target parent and before the target index
505505
// the root node is special, it is null, and will not have a key, however, a parentKey can still point to it
506506
if ((child.parentKey === toParent
507-
|| child.parentKey === toParent?.key)
507+
|| child.parentKey === toParent?.key)
508508
&& keyArray.includes(child.key)
509509
&& (toParent?.children ? toParent.children.indexOf(child) : items.indexOf(child)) < originalToIndex) {
510510
toIndex--;

packages/@react-stately/layout/src/ListLayout.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,9 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
253253

254254
protected buildCollection(y: number = this.padding): LayoutNode[] {
255255
let collection = this.virtualizer!.collection;
256-
let collectionNodes = [...collection];
256+
// filter out content nodes since we don't want them to affect the height
257+
// Tree specific for now, if we add content nodes to other collection items, we might need to reconsider this
258+
let collectionNodes = toArray(collection, (node) => node.type !== 'content');
257259
let loaderNodes = collectionNodes.filter(node => node.type === 'loader');
258260
let nodes: LayoutNode[] = [];
259261
let isEmptyOrLoading = collection?.size === 0;
@@ -370,6 +372,11 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
370372
let skipped = 0;
371373
let children: LayoutNode[] = [];
372374
for (let child of getChildNodes(node, collection)) {
375+
// 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
376+
if (child.type === 'content') {
377+
continue;
378+
}
379+
373380
let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap;
374381

375382
// Skip rows before the valid rectangle unless they are already cached.
@@ -617,3 +624,13 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
617624
return new LayoutInfo('dropIndicator', target.key + ':' + target.dropPosition, rect);
618625
}
619626
}
627+
628+
function toArray<T>(collection: Collection<Node<T>>, predicate: (node: Node<T>) => boolean): Node<T>[] {
629+
const result: Node<T>[] = [];
630+
for (const node of collection) {
631+
if (predicate(node)) {
632+
result.push(node);
633+
}
634+
}
635+
return result;
636+
}

packages/react-aria-components/src/Collection.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ function useCollectionRender(
159159
items: parent ? collection.getChildren!(parent.key) : collection,
160160
dependencies: [renderDropIndicator],
161161
children(node) {
162+
// Return a empty fragment since we don't want to render the content twice
163+
// 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
164+
if (node.type === 'content') {
165+
return <></>;
166+
}
167+
162168
let rendered = node.render!(node);
163169
if (!renderDropIndicator || node.type !== 'item') {
164170
return rendered;

packages/react-aria-components/src/GridList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,7 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode,
653653
export interface GridListHeaderProps extends DOMRenderProps<'div', undefined>, DOMProps, GlobalDOMAttributes<HTMLElement> {}
654654

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

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

0 commit comments

Comments
 (0)