Skip to content

feat: add maxHorizontalSpace prop to WaterfallLayout #8731

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion packages/@react-stately/layout/src/GridLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export class GridLayout<T, O extends GridLayoutOptions = GridLayoutOptions> exte
itemHeight = Math.max(minItemSize.height, Math.min(maxItemHeight, itemHeight));

// Compute the horizontal spacing, content height and horizontal margin
let horizontalSpacing = Math.min(maxHorizontalSpace, Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1)));
let horizontalSpacing = Math.min(Math.max(maxHorizontalSpace, minSpace.width), Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1)));
this.gap = new Size(horizontalSpacing, minSpace.height);
this.margin = Math.floor((visibleWidth - numColumns * itemWidth - horizontalSpacing * (numColumns + 1)) / 2);

Expand Down
18 changes: 14 additions & 4 deletions packages/@react-stately/layout/src/WaterfallLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export interface WaterfallLayoutOptions {
* @default 18 x 18
*/
minSpace?: Size,
/**
* The maximum allowed horizontal space between items.
* @default Infinity
*/
maxHorizontalSpace?: number,
/**
* The maximum number of columns.
* @default Infinity
Expand All @@ -55,6 +60,7 @@ const DEFAULT_OPTIONS = {
minItemSize: new Size(200, 200),
maxItemSize: new Size(Infinity, Infinity),
minSpace: new Size(18, 18),
maxSpace: Infinity,
maxColumns: Infinity,
dropIndicatorThickness: 2
};
Expand All @@ -64,20 +70,23 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
private layoutInfos: Map<Key, WaterfallLayoutInfo> = new Map();
protected numColumns = 0;
protected dropIndicatorThickness = 2;
private margin: number = 0;

shouldInvalidateLayoutOptions(newOptions: O, oldOptions: O): boolean {
return newOptions.maxColumns !== oldOptions.maxColumns
|| newOptions.dropIndicatorThickness !== oldOptions.dropIndicatorThickness
|| (!(newOptions.minItemSize || DEFAULT_OPTIONS.minItemSize).equals(oldOptions.minItemSize || DEFAULT_OPTIONS.minItemSize))
|| (!(newOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize).equals(oldOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize))
|| (!(newOptions.minSpace || DEFAULT_OPTIONS.minSpace).equals(oldOptions.minSpace || DEFAULT_OPTIONS.minSpace));
|| (!(newOptions.minSpace || DEFAULT_OPTIONS.minSpace).equals(oldOptions.minSpace || DEFAULT_OPTIONS.minSpace))
|| (newOptions.maxHorizontalSpace !== oldOptions.maxHorizontalSpace);
}

update(invalidationContext: InvalidationContext<O>): void {
let {
minItemSize = DEFAULT_OPTIONS.minItemSize,
maxItemSize = DEFAULT_OPTIONS.maxItemSize,
minSpace = DEFAULT_OPTIONS.minSpace,
maxHorizontalSpace = DEFAULT_OPTIONS.maxSpace,
maxColumns = DEFAULT_OPTIONS.maxColumns,
dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness
} = invalidationContext.layoutOptions || {};
Expand Down Expand Up @@ -107,8 +116,9 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
let itemHeight = minItemSize.height + Math.floor((maxItemHeight - minItemSize.height) * t);
itemHeight = Math.max(minItemSize.height, Math.min(maxItemHeight, itemHeight));

// Compute the horizontal spacing and content height
let horizontalSpacing = Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1));
// Compute the horizontal spacing, content height and horizontal margin
let horizontalSpacing = Math.min(Math.max(maxHorizontalSpace, minSpace.width), Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1)));
this.margin = Math.floor((visibleWidth - numColumns * itemWidth - horizontalSpacing * (numColumns + 1)) / 2);

// Setup an array of column heights
let columnHeights = Array(numColumns).fill(minSpace.height);
Expand All @@ -126,7 +136,7 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
// Preserve the previous column index so items don't jump around during resizing unless the number of columns changed.
let prevColumn = numColumns === this.numColumns && oldLayoutInfo && oldLayoutInfo.rect.y < this.virtualizer!.visibleRect.maxY ? oldLayoutInfo.column : undefined;
let column = prevColumn ?? columnHeights.reduce((minIndex, h, i) => h < columnHeights[minIndex] ? i : minIndex, 0);
let x = horizontalSpacing + column * (itemWidth + horizontalSpacing);
let x = horizontalSpacing + column * (itemWidth + horizontalSpacing) + this.margin;
let y = columnHeights[column];

let rect = new Rect(x, y, itemWidth, height);
Expand Down
10 changes: 9 additions & 1 deletion packages/react-aria-components/stories/GridList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export const VirtualizedGridList: StoryObj<typeof VirtualizedGridListRender> = {
};

interface VirtualizedGridListGridProps {
minItemSizeWidth?: number,
maxItemSizeWidth?: number,
maxColumns?: number,
minHorizontalSpace?: number,
Expand All @@ -209,6 +210,7 @@ interface VirtualizedGridListGridProps {

export let VirtualizedGridListGrid: StoryFn<VirtualizedGridListGridProps> = (args) => {
const {
minItemSizeWidth = 40,
maxItemSizeWidth = 65,
maxColumns = Infinity,
minHorizontalSpace = 0,
Expand All @@ -223,7 +225,7 @@ export let VirtualizedGridListGrid: StoryFn<VirtualizedGridListGridProps> = (arg
<Virtualizer
layout={GridLayout}
layoutOptions={{
minItemSize: new Size(40, 40),
minItemSize: new Size(minItemSizeWidth, 40),
maxItemSize: new Size(maxItemSizeWidth, 40),
minSpace: new Size(minHorizontalSpace, 18),
maxColumns,
Expand All @@ -238,12 +240,18 @@ export let VirtualizedGridListGrid: StoryFn<VirtualizedGridListGridProps> = (arg

VirtualizedGridListGrid.story = {
args: {
minItemSizeWidth: 40,
maxItemSizeWidth: 65,
maxColumns: undefined,
minHorizontalSpace: 0,
maxHorizontalSpace: undefined
},
argTypes: {
minItemSizeWidth: {
control: 'number',
description: 'The minimum width of each item in the grid list',
defaultValue: 40
},
maxItemSizeWidth: {
control: 'number',
description: 'Maximum width of each item in the grid list.',
Expand Down
50 changes: 47 additions & 3 deletions packages/react-aria-components/stories/ListBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ export const VirtualizedListBoxGrid: StoryObj<typeof VirtualizedListBoxGridExamp

let lorem = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'.split(' ');

export function VirtualizedListBoxWaterfall({minSize = 80, maxSize = 100}: {minSize: number, maxSize: number}): JSX.Element {
function VirtualizedListBoxWaterfallExample({minSize = 40, maxSize = 65, maxColumns = undefined, minSpace = undefined, maxSpace = undefined}: {minSize: number, maxSize: number, maxColumns?: number, minSpace?: number, maxSpace?: number}): JSX.Element {
let items: {id: number, name: string}[] = [];
for (let i = 0; i < 1000; i++) {
let words = Math.max(2, Math.floor(Math.random() * 25));
Expand All @@ -527,8 +527,11 @@ export function VirtualizedListBoxWaterfall({minSize = 80, maxSize = 100}: {minS
<Virtualizer
layout={WaterfallLayout}
layoutOptions={{
minItemSize: new Size(minSize, minSize),
maxItemSize: new Size(maxSize, maxSize)
minItemSize: new Size(minSize, 40),
maxItemSize: new Size(maxSize, 65),
maxColumns,
minSpace: new Size(minSpace, 18),
maxHorizontalSpace: maxSpace
}}>
<ListBox
className={styles.menu}
Expand All @@ -545,6 +548,47 @@ export function VirtualizedListBoxWaterfall({minSize = 80, maxSize = 100}: {minS
);
}

export const VirtualizedListBoxWaterfall: StoryObj<typeof VirtualizedListBoxWaterfallExample> = {
render: (args) => {
return <VirtualizedListBoxWaterfallExample {...args} />;
},
args: {
minSize: 40,
maxSize: 65,
maxColumns: undefined,
minSpace: undefined,
maxSpace: undefined
},
argTypes: {
minSize: {
control: 'number',
description: 'The minimum width of each item in the grid list',
defaultValue: 40
},
maxSize: {
control: 'number',
description: 'Maximum width of each item in the grid list.',
defaultValue: 65
},
maxColumns: {
control: 'number',
description: 'Maximum number of columns in the grid list.',
defaultValue: undefined
},
minSpace: {
control: 'number',
description: 'Minimum horizontal space between grid items.',
defaultValue: undefined
},
maxSpace: {
control: 'number',
description: 'Maximum horizontal space between grid items.',
defaultValue: undefined
}
}
};


let renderEmptyState = ({isLoading}) => {
return (
<div style={{height: 30, width: '100%'}}>
Expand Down