From a270dee46c7e3361cb179dea8767ce5b982a565a Mon Sep 17 00:00:00 2001 From: mehdibha Date: Sat, 18 Jul 2026 18:29:26 +0100 Subject: [PATCH] feat: Support sections in GridLayout --- .../s2-docs/pages/react-aria/Virtualizer.mdx | 2 +- .../stories/GridList.stories.tsx | 47 ++++ .../test/GridList.test.js | 125 ++++++++- .../react-stately/src/layout/GridLayout.ts | 257 +++++++++++++----- .../test/virtualizer/GridLayout.test.ts | 204 ++++++++++++++ 5 files changed, 568 insertions(+), 67 deletions(-) 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;