Skip to content

Commit 98621d9

Browse files
committed
feat(RAC): add loaderHeight prop to GridLayout and WaterfallLayout
1 parent 6e9ba6e commit 98621d9

File tree

3 files changed

+153
-15
lines changed

3 files changed

+153
-15
lines changed

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ export interface GridLayoutOptions {
5050
* The thickness of the drop indicator.
5151
* @default 2
5252
*/
53-
dropIndicatorThickness?: number
53+
dropIndicatorThickness?: number,
54+
/**
55+
* The fixed height of a loader element in px. This loader is specifically for
56+
* "load more" elements rendered when loading more rows at the root level or inside nested row/sections.
57+
* @default 48
58+
*/
59+
loaderHeight?: number
5460
}
5561

5662
const DEFAULT_OPTIONS = {
@@ -60,7 +66,8 @@ const DEFAULT_OPTIONS = {
6066
minSpace: new Size(18, 18),
6167
maxSpace: Infinity,
6268
maxColumns: Infinity,
63-
dropIndicatorThickness: 2
69+
dropIndicatorThickness: 2,
70+
loaderHeight: 48
6471
};
6572

6673
/**
@@ -84,7 +91,8 @@ export class GridLayout<T, O extends GridLayoutOptions = GridLayoutOptions> exte
8491
|| (!(newOptions.minItemSize || DEFAULT_OPTIONS.minItemSize).equals(oldOptions.minItemSize || DEFAULT_OPTIONS.minItemSize))
8592
|| (!(newOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize).equals(oldOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize))
8693
|| (!(newOptions.minSpace || DEFAULT_OPTIONS.minSpace).equals(oldOptions.minSpace || DEFAULT_OPTIONS.minSpace))
87-
|| newOptions.maxHorizontalSpace !== oldOptions.maxHorizontalSpace;
94+
|| newOptions.maxHorizontalSpace !== oldOptions.maxHorizontalSpace
95+
|| newOptions.loaderHeight !== oldOptions.loaderHeight;
8896
}
8997

9098
update(invalidationContext: InvalidationContext<O>): void {
@@ -95,7 +103,8 @@ export class GridLayout<T, O extends GridLayoutOptions = GridLayoutOptions> exte
95103
minSpace = DEFAULT_OPTIONS.minSpace,
96104
maxHorizontalSpace = DEFAULT_OPTIONS.maxSpace,
97105
maxColumns = DEFAULT_OPTIONS.maxColumns,
98-
dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness
106+
dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness,
107+
loaderHeight = DEFAULT_OPTIONS.loaderHeight
99108
} = invalidationContext.layoutOptions || {};
100109
this.dropIndicatorThickness = dropIndicatorThickness;
101110

@@ -209,9 +218,16 @@ export class GridLayout<T, O extends GridLayoutOptions = GridLayoutOptions> exte
209218
// Always add the loader sentinel if present in the collection so we can make sure it is never virtualized out.
210219
let lastNode = collection.getItem(collection.getLastKey()!);
211220
if (lastNode?.type === 'loader') {
212-
let rect = new Rect(horizontalSpacing, y, itemWidth, 0);
221+
if (skeletonCount > 0 || !lastNode.props.isLoading) {
222+
loaderHeight = 0;
223+
}
224+
const loaderWidth = visibleWidth - horizontalSpacing * 2;
225+
// Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve
226+
// room for the loader alongside rendering the emptyState
227+
let rect = new Rect(horizontalSpacing, y, loaderWidth, loaderHeight);
213228
let layoutInfo = new LayoutInfo('loader', lastNode.key, rect);
214229
newLayoutInfos.set(lastNode.key, layoutInfo);
230+
y = layoutInfo.rect.maxY;
215231
}
216232

217233
this.layoutInfos = newLayoutInfos;

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

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@ export interface WaterfallLayoutOptions {
4343
* The thickness of the drop indicator.
4444
* @default 2
4545
*/
46-
dropIndicatorThickness?: number
46+
dropIndicatorThickness?: number,
47+
/**
48+
* The fixed height of a loader element in px. This loader is specifically for
49+
* "load more" elements rendered when loading more rows at the root level or inside nested row/sections.
50+
* @default 48
51+
*/
52+
loaderHeight?: number
4753
}
4854

