Skip to content

Commit 505a6d9

Browse files
LFDanLudevongovett
andauthored
Load more collection element support in RAC Table and Tree (#6494)
* rough approach for having user provide loading element to rac collection element * bare minimum for table loader support * add first and last row/cell data-attributes * updating collection to auto propagate dependencies and debugging example stories * adding stories for empty state + loading variations in tree and table * updating verdaccio to not crash on newer macbooks * rip out first/last row and fix lint * add TableLoader to index so that I can test in s2 * add isTableEmpty and colspan for table loader also some debugging of crashes * add renderProps to loader * omit loader from the collection size in table * fix bugs with keyboard navigation and load more in table * Fix Tree keyboard nav issues and add tests * fix keyboard nav with loaders via keyboard delegate item filtering logic it makes more sense for the keyboard delegate to handle what keys should be returned on up/down/pageUp/etc * clean up and use hook for loader row attributes * refactor so that empty load more is expected to be a part of renderEmptyState * useLoadMore support in RAC Table (#6538) * initial implementation of load more hook * initial stab at scrollable body table * fix lint and readd content height change check * update attributes and test virtualizer + loadmore * remove option in prep for fix * fix for loadMore and update virtualized RAC table stories * get rid of loaderHeight option * move hook to separate package * add tests * fix some stuff from rebase * fix test for 16/17 * refactor useLoadOnScroll so it can replace useVirtualizer internals * inconsistent 19 test is fixed now * fix another 19 test * fix crash when dragging over loading spinner * only render loadmore spinner in dnd table if there are items * get rid of todo now that layout has been refactored * move logic to render after drop indicator when row is before logic into Collection * move useLoadMore to util package for now punting refactor and removal of virtualizer to later see note * pull loadmore hook out of rac table * rename loader collection element and update layout for variable loader height * forgot to remove deps * fix esm test? * update useloadmore logic and replace useVirtualizer internals note this removes the "wasLoading || ref.current.scrollHeight !== lastContentSize.current" portion of the logic. Previously useVirtualizer onVisibleRectChange was the one that would handle loading more items if opening a combobox dropdown during/after initial load, not the layouteffect. However, now that that is replaced by useLoadMore onScroll handler, we need the layouteffect to handle that * remove useVirtualizer * fix tests * fix erroneous conditional hooks call was causing a different order of hooks calls when a table with renderEmptyState went from empty to having a rows * review comments and updating Tree loader naming for consistency * dont apply width directly on columnheader if virtualizer fix for S2, the wrapper around the columnheader already has the calculated width applied on it so we dont need it on the columnheader as well. This makes padding values applied on the columnheader take up room from current width instead of making the columnheader extra wide * fix lint * add items check to useLoadMore interim fix to prevent some extra onLoadMore calls. RAC will eventually make use of the collection when the hook is moved inside the component whereas RSP table cant benefit from this since its collection is always rebuilt and thus non stable. Items are also provided to the TableBody and cant be accessed from with tablevirtualizer * add display contents and debug sticky loader * clean up from review * test and story updates from review comments --------- Co-authored-by: Devon Govett <[email protected]>
1 parent 109ea06 commit 505a6d9

File tree

23 files changed

+1705
-252
lines changed

23 files changed

+1705
-252
lines changed

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,11 @@ export class ListDropTargetDelegate implements DropTargetDelegate {
9595
}
9696
}
9797

98-
let items = [...this.collection];
98+
// TODO: assume that only item type items are valid drop targets. This is to prevent a crash when dragging over the loader
99+
// row since it doesn't have a data-key set on it. Will eventually need to handle the case with drag and drop and loaders located between rows aka tree.
100+
// Can see https://github.com/adobe/react-spectrum/pull/4210/files#diff-21e555e0c597a28215e36137f5be076a65a1e1456c92cd0fdd60f866929aae2a for additional logic
101+
// that may need to happen then
102+
let items = [...this.collection].filter(item => item.type === 'item');
99103
let low = 0;
100104
let high = items.length;
101105
while (low < high) {

packages/@react-aria/grid/src/GridKeyboardDelegate.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
101101
}
102102

103103
// Find the next item
104-
key = this.findNextKey(key);
104+
key = this.findNextKey(key, (item => item.type === 'item'));
105105
if (key != null) {
106106
// If focus was on a cell, focus the cell with the same index in the next row.
107107
if (this.isCell(startItem)) {
@@ -128,7 +128,7 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
128128
}
129129

130130
// Find the previous item
131-
key = this.findPreviousKey(key);
131+
key = this.findPreviousKey(key, item => item.type === 'item');
132132
if (key != null) {
133133
// If focus was on a cell, focus the cell with the same index in the previous row.
134134
if (this.isCell(startItem)) {
@@ -232,7 +232,7 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
232232
}
233233

234234
// Find the first row
235-
key = this.findNextKey();
235+
key = this.findNextKey(null, item => item.type === 'item');
236236

237237
// If global flag is set (or if focus mode is cell), focus the first cell in the first row.
238238
if ((key != null && item && this.isCell(item) && global) || this.focusMode === 'cell') {
@@ -262,7 +262,7 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
262262
}
263263

264264
// Find the last row
265-
key = this.findPreviousKey();
265+
key = this.findPreviousKey(null, item => item.type === 'item');
266266

267267
// If global flag is set (or if focus mode is cell), focus the last cell in the last row.
268268
if ((key != null && item && this.isCell(item) && global) || this.focusMode === 'cell') {
@@ -303,13 +303,13 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
303303

304304
while (itemRect && (itemRect.y + itemRect.height) < pageY) {
305305
let nextKey = this.getKeyBelow(key);
306-
itemRect = this.layoutDelegate.getItemRect(nextKey);
307-
308-
// Guard against case where maxY of the last key is barely less than pageY due to rounding
309-
// and thus it attempts to set key to null
310-
if (nextKey != null) {
311-
key = nextKey;
306+
// If nextKey is undefined, we've reached the last row already
307+
if (nextKey == null) {
308+
break;
312309
}
310+
311+
itemRect = this.layoutDelegate.getItemRect(nextKey);
312+
key = nextKey;
313313
}
314314

315315
return key;
@@ -345,7 +345,7 @@ export class GridKeyboardDelegate<T, C extends GridCollection<T>> implements Key
345345
}
346346
}
347347

348-
key = this.findNextKey(key);
348+
key = this.findNextKey(key, item => item.type === 'item');
349349

350350
// Wrap around when reaching the end of the collection
351351
if (key == null && !hasWrapped) {

packages/@react-aria/table/src/useTableCell.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ export interface TableCellAria {
4646
*/
4747
export function useTableCell<T>(props: AriaTableCellProps, state: TableState<T>, ref: RefObject<FocusableElement | null>): TableCellAria {
4848
let {gridCellProps, isPressed} = useGridCell(props, state, ref);
49-
5049
let columnKey = props.node.column.key;
5150
if (state.collection.rowHeaderColumnKeys.has(columnKey)) {
5251
gridCellProps.role = 'rowheader';

packages/@react-aria/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ export {isVirtualClick, isVirtualPointerEvent} from './isVirtualEvent';
4141
export {useEffectEvent} from './useEffectEvent';
4242
export {useDeepMemo} from './useDeepMemo';
4343
export {useFormReset} from './useFormReset';
44+
export {useLoadMore} from './useLoadMore';
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {RefObject, useCallback, useRef} from 'react';
14+
import {useEvent} from './useEvent';
15+
// eslint-disable-next-line rulesdir/useLayoutEffectRule
16+
import {useLayoutEffect} from './useLayoutEffect';
17+
18+
export interface LoadMoreProps {
19+
/** Whether data is currently being loaded. */
20+
isLoading?: boolean,
21+
/** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */
22+
onLoadMore?: () => void,
23+
/**
24+
* The amount of offset from the bottom of your scrollable region that should trigger load more.
25+
* Uses a percentage value relative to the scroll body's client height. Load more is then triggered
26+
* when your current scroll position's distance from the bottom of the currently loaded list of items is less than
27+
* or equal to the provided value. (e.g. 1 = 100% of the scroll region's height).
28+
* @default 1
29+
*/
30+
scrollOffset?: number,
31+
/** The data currently loaded. */
32+
items?: any[]
33+
}
34+
35+
export function useLoadMore(props: LoadMoreProps, ref: RefObject<HTMLElement | null>) {
36+
let {isLoading, onLoadMore, scrollOffset = 1, items} = props;
37+
38+
// Handle scrolling, and call onLoadMore when nearing the bottom.
39+
let isLoadingRef = useRef(isLoading);
40+
let prevProps = useRef(props);
41+
let onScroll = useCallback(() => {
42+
if (ref.current && !isLoadingRef.current && onLoadMore) {
43+
let shouldLoadMore = ref.current.scrollHeight - ref.current.scrollTop - ref.current.clientHeight < ref.current.clientHeight * scrollOffset;
44+
45+
if (shouldLoadMore) {
46+
isLoadingRef.current = true;
47+
onLoadMore();
48+
}
49+
}
50+
}, [onLoadMore, ref, scrollOffset]);
51+
52+
let lastItems = useRef(items);
53+
useLayoutEffect(() => {
54+
// Only update isLoadingRef if props object actually changed,
55+
// not if a local state change occurred.
56+
if (props !== prevProps.current) {
57+
isLoadingRef.current = isLoading;
58+
prevProps.current = props;
59+
}
60+
61+
// TODO: Eventually this hook will move back into RAC during which we will accept the collection as a option to this hook.
62+
// We will only load more if the collection has changed after the last load to prevent multiple onLoadMore from being called
63+
// while the data from the last onLoadMore is being processed by RAC collection.
64+
let shouldLoadMore = ref?.current
65+
&& !isLoadingRef.current
66+
&& onLoadMore
67+
&& (!items || items !== lastItems.current)
68+
&& ref.current.clientHeight === ref.current.scrollHeight;
69+
70+
if (shouldLoadMore) {
71+
isLoadingRef.current = true;
72+
onLoadMore?.();
73+
}
74+
75+
lastItems.current = items;
76+
}, [isLoading, onLoadMore, props, ref]);
77+
78+
// TODO: maybe this should still just return scroll props?
79+
// Test against case where the ref isn't defined when this is called
80+
// Think this was a problem when trying to attach to the scrollable body of the table in OnLoadMoreTableBodyScroll
81+
useEvent(ref, 'scroll', onScroll);
82+
}

packages/@react-aria/virtualizer/src/Virtualizer.tsx

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

1313
import {Collection, Key, RefObject} from '@react-types/shared';
14-
import {Layout, Rect, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer';
15-
import {mergeProps, useLayoutEffect} from '@react-aria/utils';
14+
import {Layout, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer';
15+
import {mergeProps, useLoadMore} from '@react-aria/utils';
1616
import React, {HTMLAttributes, ReactElement, ReactNode, useCallback, useRef} from 'react';
1717
import {ScrollView} from './ScrollView';
1818
import {VirtualizerItem} from './VirtualizerItem';
@@ -69,11 +69,14 @@ function Virtualizer<T extends object, V extends ReactNode, O>(props: Virtualize
6969
layoutOptions
7070
});
7171

72-
let {virtualizerProps, scrollViewProps} = useVirtualizer(props, state, ref);
72+
useLoadMore({isLoading, onLoadMore, scrollOffset: 1}, ref);
73+
let onVisibleRectChange = useCallback((rect: Rect) => {
74+
state.setVisibleRect(rect);
75+
}, [state]);
7376

7477
return (
7578
<ScrollView
76-
{...mergeProps(otherProps, virtualizerProps, scrollViewProps)}
79+
{...mergeProps(otherProps, {onVisibleRectChange})}
7780
ref={ref}
7881
contentSize={state.contentSize}
7982
onScrollStart={state.startScrolling}
@@ -85,66 +88,6 @@ function Virtualizer<T extends object, V extends ReactNode, O>(props: Virtualize
8588
);
8689
}
8790

88-
interface VirtualizerOptions {
89-
tabIndex?: number,
90-
focusedKey?: Key,
91-
isLoading?: boolean,
92-
onLoadMore?: () => void
93-
}
94-
95-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
96-
export function useVirtualizer<T extends object, V extends ReactNode, W>(props: VirtualizerOptions, state: VirtualizerState<T, V>, ref: RefObject<HTMLElement | null>) {
97-
let {isLoading, onLoadMore} = props;
98-
let {setVisibleRect, virtualizer} = state;
99-
100-
// Handle scrolling, and call onLoadMore when nearing the bottom.
101-
let isLoadingRef = useRef(isLoading);
102-
let prevProps = useRef(props);
103-
let onVisibleRectChange = useCallback((rect: Rect) => {
104-
setVisibleRect(rect);
105-
106-
if (!isLoadingRef.current && onLoadMore) {
107-
let scrollOffset = virtualizer.contentSize.height - rect.height * 2;
108-
if (rect.y > scrollOffset) {
109-
isLoadingRef.current = true;
110-
onLoadMore();
111-
}
112-
}
113-
}, [onLoadMore, setVisibleRect, virtualizer]);
114-
115-
let lastContentSize = useRef(0);
116-
useLayoutEffect(() => {
117-
// Only update isLoadingRef if props object actually changed,
118-
// not if a local state change occurred.
119-
let wasLoading = isLoadingRef.current;
120-
if (props !== prevProps.current) {
121-
isLoadingRef.current = isLoading;
122-
prevProps.current = props;
123-
}
124-
125-
let shouldLoadMore = !isLoadingRef.current
126-
&& onLoadMore
127-
&& state.contentSize.height > 0
128-
&& state.contentSize.height <= state.virtualizer.visibleRect.height
129-
// Only try loading more if the content size changed, or if we just finished
130-
// loading and still have room for more items.
131-
&& (wasLoading || state.contentSize.height !== lastContentSize.current);
132-
133-
if (shouldLoadMore) {
134-
isLoadingRef.current = true;
135-
onLoadMore();
136-
}
137-
lastContentSize.current = state.contentSize.height;
138-
}, [state.contentSize, state.virtualizer, isLoading, onLoadMore, props]);
139-
140-
return {
141-
virtualizerProps: {},
142-
scrollViewProps: {
143-
onVisibleRectChange
144-
}
145-
};
146-
}
147-
14891
// forwardRef doesn't support generic parameters, so cast the result to the correct type
14992
// https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref
15093
const _Virtualizer = React.forwardRef(Virtualizer) as <T extends object, V, O>(props: VirtualizerProps<T, V, O> & {ref?: RefObject<HTMLDivElement | null>}) => ReactElement;

packages/@react-aria/virtualizer/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
export type {RTLOffsetType} from './utils';
1414
export type {VirtualizerItemOptions} from './useVirtualizerItem';
15-
export {useVirtualizer, Virtualizer} from './Virtualizer';
15+
export {Virtualizer} from './Virtualizer';
1616
export {useVirtualizerItem} from './useVirtualizerItem';
1717
export {VirtualizerItem, layoutInfoToStyle} from './VirtualizerItem';
1818
export {ScrollView, useScrollView} from './ScrollView';

packages/@react-spectrum/card/test/CardView.test.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,7 @@ describe('CardView', function () {
11861186
${'Grid layout'} | ${GridLayout}
11871187
${'Gallery layout'} | ${GalleryLayout}
11881188
`('$Name CardView should call loadMore when scrolling to the bottom', async function ({layout}) {
1189+
let scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 3000);
11891190
let onLoadMore = jest.fn();
11901191
let tree = render(<DynamicCardView layout={layout} onLoadMore={onLoadMore} />);
11911192

@@ -1199,13 +1200,8 @@ describe('CardView', function () {
11991200
let grid = tree.getByRole('grid');
12001201
grid.scrollTop = 3000;
12011202
fireEvent.scroll(grid);
1202-
// TODO: look into and address difference in behavior
1203-
let isReact19 = parseInt(React.version, 10) >= 19;
1204-
if (isReact19 && layout !== GridLayout) {
1205-
expect(onLoadMore).toHaveBeenCalledTimes(2);
1206-
} else {
1207-
expect(onLoadMore).toHaveBeenCalledTimes(1);
1208-
}
1203+
expect(onLoadMore).toHaveBeenCalledTimes(1);
1204+
scrollHeightMock.mockReset();
12091205
});
12101206

12111207
it.each`

0 commit comments

Comments
 (0)