diff --git a/packages/dev/s2-docs/pages/react-aria/Virtualizer.mdx b/packages/dev/s2-docs/pages/react-aria/Virtualizer.mdx
index bd9383b498e..1b235bbde75 100644
--- a/packages/dev/s2-docs/pages/react-aria/Virtualizer.mdx
+++ b/packages/dev/s2-docs/pages/react-aria/Virtualizer.mdx
@@ -326,7 +326,7 @@ for (let i = 0; imageOptions.length < 500; i++) {
### Grid
-`GridLayout` supports layout of items in an equal size grid. The items are sized between a minimum and maximum size depending on the width of the container. Make sure to set `layout="grid"` on the `ListBox` or `GridList` component as well so that keyboard navigation behavior is correct.
+`GridLayout` supports layout of items in an equal size grid. The items are sized between a minimum and maximum size depending on the width of the container. Make sure to set `layout="grid"` on the `ListBox` or `GridList` component as well so that keyboard navigation behavior is correct. Sections are also supported, with section headers laid out as full width rows between the grid items. Use the `headingSize` option to provide a fixed header height, or `estimatedHeadingSize` if header heights are variable.
```tsx render docs={docs.exports.GridLayoutOptions} links={docs.links} props={['minItemSize', 'maxItemSize', 'minSpace', 'maxColumns', 'preserveAspectRatio']} initialProps={{minItemSize: {width: 100, height: 140}, minSpace: {width: 8, height: 8}}} propsObject="layoutOptions" wide
"use client";
diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx
index 5c3ba1cf921..9aea62d538e 100644
--- a/packages/react-aria-components/stories/GridList.stories.tsx
+++ b/packages/react-aria-components/stories/GridList.stories.tsx
@@ -509,6 +509,53 @@ VirtualizedGridListGrid.story = {
}
};
+export function VirtualizedGridListGridSection(): JSX.Element {
+ let sections: {
+ id: string;
+ name: string;
+ children: {id: string; name: string}[];
+ }[] = [];
+ for (let s = 0; s < 10; s++) {
+ let items: {id: string; name: string}[] = [];
+ for (let i = 0; i < 25; i++) {
+ items.push({id: `item_${s}_${i}`, name: `Item ${s},${i}`});
+ }
+ sections.push({
+ id: `section_${s}`,
+ name: `Section ${s}`,
+ children: items
+ });
+ }
+
+ return (
+
+
+ {section => (
+
+ {section.name}
+
+ {item => {item.name}}
+
+
+ )}
+
+
+ );
+}
+
function VirtualizedGridDnD() {
let initialItems: {id: number; name: string}[] = [];
for (let i = 0; i < 50; i++) {
diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js
index ebac9a247e8..2e5f7f16bd9 100644
--- a/packages/react-aria-components/test/GridList.test.js
+++ b/packages/react-aria-components/test/GridList.test.js
@@ -26,6 +26,7 @@ import {ComboBox} from '../src/ComboBox';
import {Dialog, DialogTrigger} from '../src/Dialog';
import {DropIndicator, useDragAndDrop} from '../src/useDragAndDrop';
import {getFocusableTreeWalker} from 'react-aria/private/focus/FocusScope';
+import {GridLayout, ListLayout, Size} from 'react-stately/useVirtualizerState';
import {
GridList,
GridListContext,
@@ -39,7 +40,6 @@ import {Input} from '../src/Input';
import {installPointerEvent, User} from '@react-aria/test-utils';
import {Label} from '../src/Label';
import {ListBox, ListBoxItem} from '../src/ListBox';
-import {ListLayout} from 'react-stately/useVirtualizerState';
import {Modal} from '../src/Modal';
import {Popover} from '../src/Popover';
import React from 'react';
@@ -1042,6 +1042,129 @@ describe('GridList', () => {
]);
});
+ describe('virtualized grid layout with sections', () => {
+ let sections = [];
+ for (let s = 0; s < 2; s++) {
+ let items = [];
+ for (let i = 0; i < 6; i++) {
+ let id = s * 6 + i;
+ items.push({id, name: `Item ${id}`});
+ }
+ sections.push({id: `section-${s}`, name: `Section ${s}`, children: items});
+ }
+
+ let renderSectionedGrid = () =>
+ render(
+
+
+ {section => (
+
+ {section.name}
+
+ {item => {item.name}}
+
+
+ )}
+
+
+ );
+
+ it('should render section headers as rows and virtualize offscreen sections', () => {
+ let clientWidth = jest
+ .spyOn(window.HTMLElement.prototype, 'clientWidth', 'get')
+ .mockImplementation(() => 330);
+ let clientHeight = jest
+ .spyOn(window.HTMLElement.prototype, 'clientHeight', 'get')
+ .mockImplementation(() => 200);
+
+ let {getByRole, getAllByRole} = renderSectionedGrid();
+
+ let groups = getAllByRole('rowgroup');
+ expect(groups).toHaveLength(2);
+
+ // With a 330x200 viewport and 100px items, section 1 (header + 2 rows of 3 items)
+ // is fully visible. Section 2 begins below the overscanned rect, so only its
+ // header is added (headers of visible sections are always included).
+ let rows = getAllByRole('row');
+ expect(rows.map(r => r.textContent)).toEqual([
+ 'Section 0',
+ 'Item 0',
+ 'Item 1',
+ 'Item 2',
+ 'Item 3',
+ 'Item 4',
+ 'Item 5',
+ 'Section 1'
+ ]);
+
+ // Items in the second section appear after scrolling, and the first
+ // section is virtualized out.
+ let grid = getByRole('grid');
+ grid.scrollTop = 300;
+ fireEvent.scroll(grid);
+
+ rows = getAllByRole('row');
+ expect(rows.map(r => r.textContent)).toEqual([
+ 'Section 1',
+ 'Item 6',
+ 'Item 7',
+ 'Item 8',
+ 'Item 9',
+ 'Item 10',
+ 'Item 11'
+ ]);
+
+ clientWidth.mockRestore();
+ clientHeight.mockRestore();
+ });
+
+ it('should support keyboard navigation across section boundaries', async () => {
+ let clientWidth = jest
+ .spyOn(window.HTMLElement.prototype, 'clientWidth', 'get')
+ .mockImplementation(() => 330);
+ let clientHeight = jest
+ .spyOn(window.HTMLElement.prototype, 'clientHeight', 'get')
+ .mockImplementation(() => 200);
+
+ renderSectionedGrid();
+
+ await user.tab();
+ expect(document.activeElement).toHaveTextContent('Item 0');
+
+ // Down within the first section.
+ await user.keyboard('{ArrowDown}');
+ expect(document.activeElement).toHaveTextContent('Item 3');
+
+ // Down across the section boundary keeps the same column,
+ // skipping the section header.
+ await user.keyboard('{ArrowDown}');
+ expect(document.activeElement).toHaveTextContent('Item 6');
+
+ await user.keyboard('{ArrowRight}');
+ expect(document.activeElement).toHaveTextContent('Item 7');
+
+ // Back up across the section boundary.
+ await user.keyboard('{ArrowUp}');
+ expect(document.activeElement).toHaveTextContent('Item 4');
+
+ // Jump to the last item, which is outside the visible rect.
+ await user.keyboard('{End}');
+ expect(document.activeElement).toHaveTextContent('Item 11');
+
+ clientWidth.mockRestore();
+ clientHeight.mockRestore();
+ });
+ });
+
it('should support rendering a TagGroup inside a GridListItem', async () => {
let buttonRef = React.createRef();
let {getAllByRole} = render(
diff --git a/packages/react-stately/src/layout/GridLayout.ts b/packages/react-stately/src/layout/GridLayout.ts
index 160040f13ff..7702620688a 100644
--- a/packages/react-stately/src/layout/GridLayout.ts
+++ b/packages/react-stately/src/layout/GridLayout.ts
@@ -11,6 +11,7 @@
*/
import {DropTarget, DropTargetDelegate, ItemDropTarget, Key, Node} from '@react-types/shared';
+import {getChildNodes} from '../collections/getChildNodes';
import {InvalidationContext} from '../virtualizer/types';
import {Layout} from '../virtualizer/Layout';
import {LayoutInfo} from '../virtualizer/LayoutInfo';
@@ -69,6 +70,17 @@ export interface GridLayoutOptions {
* @default 48
*/
loaderHeight?: number;
+ /**
+ * The fixed height of a section header in px. Section headers are laid out as
+ * full width rows interleaved with the grid items.
+ *
+ * @default 48
+ */
+ headingSize?: number;
+ /**
+ * The estimated height of a section header in px, when heading sizes are variable.
+ */
+ estimatedHeadingSize?: number;
/**
* The layout direction. When `'rtl'`, drop target positions (`'before'`/`'after'`)
* are computed correctly for right-to-left locales in multi-column grids.
@@ -89,6 +101,8 @@ const DEFAULT_OPTIONS = {
loaderHeight: 48
};
+const DEFAULT_HEADING_SIZE = 48;
+
/**
* GridLayout is a virtualizer Layout implementation
* that arranges its items in a grid.
@@ -123,6 +137,8 @@ export class GridLayout
) ||
newOptions.maxHorizontalSpace !== oldOptions.maxHorizontalSpace ||
newOptions.loaderHeight !== oldOptions.loaderHeight ||
+ newOptions.headingSize !== oldOptions.headingSize ||
+ newOptions.estimatedHeadingSize !== oldOptions.estimatedHeadingSize ||
newOptions.direction !== oldOptions.direction
);
}
@@ -137,6 +153,8 @@ export class GridLayout
maxColumns = DEFAULT_OPTIONS.maxColumns,
dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness,
loaderHeight = DEFAULT_OPTIONS.loaderHeight,
+ headingSize = null,
+ estimatedHeadingSize = null,
direction = 'ltr' as const
} = invalidationContext.layoutOptions || {};
this.dropIndicatorThickness = dropIndicatorThickness;
@@ -178,89 +196,178 @@ export class GridLayout
(virtualizerWidth - numColumns * itemWidth - horizontalSpacing * (numColumns + 1)) / 2
);
- // If there is a skeleton loader within the last 2 items in the collection, increment the collection size
- // so that an additional row is added for the skeletons.
let collection = this.virtualizer!.collection;
- let collectionSize = collection.size;
- let lastKey = collection.getLastKey();
- for (let i = 0; i < 2 && lastKey != null; i++) {
- let item = collection.getItem(lastKey);
- if (item?.type === 'skeleton') {
- collectionSize++;
- break;
- }
- lastKey = collection.getKeyBefore(lastKey);
- }
-
- let rows = Math.ceil(collectionSize / numColumns);
- let iterator = collection[Symbol.iterator]();
- let y = rows > 0 ? minSpace.height : 0;
- let newLayoutInfos = new Map();
+ let y = collection.size > 0 ? minSpace.height : 0;
+ let newLayoutInfos = new Map();
let skeleton: Node | null = null;
let skeletonCount = 0;
- for (let row = 0; row < rows; row++) {
+
+ // The cells in the current (unfinished) row of the grid.
+ let rowLayoutInfos: LayoutInfo[] = [];
+ // The number of grid slots used in the current row. This can be greater than
+ // rowLayoutInfos.length because loaders consume a slot without producing a cell.
+ let colIndex = 0;
+
+ let finishRow = () => {
+ if (colIndex === 0) {
+ return;
+ }
+
let maxHeight = 0;
- let rowLayoutInfos: LayoutInfo[] = [];
- for (let col = 0; col < numColumns; col++) {
- // Repeat skeleton until the end of the current row.
- let node = skeleton || iterator.next().value;
- if (!node) {
- break;
- }
+ for (let layoutInfo of rowLayoutInfos) {
+ maxHeight = Math.max(maxHeight, layoutInfo.rect.height);
+ }
- // We will add the loader after the skeletons so skip here
- if (node.type === 'loader') {
- continue;
- }
+ for (let layoutInfo of rowLayoutInfos) {
+ layoutInfo.rect.height = maxHeight;
+ }
- if (node.type === 'skeleton') {
- skeleton = node;
- }
+ y += maxHeight + minSpace.height;
+ rowLayoutInfos = [];
+ colIndex = 0;
+ };
- let key = skeleton ? `${skeleton.key}-${skeletonCount++}` : node.key;
- let oldLayoutInfo = this.layoutInfos.get(key);
- let content = node;
- if (skeleton) {
- content =
- oldLayoutInfo && oldLayoutInfo.content.key === key
- ? oldLayoutInfo.content
- : {...skeleton, key};
- }
- let x = horizontalSpacing + col * (itemWidth + horizontalSpacing) + this.margin;
- let height = itemHeight;
- let estimatedSize = !preserveAspectRatio;
- if (oldLayoutInfo && estimatedSize) {
+ let layoutCell = (node: Node, parentKey: Key | null) => {
+ let key = skeleton ? `${skeleton.key}-${skeletonCount++}` : node.key;
+ let oldLayoutInfo = this.layoutInfos.get(key);
+ let content = node;
+ if (skeleton) {
+ content =
+ oldLayoutInfo && oldLayoutInfo.content.key === key
+ ? oldLayoutInfo.content
+ : {...skeleton, key};
+ }
+ let x = horizontalSpacing + colIndex * (itemWidth + horizontalSpacing) + this.margin;
+ let height = itemHeight;
+ let estimatedSize = !preserveAspectRatio;
+ if (oldLayoutInfo && estimatedSize) {
+ height = oldLayoutInfo.rect.height;
+ estimatedSize =
+ invalidationContext.layoutOptionsChanged ||
+ invalidationContext.sizeChanged ||
+ oldLayoutInfo.estimatedSize ||
+ oldLayoutInfo.content !== content;
+ }
+
+ let rect = new Rect(x, y, itemWidth, height);
+ let layoutInfo = new LayoutInfo(node.type, key, rect);
+ layoutInfo.estimatedSize = estimatedSize;
+ layoutInfo.allowOverflow = true;
+ layoutInfo.content = content;
+ layoutInfo.parentKey = parentKey;
+ newLayoutInfos.set(key, layoutInfo);
+ rowLayoutInfos.push(layoutInfo);
+ colIndex++;
+ if (colIndex === numColumns) {
+ finishRow();
+ }
+ };
+
+ // Section headers are laid out as full width rows, aligned with the grid content.
+ let headerX = this.margin + horizontalSpacing;
+ let headerWidth = virtualizerWidth - headerX * 2;
+
+ let layoutHeader = (node: Node, parentKey: Key | null) => {
+ finishRow();
+ let height = headingSize;
+ let estimatedSize = false;
+ if (height == null) {
+ // If no explicit height is available, reuse the last measured height,
+ // or use an estimated height until the actual size is measured.
+ let oldLayoutInfo = this.layoutInfos.get(node.key);
+ if (oldLayoutInfo) {
height = oldLayoutInfo.rect.height;
estimatedSize =
invalidationContext.layoutOptionsChanged ||
invalidationContext.sizeChanged ||
oldLayoutInfo.estimatedSize ||
- oldLayoutInfo.content !== content;
+ oldLayoutInfo.content !== node;
+ } else {
+ height = node.rendered ? (estimatedHeadingSize ?? DEFAULT_HEADING_SIZE) : 0;
+ estimatedSize = true;
}
+ }
- let rect = new Rect(x, y, itemWidth, height);
- let layoutInfo = new LayoutInfo(node.type, key, rect);
- layoutInfo.estimatedSize = estimatedSize;
- layoutInfo.allowOverflow = true;
- layoutInfo.content = content;
- newLayoutInfos.set(key, layoutInfo);
- rowLayoutInfos.push(layoutInfo);
+ let rect = new Rect(headerX, y, headerWidth, height);
+ let layoutInfo = new LayoutInfo('header', node.key, rect);
+ layoutInfo.estimatedSize = estimatedSize;
+ layoutInfo.allowOverflow = true;
+ layoutInfo.content = node;
+ layoutInfo.parentKey = parentKey;
+ newLayoutInfos.set(node.key, layoutInfo);
+ y = rect.maxY + (height > 0 ? minSpace.height : 0);
+ };
- maxHeight = Math.max(maxHeight, layoutInfo.rect.height);
+ let layoutLoader = (node: Node, parentKey: Key | null) => {
+ finishRow();
+ let height = node.props.isLoading ? loaderHeight : 0;
+ let rect = new Rect(headerX, y, headerWidth, height);
+ let layoutInfo = new LayoutInfo('loader', node.key, rect);
+ layoutInfo.parentKey = parentKey;
+ newLayoutInfos.set(node.key, layoutInfo);
+ if (height > 0) {
+ y = rect.maxY + minSpace.height;
}
+ };
- for (let layoutInfo of rowLayoutInfos) {
- layoutInfo.rect.height = maxHeight;
+ let layoutSection = (node: Node) => {
+ finishRow();
+ let sectionY = y;
+ let rect = new Rect(0, sectionY, virtualizerWidth, 0);
+ let layoutInfo = new LayoutInfo(node.type, node.key, rect);
+ layoutInfo.allowOverflow = true;
+ // Add the section before its children so the map stays in topological order.
+ newLayoutInfos.set(node.key, layoutInfo);
+
+ for (let child of getChildNodes(node, collection)) {
+ switch (child.type) {
+ case 'header':
+ layoutHeader(child, node.key);
+ break;
+ case 'loader':
+ layoutLoader(child, node.key);
+ break;
+ default:
+ layoutCell(child, node.key);
+ }
}
- y += maxHeight + minSpace.height;
+ finishRow();
+
+ // Exclude the trailing gap after the last row from the section height.
+ rect.height = Math.max(0, y - minSpace.height - sectionY);
+ };
- // Keep adding skeleton rows until we fill the viewport
- if (skeleton && row === rows - 1 && y < this.virtualizer!.size.height) {
- rows++;
+ for (let node of collection) {
+ if (node.type === 'section') {
+ layoutSection(node);
+ } else if (node.type === 'header') {
+ layoutHeader(node, null);
+ } else if (node.type === 'loader') {
+ // The loader is added after all other items, but still consumes a grid slot.
+ colIndex++;
+ if (colIndex === numColumns) {
+ finishRow();
+ }
+ } else if (node.type === 'skeleton') {
+ // Repeat the skeleton in every remaining cell, stopping below.
+ skeleton = node;
+ break;
+ } else {
+ layoutCell(node, null);
}
}
+ if (skeleton) {
+ // Fill the remainder of the current row with skeletons, and keep
+ // adding skeleton rows until we fill the viewport.
+ do {
+ layoutCell(skeleton, null);
+ } while (colIndex !== 0 || y < this.virtualizer!.size.height);
+ } else {
+ finishRow();
+ }
+
// Always add the loader sentinel if present in the collection so we can make sure it is never virtualized out.
let lastNode = collection.getItem(collection.getLastKey()!);
if (lastNode?.type === 'loader') {
@@ -290,12 +397,22 @@ export class GridLayout
getVisibleLayoutInfos(rect: Rect): LayoutInfo[] {
let layoutInfos: LayoutInfo[] = [];
+ // Layout infos within a section are always preceded by their section in the map,
+ // so the result stays in topological order (parents before children).
+ let visibleSections = new Set();
for (let layoutInfo of this.layoutInfos.values()) {
+ let isInVisibleSection =
+ layoutInfo.parentKey == null || visibleSections.has(layoutInfo.parentKey);
if (
layoutInfo.rect.intersects(rect) ||
this.virtualizer!.isPersistedKey(layoutInfo.key) ||
- layoutInfo.type === 'loader'
+ // Loaders and section headers are always visible within their section,
+ // e.g. to support load more sentinels and sticky headers.
+ ((layoutInfo.type === 'loader' || layoutInfo.type === 'header') && isInVisibleSection)
) {
+ if (layoutInfo.type === 'section') {
+ visibleSections.add(layoutInfo.key);
+ }
layoutInfos.push(layoutInfo);
}
}
@@ -350,8 +467,13 @@ export class GridLayout
let candidates = this.getVisibleLayoutInfos(searchRect);
let minDistance = Infinity;
for (let candidate of candidates) {
- // Ignore items outside the search rect, e.g. persisted keys.
- if (!candidate.rect.intersects(searchRect)) {
+ // Ignore items outside the search rect, e.g. persisted keys,
+ // and section/header rows which are not valid drop targets.
+ if (
+ !candidate.rect.intersects(searchRect) ||
+ candidate.type === 'section' ||
+ candidate.type === 'header'
+ ) {
continue;
}
@@ -368,8 +490,13 @@ export class GridLayout
let candidates = this.getVisibleLayoutInfos(searchRect);
let minDistance = Infinity;
for (let candidate of candidates) {
- // Ignore items outside the search rect, e.g. persisted keys.
- if (!candidate.rect.intersects(searchRect)) {
+ // Ignore items outside the search rect, e.g. persisted keys,
+ // and section/header rows which are not valid drop targets.
+ if (
+ !candidate.rect.intersects(searchRect) ||
+ candidate.type === 'section' ||
+ candidate.type === 'header'
+ ) {
continue;
}
diff --git a/packages/react-stately/test/virtualizer/GridLayout.test.ts b/packages/react-stately/test/virtualizer/GridLayout.test.ts
index 75b2eeebfde..772f1573767 100644
--- a/packages/react-stately/test/virtualizer/GridLayout.test.ts
+++ b/packages/react-stately/test/virtualizer/GridLayout.test.ts
@@ -91,7 +91,211 @@ function setupGridLayout(options: GridLayoutOptions = {}, itemCount = 4, viewpor
return layout;
}
+interface SectionDescription {
+ id: string;
+ itemCount: number;
+ hasHeader?: boolean;
+}
+
+/**
+ * Creates a mock virtualizer and a collection containing sections with headers and items,
+ * then calls layout.update() so the layout has valid internal state for testing.
+ */
+function setupSectionedGridLayout(
+ options: GridLayoutOptions = {},
+ sections: SectionDescription[],
+ viewportWidth = 400
+) {
+ let layout = new GridLayout, GridLayoutOptions>();
+
+ let rootNodes: Node[] = [];
+ let nodesByKey = new Map>();
+ for (let section of sections) {
+ let children: Node[] = [];
+ if (section.hasHeader ?? true) {
+ children.push({
+ type: 'header',
+ key: `${section.id}-header`,
+ rendered: 'Header',
+ textValue: '',
+ parentKey: section.id,
+ childNodes: [],
+ props: {}
+ } as unknown as Node);
+ }
+
+ for (let i = 0; i < section.itemCount; i++) {
+ children.push({
+ type: 'item',
+ key: `${section.id}-item-${i}`,
+ rendered: null,
+ textValue: `Item ${i}`,
+ parentKey: section.id,
+ childNodes: [],
+ props: {}
+ } as unknown as Node);
+ }
+
+ let sectionNode = {
+ type: 'section',
+ key: section.id,
+ rendered: null,
+ textValue: '',
+ parentKey: null,
+ childNodes: children,
+ props: {}
+ } as unknown as Node;
+ rootNodes.push(sectionNode);
+ nodesByKey.set(sectionNode.key, sectionNode);
+ for (let child of children) {
+ nodesByKey.set(child.key, child);
+ }
+ }
+
+ let collection = {
+ size: nodesByKey.size,
+ getItem(key: Key) {
+ return nodesByKey.get(key) ?? null;
+ },
+ getFirstKey() {
+ return rootNodes[0]?.key ?? null;
+ },
+ getLastKey() {
+ return rootNodes[rootNodes.length - 1]?.key ?? null;
+ },
+ [Symbol.iterator]() {
+ return rootNodes[Symbol.iterator]();
+ }
+ };
+
+ (layout as any).virtualizer = {
+ collection,
+ visibleRect: new Rect(0, 0, viewportWidth, 600),
+ size: new Size(viewportWidth, 600),
+ isPersistedKey: () => false
+ };
+
+ layout.update({
+ layoutOptions: {
+ minItemSize: new Size(100, 100),
+ maxItemSize: new Size(100, 100),
+ minSpace: new Size(10, 10),
+ ...options
+ },
+ sizeChanged: true,
+ offsetChanged: false,
+ layoutOptionsChanged: true
+ });
+
+ return layout;
+}
+
describe('GridLayout', () => {
+ // With a 400px viewport, 100x100 items and 10px min space: 3 columns,
+ // 25px horizontal spacing, so columns are at x = 25, 150 and 275.
+ describe('sections', () => {
+ it('lays out headers as full width rows interleaved with grid rows', () => {
+ let layout = setupSectionedGridLayout({headingSize: 40}, [
+ {id: 's1', itemCount: 4},
+ {id: 's2', itemCount: 2}
+ ]);
+
+ // Section 1: header row, then a row of 3 items and a row with 1 item.
+ expect(layout.getLayoutInfo('s1-header')!.rect).toEqual(new Rect(25, 10, 350, 40));
+ expect(layout.getLayoutInfo('s1-item-0')!.rect).toEqual(new Rect(25, 60, 100, 100));
+ expect(layout.getLayoutInfo('s1-item-1')!.rect).toEqual(new Rect(150, 60, 100, 100));
+ expect(layout.getLayoutInfo('s1-item-2')!.rect).toEqual(new Rect(275, 60, 100, 100));
+ expect(layout.getLayoutInfo('s1-item-3')!.rect).toEqual(new Rect(25, 170, 100, 100));
+
+ // Section 2 starts on a new row below section 1.
+ expect(layout.getLayoutInfo('s2-header')!.rect).toEqual(new Rect(25, 280, 350, 40));
+ expect(layout.getLayoutInfo('s2-item-0')!.rect).toEqual(new Rect(25, 330, 100, 100));
+ expect(layout.getLayoutInfo('s2-item-1')!.rect).toEqual(new Rect(150, 330, 100, 100));
+
+ expect(layout.getContentSize().height).toBe(440);
+ });
+
+ it('produces section layout infos containing their children', () => {
+ let layout = setupSectionedGridLayout({headingSize: 40}, [
+ {id: 's1', itemCount: 4},
+ {id: 's2', itemCount: 2}
+ ]);
+
+ let s1 = layout.getLayoutInfo('s1')!;
+ expect(s1.type).toBe('section');
+ expect(s1.rect).toEqual(new Rect(0, 10, 400, 260));
+ expect(layout.getLayoutInfo('s2')!.rect).toEqual(new Rect(0, 280, 400, 150));
+
+ expect(layout.getLayoutInfo('s1-header')!.parentKey).toBe('s1');
+ expect(layout.getLayoutInfo('s1-item-0')!.parentKey).toBe('s1');
+ expect(layout.getLayoutInfo('s2-item-1')!.parentKey).toBe('s2');
+ });
+
+ it('aligns columns across sections', () => {
+ let layout = setupSectionedGridLayout({}, [
+ {id: 's1', itemCount: 4},
+ {id: 's2', itemCount: 3}
+ ]);
+
+ // Keyboard navigation across section boundaries depends on this.
+ expect(layout.getLayoutInfo('s2-item-0')!.rect.x).toBe(
+ layout.getLayoutInfo('s1-item-0')!.rect.x
+ );
+ expect(layout.getLayoutInfo('s2-item-1')!.rect.x).toBe(
+ layout.getLayoutInfo('s1-item-1')!.rect.x
+ );
+ });
+
+ it('supports sections without headers', () => {
+ let layout = setupSectionedGridLayout({}, [
+ {id: 's1', itemCount: 3, hasHeader: false},
+ {id: 's2', itemCount: 3, hasHeader: false}
+ ]);
+
+ expect(layout.getLayoutInfo('s1')!.rect).toEqual(new Rect(0, 10, 400, 100));
+ expect(layout.getLayoutInfo('s1-item-0')!.rect).toEqual(new Rect(25, 10, 100, 100));
+ expect(layout.getLayoutInfo('s2-item-0')!.rect).toEqual(new Rect(25, 120, 100, 100));
+ });
+
+ it('uses estimatedHeadingSize until headers are measured', () => {
+ let layout = setupSectionedGridLayout({estimatedHeadingSize: 60}, [{id: 's1', itemCount: 1}]);
+
+ let header = layout.getLayoutInfo('s1-header')!;
+ expect(header.rect.height).toBe(60);
+ expect(header.estimatedSize).toBe(true);
+
+ // Headers with a fixed headingSize are not estimated.
+ layout = setupSectionedGridLayout({headingSize: 40}, [{id: 's1', itemCount: 1}]);
+ header = layout.getLayoutInfo('s1-header')!;
+ expect(header.rect.height).toBe(40);
+ expect(header.estimatedSize).toBe(false);
+ });
+
+ it('returns sections before their children in getVisibleLayoutInfos', () => {
+ let layout = setupSectionedGridLayout({headingSize: 40}, [
+ {id: 's1', itemCount: 4},
+ {id: 's2', itemCount: 2}
+ ]);
+
+ let visible = layout.getVisibleLayoutInfos(new Rect(0, 0, 400, 100));
+ let keys = visible.map(v => v.key);
+ expect(keys).toEqual(['s1', 's1-header', 's1-item-0', 's1-item-1', 's1-item-2']);
+ });
+
+ it('includes headers of visible sections even when scrolled past', () => {
+ let layout = setupSectionedGridLayout({headingSize: 40}, [
+ {id: 's1', itemCount: 12},
+ {id: 's2', itemCount: 2}
+ ]);
+
+ // A rect in the middle of section 1, past its header.
+ let visible = layout.getVisibleLayoutInfos(new Rect(0, 170, 400, 100));
+ let keys = visible.map(v => v.key);
+ expect(keys).toContain('s1-header');
+ expect(keys).not.toContain('s2-header');
+ });
+ });
+
describe('getDropTargetFromPoint', () => {
let isValidDropTarget = () => true;