4955
class WaterfallLayoutInfo extends LayoutInfo {
@@ -64,7 +70,8 @@ const DEFAULT_OPTIONS = {
6470
minSpace: new Size(18, 18),
6571
maxSpace: Infinity,
6672
maxColumns: Infinity,
67-
dropIndicatorThickness: 2
73+
dropIndicatorThickness: 2,
74+
loaderHeight: 48
6875
};
6976

7077
export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions = WaterfallLayoutOptions> extends Layout<Node<T>, O> implements LayoutDelegate, DropTargetDelegate {
@@ -80,7 +87,8 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
8087
|| (!(newOptions.minItemSize || DEFAULT_OPTIONS.minItemSize).equals(oldOptions.minItemSize || DEFAULT_OPTIONS.minItemSize))
8188
|| (!(newOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize).equals(oldOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize))
8289
|| (!(newOptions.minSpace || DEFAULT_OPTIONS.minSpace).equals(oldOptions.minSpace || DEFAULT_OPTIONS.minSpace))
83-
|| (newOptions.maxHorizontalSpace !== oldOptions.maxHorizontalSpace);
90+
|| (newOptions.maxHorizontalSpace !== oldOptions.maxHorizontalSpace)
91+
|| newOptions.loaderHeight !== oldOptions.loaderHeight;
8492
}
8593

8694
update(invalidationContext: InvalidationContext<O>): void {
@@ -90,7 +98,8 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
9098
minSpace = DEFAULT_OPTIONS.minSpace,
9199
maxHorizontalSpace = DEFAULT_OPTIONS.maxSpace,
92100
maxColumns = DEFAULT_OPTIONS.maxColumns,
93-
dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness
101+
dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness,
102+
loaderHeight = DEFAULT_OPTIONS.loaderHeight
94103
} = invalidationContext.layoutOptions || {};
95104
this.dropIndicatorThickness = dropIndicatorThickness;
96105
let visibleWidth = this.virtualizer!.visibleRect.width;
@@ -174,19 +183,27 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
174183
}
175184
}
176185

186+
// Reset all columns to the maximum for the next section. If loading, set to 0 so virtualizer doesn't render its body since there aren't items to render,
187+
// except if we are performing skeleton loading
188+
let isEmptyOrLoading = collection?.size === 0 && collection.getItem(collection.getFirstKey()!)?.type !== 'skeleton';
189+
let maxHeight = isEmptyOrLoading ? 0 : Math.max(...columnHeights);
190+
177191
// Always add the loader sentinel if present in the collection so we can make sure it is never virtualized out.
178192
// Add it under the first column for simplicity
179193
let lastNode = collection.getItem(collection.getLastKey()!);
180194
if (lastNode?.type === 'loader') {
181-
let rect = new Rect(horizontalSpacing, columnHeights[0], itemWidth, 0);
195+
if (skeletonCount > 0 || !lastNode.props.isLoading) {
196+
loaderHeight = 0;
197+
}
198+
const loaderWidth = visibleWidth - horizontalSpacing * 2;
199+
// Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve
200+
// room for the loader alongside rendering the emptyState
201+
let rect = new Rect(horizontalSpacing, maxHeight, loaderWidth, loaderHeight);
182202
let layoutInfo = new LayoutInfo('loader', lastNode.key, rect);
183203
newLayoutInfos.set(lastNode.key, layoutInfo);
204+
maxHeight = layoutInfo.rect.maxY;
184205
}
185206

186-
// Reset all columns to the maximum for the next section. If loading, set to 0 so virtualizer doesn't render its body since there aren't items to render,
187-
// except if we are performing skeleton loading
188-
let isEmptyOrLoading = collection?.size === 0 && collection.getItem(collection.getFirstKey()!)?.type !== 'skeleton';
189-
let maxHeight = isEmptyOrLoading ? 0 : Math.max(...columnHeights);
190207
this.contentSize = new Size(this.virtualizer!.visibleRect.width, maxHeight);
191208
this.layoutInfos = newLayoutInfos;
192209
this.numColumns = numColumns;

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

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ import {
3737
Tag,
3838
TagGroup,
3939
TagList,
40+
Text,
4041
useDragAndDrop,
41-
Virtualizer
42+
Virtualizer,
43+
WaterfallLayout
4244
} from 'react-aria-components';
4345
import {classNames} from '@react-spectrum/utils';
4446
import {Key, useAsyncList, useListData} from 'react-stately';
@@ -47,6 +49,7 @@ import {Meta, StoryFn, StoryObj} from '@storybook/react';
4749
import React, {JSX, useState} from 'react';
4850
import styles from '../example/index.css';
4951
import './styles.css';
52+
import {LoadingState} from '@react-types/shared';
5053

