Skip to content

Commit 6bbacb8

Browse files
authored
feat: Tree multiple level loading support (#8299)
* Update tree and listlayout to handle multi loaders * adapting other stories to new loader api and adding useAsync example story * add tests * fix story for correctness, should only need to provide a dependecy at the top most collection * restoring focus back to the tree if the user was focused on the loader * fixing estimated loader position if sections exist taken from #8326 * skip test for now
1 parent 3ebf561 commit 6bbacb8

File tree

7 files changed

+835
-70
lines changed

7 files changed

+835
-70
lines changed

packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1844,7 +1844,7 @@ describe('SearchAutocomplete', function () {
18441844
expect(() => within(tray).getByText('No results')).toThrow();
18451845
});
18461846

1847-
it.skip('user can select options by pressing them', async function () {
1847+
it('user can select options by pressing them', async function () {
18481848
let {getByRole, getByText, getByTestId} = renderSearchAutocomplete();
18491849
let button = getByRole('button');
18501850

@@ -1892,7 +1892,7 @@ describe('SearchAutocomplete', function () {
18921892
expect(items[1]).toHaveAttribute('aria-selected', 'true');
18931893
});
18941894

1895-
it.skip('user can select options by focusing them and hitting enter', async function () {
1895+
it('user can select options by focusing them and hitting enter', async function () {
18961896
let {getByRole, getByText, getByTestId} = renderSearchAutocomplete();
18971897
let button = getByRole('button');
18981898

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

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -253,41 +253,48 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
253253

254254
protected buildCollection(y = this.padding): LayoutNode[] {
255255
let collection = this.virtualizer!.collection;
256-
let skipped = 0;
256+
let collectionNodes = [...collection];
257+
let loaderNodes = collectionNodes.filter(node => node.type === 'loader');
257258
let nodes: LayoutNode[] = [];
258-
let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader');
259+
260+
let isEmptyOrLoading = collection?.size === 0 || !collectionNodes.some(item => item.type !== 'loader');
259261
if (isEmptyOrLoading) {
260262
y = 0;
261263
}
262264

263-
for (let node of collection) {
265+
for (let node of collectionNodes) {
264266
let rowHeight = (this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT) + this.gap;
265267
// Skip rows before the valid rectangle unless they are already cached.
266268
if (node.type === 'item' && y + rowHeight < this.requestedRect.y && !this.isValid(node, y)) {
267269
y += rowHeight;
268-
skipped++;
269270
continue;
270271
}
271272

272273
let layoutNode = this.buildChild(node, this.padding, y, null);
273274
y = layoutNode.layoutInfo.rect.maxY + this.gap;
274275
nodes.push(layoutNode);
275-
if (node.type === 'item' && y > this.requestedRect.maxY) {
276-
let itemsAfterRect = collection.size - (nodes.length + skipped);
277-
let lastNode = collection.getItem(collection.getLastKey()!);
278-
if (lastNode?.type === 'loader') {
279-
itemsAfterRect--;
280-
}
281-
282-
y += itemsAfterRect * rowHeight;
276+
if (node.type === 'loader') {
277+
let index = loaderNodes.indexOf(node);
278+
loaderNodes.splice(index, 1);
279+
}
283280

284-
// Always add the loader sentinel if present. This assumes the loader is the last option/row
285-
// will need to refactor when handling multi section loading
286-
if (lastNode?.type === 'loader' && nodes.at(-1)?.layoutInfo.type !== 'loader') {
287-
let loader = this.buildChild(lastNode, this.padding, y, null);
281+
// Build each loader that exists in the collection that is outside the visible rect so that they are persisted
282+
// at the proper estimated location. If the node.type is "section" then we don't do this shortcut since we have to
283+
// build the sections to see how tall they are.
284+
if ((node.type === 'item' || node.type === 'loader') && y > this.requestedRect.maxY) {
285+
let lastProcessedIndex = collectionNodes.indexOf(node);
286+
for (let loaderNode of loaderNodes) {
287+
let loaderNodeIndex = collectionNodes.indexOf(loaderNode);
288+
// Subtract by an additional 1 since we've already added the current item's height to y
289+
y += (loaderNodeIndex - lastProcessedIndex - 1) * rowHeight;
290+
let loader = this.buildChild(loaderNode, this.padding, y, null);
288291
nodes.push(loader);
289292
y = loader.layoutInfo.rect.maxY;
293+
lastProcessedIndex = loaderNodeIndex;
290294
}
295+
296+
// Account for the rest of the items after the last loader spinner, subtract by 1 since we've processed the current node's height already
297+
y += (collectionNodes.length - lastProcessedIndex - 1) * rowHeight;
291298
break;
292299
}
293300
}

packages/@react-stately/tree/src/useTreeState.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,13 @@ export function useTreeState<T extends object>(props: TreeProps<T>): TreeState<T
6565

6666
// Reset focused key if that item is deleted from the collection.
6767
useEffect(() => {
68-
if (selectionState.focusedKey != null && !tree.getItem(selectionState.focusedKey)) {
69-
selectionState.setFocusedKey(null);
68+
if (selectionState.focusedKey != null) {
69+
let focusedItem = tree.getItem(selectionState.focusedKey);
70+
// TODO: do we want to have the same logic as useListState/useGridState where it tries to find the nearest row?
71+
// We could possibly special case this loader case and have it try to find the item just before it/the parent
72+
if (!focusedItem || focusedItem.type === 'loader' && !focusedItem.props.isLoading) {
73+
selectionState.setFocusedKey(null);
74+
}
7075
}
7176
// eslint-disable-next-line react-hooks/exhaustive-deps
7277
}, [tree, selectionState.focusedKey]);

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

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {DisabledBehavior, DragPreviewRenderer, Expandable, forwardRefType, Hover
2020
import {DragAndDropContext, DropIndicatorContext, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop';
2121
import {DragAndDropHooks} from './useDragAndDrop';
2222
import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, Node, SelectionBehavior, TreeState, useTreeState} from 'react-stately';
23-
import {filterDOMProps, useObjectRef} from '@react-aria/utils';
23+
import {filterDOMProps, inertValue, LoadMoreSentinelProps, UNSTABLE_useLoadMoreSentinel, useObjectRef} from '@react-aria/utils';
2424
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
2525
import {useControlledState} from '@react-stately/utils';
2626

@@ -699,51 +699,87 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent('item', <T extends o
699699
);
700700
});
701701

702-
export interface UNSTABLE_TreeLoadingIndicatorRenderProps {
702+
export interface UNSTABLE_TreeLoadingSentinelRenderProps extends Pick<TreeItemRenderProps, 'isFocused' | 'isFocusVisible'> {
703703
/**
704704
* What level the tree item has within the tree.
705705
* @selector [data-level]
706706
*/
707707
level: number
708708
}
709709

710-
export interface TreeLoaderProps extends RenderProps<UNSTABLE_TreeLoadingIndicatorRenderProps>, StyleRenderProps<UNSTABLE_TreeLoadingIndicatorRenderProps> {}
710+
export interface TreeLoadingSentinelProps extends Omit<LoadMoreSentinelProps, 'collection'>, RenderProps<UNSTABLE_TreeLoadingSentinelRenderProps> {
711+
/**
712+
* The load more spinner to render when loading additional items.
713+
*/
714+
children?: ReactNode | ((values: UNSTABLE_TreeLoadingSentinelRenderProps & {defaultChildren: ReactNode | undefined}) => ReactNode),
715+
/**
716+
* Whether or not the loading spinner should be rendered or not.
717+
*/
718+
isLoading?: boolean
719+
}
711720

712-
export const UNSTABLE_TreeLoadingIndicator = createLeafComponent('loader', function TreeLoader<T extends object>(props: TreeLoaderProps, ref: ForwardedRef<HTMLDivElement>, item: Node<T>) {
721+
export const UNSTABLE_TreeLoadingSentinel = createLeafComponent('loader', function TreeLoadingSentinel<T extends object>(props: TreeLoadingSentinelProps, ref: ForwardedRef<HTMLDivElement>, item: Node<T>) {
713722
let state = useContext(TreeStateContext)!;
714-
// This loader row is is non-interactable, but we want the same aria props calculated as a typical row
715-
// @ts-ignore
716-
let {rowProps} = useTreeItem({node: item}, state, ref);
723+
let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props;
724+
let sentinelRef = useRef(null);
725+
let memoedLoadMoreProps = useMemo(() => ({
726+
onLoadMore,
727+
// TODO: this collection will update anytime a row is expanded/collapsed becaused the flattenedRows will change.
728+
// This means onLoadMore will trigger but that might be ok cause the user should have logic to handle multiple loadMore calls
729+
collection: state?.collection,
730+
sentinelRef,
731+
scrollOffset
732+
}), [onLoadMore, scrollOffset, state?.collection]);
733+
UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef);
734+
735+
ref = useObjectRef<HTMLDivElement>(ref);
736+
let {rowProps, gridCellProps, ...states} = useTreeItem({node: item}, state, ref);
717737
let level = rowProps['aria-level'] || 1;
718738

719739
let ariaProps = {
740+
role: 'row',
720741
'aria-level': rowProps['aria-level'],
721742
'aria-posinset': rowProps['aria-posinset'],
722-
'aria-setsize': rowProps['aria-setsize']
743+
'aria-setsize': rowProps['aria-setsize'],
744+
tabIndex: rowProps.tabIndex
723745
};
724746

747+
let {isFocusVisible, focusProps} = useFocusRing();
748+
725749
let renderProps = useRenderProps({
726-
...props,
750+
...otherProps,
727751
id: undefined,
728752
children: item.rendered,
729753
defaultClassName: 'react-aria-TreeLoader',
730754
values: {
731-
level
755+
level,
756+
isFocused: states.isFocused,
757+
isFocusVisible
732758
}
733759
});
734760

735761
return (
736762
<>
737-
<div
738-
role="row"
739-
ref={ref}
740-
{...mergeProps(filterDOMProps(props as any), ariaProps)}
741-
{...renderProps}
742-
data-level={level}>
743-
<div role="gridcell" aria-colindex={1}>
744-
{renderProps.children}
745-
</div>
763+
{/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */}
764+
{/* @ts-ignore - compatibility with React < 19 */}
765+
<div style={{position: 'relative', width: 0, height: 0}} inert={inertValue(true)} >
766+
<div data-testid="loadMoreSentinel" ref={sentinelRef} style={{position: 'absolute', height: 1, width: 1}} />
746767
</div>
768+
{isLoading && renderProps.children && (
769+
<div
770+
ref={ref}
771+
{...mergeProps(filterDOMProps(props as any), ariaProps, focusProps)}
772+
{...renderProps}
773+
data-key={rowProps['data-key']}
774+
data-collection={rowProps['data-collection']}
775+
data-focused={states.isFocused || undefined}
776+
data-focus-visible={isFocusVisible || undefined}
777+
data-level={level}>
778+
<div {...gridCellProps}>
779+
{renderProps.children}
780+
</div>
781+
</div>
782+
)}
747783
</>
748784
);
749785
});
@@ -796,9 +832,10 @@ function flattenTree<T>(collection: TreeCollection<T>, opts: TreeGridCollectionO
796832
keyMap.set(node.key, node as CollectionNode<T>);
797833
}
798834

799-
if (node.level === 0 || (parentKey != null && expandedKeys.has(parentKey) && flattenedRows.find(row => row.key === parentKey))) {
800-
// Grab the modified node from the key map so our flattened list and modified key map point to the same nodes
801-
flattenedRows.push(keyMap.get(node.key) || node);
835+
// Grab the modified node from the key map so our flattened list and modified key map point to the same nodes
836+
let modifiedNode = keyMap.get(node.key) || node;
837+
if (modifiedNode.level === 0 || (modifiedNode.parentKey != null && expandedKeys.has(modifiedNode.parentKey) && flattenedRows.find(row => row.key === modifiedNode.parentKey))) {
838+
flattenedRows.push(modifiedNode);
802839
}
803840
} else if (node.type !== null) {
804841
keyMap.set(node.key, node as CollectionNode<T>);
@@ -836,7 +873,7 @@ function TreeDropIndicatorWrapper(props: DropIndicatorProps, ref: ForwardedRef<H
836873
let level = dropState && props.target.type === 'item' ? (dropState.collection.getItem(props.target.key)?.level || 0) + 1 : 1;
837874

838875
return (
839-
<TreeDropIndicatorForwardRef
876+
<TreeDropIndicatorForwardRef
840877
{...props}
841878
dropIndicatorProps={dropIndicatorProps}
842879
isDropTarget={isDropTarget}

packages/react-aria-components/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export {ToggleButton, ToggleButtonContext} from './ToggleButton';
7575
export {ToggleButtonGroup, ToggleButtonGroupContext, ToggleGroupStateContext} from './ToggleButtonGroup';
7676
export {Toolbar, ToolbarContext} from './Toolbar';
7777
export {TooltipTrigger, Tooltip, TooltipTriggerStateContext, TooltipContext} from './Tooltip';
78-
export {UNSTABLE_TreeLoadingIndicator, Tree, TreeItem, TreeContext, TreeItemContent, TreeStateContext} from './Tree';
78+
export {UNSTABLE_TreeLoadingSentinel, Tree, TreeItem, TreeContext, TreeItemContent, TreeStateContext} from './Tree';
7979
export {useDragAndDrop} from './useDragAndDrop';
8080
export {DropIndicator, DropIndicatorContext, DragAndDropContext} from './DragAndDrop';
8181
export {Virtualizer} from './Virtualizer';

0 commit comments

Comments
 (0)