5154
export default {
5255
title: 'React Aria Components/GridList',
@@ -636,3 +639,105 @@ export let GridListInModalPicker: StoryObj<typeof GridListInModalPickerRender> =
636639
}
637640
};
638641

642+
type Item = {
643+
id: number,
644+
user: {
645+
name: string,
646+
profile_image: { small: string }
647+
},
648+
urls: { regular: string },
649+
description: string,
650+
alt_description: string,
651+
width: number,
652+
height: number
653+
};
654+
655+
interface AsyncGridListGridVirtualizedRenderProps {
656+
delay: number,
657+
layout: 'grid' | 'waterfall',
658+
loaderHeight: number,
659+
loadingState: LoadingState
660+
}
661+
662+
function AsyncGridListGridVirtualizedRender(props: AsyncGridListGridVirtualizedRenderProps) {
663+
const list = useAsyncList<Item, number | null>({
664+
async load({cursor, items, signal}) {
665+
const page = cursor || 1;
666+
await new Promise((resolve) => setTimeout(resolve, props.delay));
667+
const res = await fetch(
668+
`https://api.unsplash.com/topics/nature/photos?page=${page}&per_page=30&client_id=AJuU-FPh11hn7RuumUllp4ppT8kgiLS7LtOHp_sp4nc`,
669+
{signal}
670+
);
671+
let nextItems = await res.json();
672+
// Filter duplicates which might be returned by the API.
673+
const existingKeys = new Set(items.map((i) => i.id));
674+
nextItems = nextItems.filter((i) => !existingKeys.has(i.id) && (i.description || i.alt_description));
675+
return {cursor: nextItems.length ? page + 1 : null, items: nextItems};
676+
}
677+
});
678+
const layout = props.layout === 'waterfall' ? WaterfallLayout : GridLayout;
679+
const loadingState = props.loadingState === 'idle' ? list.loadingState : props.loadingState;
680+
681+
return (
682+
<Virtualizer
683+
layout={layout}
684+
layoutOptions={{
685+
loaderHeight: props.loaderHeight,
686+
maxItemSize: new Size(140, 140),
687+
minItemSize: new Size(100, 100),
688+
minSpace: new Size(6, 6)
689+
}}>
690+
<GridList
691+
aria-label="async virtualized gridlist"
692+
className={styles.menu}
693+
layout="grid"
694+
renderEmptyState={() => renderEmptyState({isLoading: list.isLoading})}
695+
style={{height: 400, width: 400}}>
696+
<Collection items={list.items}>
697+
{(item) => (
698+
<GridListItem
699+
style={{display: 'flex', flexDirection: 'column'}}
700+
textValue={item.description || item.alt_description}>
701+
<img
702+
alt=""
703+
width={item.width}
704+
height={item.height}
705+
src={item.urls.regular}
706+
style={{height: 200, objectFit: 'cover', width: '100%'}} />
707+
<Text slot="description">By {item.user.name}</Text>
708+
</GridListItem>
709+
)}
710+
</Collection>
711+
<MyGridListLoaderIndicator
712+
isLoading={loadingState === 'loadingMore'}
713+
onLoadMore={loadingState === 'idle' ? list.loadMore : undefined} />
714+
</GridList>
715+
</Virtualizer>
716+
);
717+
}
718+
719+
export const AsyncGridListGridVirtualized: StoryObj<typeof AsyncGridListGridVirtualizedRender> = {
720+
render: AsyncGridListGridVirtualizedRender,
721+
args: {
722+
delay: 50,
723+
layout: 'grid',
724+
loaderHeight: 30,
725+
loadingState: 'idle'
726+
},
727+
argTypes: {
728+
delay: {
729+
control: 'number'
730+
},
731+
layout: {
732+
control: 'select',
733+
options: ['grid', 'waterfall']
734+
},
735+
loaderHeight: {
736+
control: 'number'
737+
},
738+
loadingState: {
739+
control: 'select',
740+
options: ['idle', 'loadingMore']
741+
}
742+
}
743+
};

0 commit comments

Comments
 (0)