From 76a56d10485b1d41b0e5ccc9250e5dea63e52139 Mon Sep 17 00:00:00 2001 From: Danny Thielman Date: Fri, 5 Apr 2019 06:55:53 -0400 Subject: [PATCH] Adds range rendering In certain situations, consumers benefit from being able to control how virtualized items are placed, such as when managing aria attributes (where the DOM hierarchy determines accessibility; see https://www.w3.org/TR/wai-aria-1.1/#grid). This commit adds some light tooling to allow consumers to specify exactly how children are placed in the DOM hierarchy. --- src/__tests__/FixedSizeGrid.js | 49 +++++++++++ src/__tests__/FixedSizeList.js | 35 ++++++++ src/__tests__/VariableSizeGrid.js | 49 +++++++++++ src/__tests__/VariableSizeList.js | 35 ++++++++ src/createGridComponent.js | 82 +++++++++++++------ src/createListComponent.js | 62 +++++++++++--- .../code/FixedSizeGridCellRangeRenderer.js | 19 +++++ .../src/code/FixedSizeListRowRangeRenderer.js | 13 +++ website/src/routes/api/FixedSizeGrid.js | 22 +++++ website/src/routes/api/FixedSizeList.js | 22 +++++ 10 files changed, 353 insertions(+), 35 deletions(-) create mode 100644 website/src/code/FixedSizeGridCellRangeRenderer.js create mode 100644 website/src/code/FixedSizeListRowRangeRenderer.js diff --git a/src/__tests__/FixedSizeGrid.js b/src/__tests__/FixedSizeGrid.js index 3d7bf90e..fffdeaf5 100644 --- a/src/__tests__/FixedSizeGrid.js +++ b/src/__tests__/FixedSizeGrid.js @@ -4,6 +4,7 @@ import ReactTestRenderer from 'react-test-renderer'; import ReactTestUtils from 'react-dom/test-utils'; import { FixedSizeGrid } from '..'; import * as domHelpers from '../domHelpers'; +import { defaultCellRangeRenderer } from '../createGridComponent'; const findScrollContainer = rendered => rendered.root.children[0].children[0]; @@ -1085,4 +1086,52 @@ describe('FixedSizeGrid', () => { ); }); }); + + describe('cellRangeRenderer', () => { + it('should use a custom cellRangeRenderer if specified', () => { + const width = 90; + const height = 70; + const columnWidth = 20; + const rowHeight = 40; + const overscanRowsCount = 1; + const overscanColumnsCount = 1; + + const expectedColumnStopIndex = Math.floor( + width / columnWidth + overscanColumnsCount + ); + + const expectedRowStopIndex = Math.floor( + height / rowHeight + overscanRowsCount + ); + + const CustomRow = props =>
; + const cellRangeRenderer = jest + .fn() + .mockImplementation(defaultCellRangeRenderer); + + const rendered = ReactTestRenderer.create( + + ); + + expect(cellRangeRenderer).toHaveBeenCalledTimes(1); + expect(cellRangeRenderer).toHaveBeenCalledWith({ + columnStartIndex: 0, + rowStartIndex: 0, + columnStopIndex: expectedColumnStopIndex, + rowStopIndex: expectedRowStopIndex, + childFactory: expect.any(Function), + }); + }); + }); }); diff --git a/src/__tests__/FixedSizeList.js b/src/__tests__/FixedSizeList.js index 371b8cdb..74503d37 100644 --- a/src/__tests__/FixedSizeList.js +++ b/src/__tests__/FixedSizeList.js @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import ReactTestRenderer from 'react-test-renderer'; import ReactTestUtils from 'react-dom/test-utils'; import { FixedSizeList } from '..'; +import { defaultRowRangeRenderer } from '../createListComponent'; const simulateScroll = (instance, scrollOffset, direction = 'vertical') => { if (direction === 'horizontal') { @@ -870,4 +871,38 @@ describe('FixedSizeList', () => { ); }); }); + + describe('rowRangeRenderer', () => { + it('should use a custom rowRangeRenderer if specified', () => { + const height = 70; + const itemSize = 40; + const overscanCount = 1; + + const expectedStopIndex = Math.floor(height / itemSize + overscanCount); + + const CustomRow = props =>
; + const rowRangeRenderer = jest + .fn() + .mockImplementation(defaultRowRangeRenderer); + + const rendered = ReactTestRenderer.create( + + ); + + expect(rowRangeRenderer).toHaveBeenCalledTimes(1); + expect(rowRangeRenderer).toHaveBeenCalledWith({ + startIndex: 0, + stopIndex: expectedStopIndex, + childFactory: expect.any(Function), + }); + }); + }); }); diff --git a/src/__tests__/VariableSizeGrid.js b/src/__tests__/VariableSizeGrid.js index 71065399..3a71b456 100644 --- a/src/__tests__/VariableSizeGrid.js +++ b/src/__tests__/VariableSizeGrid.js @@ -4,6 +4,7 @@ import { Simulate } from 'react-dom/test-utils'; import ReactTestRenderer from 'react-test-renderer'; import { VariableSizeGrid } from '..'; import * as domHelpers from '../domHelpers'; +import { defaultCellRangeRenderer } from '../createGridComponent'; const simulateScroll = (instance, { scrollLeft, scrollTop }) => { instance._outerRef.scrollLeft = scrollLeft; @@ -584,4 +585,52 @@ describe('VariableSizeGrid', () => { expect(innerRef.current.style.height).toEqual('106px'); expect(innerRef.current.style.width).toEqual('101px'); }); + + describe('cellRangeRenderer', () => { + it('should use a custom cellRangeRenderer if specified', () => { + const width = 90; + const height = 70; + const columnWidth = _ => 20; + const rowHeight = _ => 40; + const overscanRowsCount = 1; + const overscanColumnsCount = 1; + + const expectedColumnStopIndex = Math.floor( + width / columnWidth() + overscanColumnsCount + ); + + const expectedRowStopIndex = Math.floor( + height / rowHeight() + overscanRowsCount + ); + + const CustomRow = props =>
; + const cellRangeRenderer = jest + .fn() + .mockImplementation(defaultCellRangeRenderer); + + const rendered = ReactTestRenderer.create( + + ); + + expect(cellRangeRenderer).toHaveBeenCalledTimes(1); + expect(cellRangeRenderer).toHaveBeenCalledWith({ + columnStartIndex: 0, + rowStartIndex: 0, + columnStopIndex: expectedColumnStopIndex, + rowStopIndex: expectedRowStopIndex, + childFactory: expect.any(Function), + }); + }); + }); }); diff --git a/src/__tests__/VariableSizeList.js b/src/__tests__/VariableSizeList.js index aeffad52..279f1caf 100644 --- a/src/__tests__/VariableSizeList.js +++ b/src/__tests__/VariableSizeList.js @@ -3,6 +3,7 @@ import { render } from 'react-dom'; import { Simulate } from 'react-dom/test-utils'; import ReactTestRenderer from 'react-test-renderer'; import { VariableSizeList } from '..'; +import { defaultRowRangeRenderer } from '../createListComponent'; const simulateScroll = (instance, scrollOffset, direction = 'vertical') => { if (direction === 'horizontal') { @@ -349,4 +350,38 @@ describe('VariableSizeList', () => { instance.setState({ itemCount: 3 }); expect(innerRef.current.style.height).toEqual('78px'); }); + + describe('rowRangeRenderer', () => { + it('should use a custom rowRangeRenderer if specified', () => { + const height = 70; + const itemSize = _ => 40; + const overscanCount = 1; + + const expectedStopIndex = Math.floor(height / itemSize() + overscanCount); + + const CustomRow = props =>
; + const rowRangeRenderer = jest + .fn() + .mockImplementation(defaultRowRangeRenderer); + + const rendered = ReactTestRenderer.create( + + ); + + expect(rowRangeRenderer).toHaveBeenCalledTimes(1); + expect(rowRangeRenderer).toHaveBeenCalledWith({ + startIndex: 0, + stopIndex: expectedStopIndex, + childFactory: expect.any(Function), + }); + }); + }); }); diff --git a/src/createGridComponent.js b/src/createGridComponent.js index 9d725821..b6c599ae 100644 --- a/src/createGridComponent.js +++ b/src/createGridComponent.js @@ -46,7 +46,23 @@ type OnScrollCallback = ({ type ScrollEvent = SyntheticEvent; type ItemStyleCache = { [key: string]: Object }; +type CellRangeRendererParams = {| + columnStartIndex: number, + columnStopIndex: number, + rowStartIndex: number, + rowStopIndex: number, + childFactory: (params: {| + rowIndex: number, + columnIndex: number, + |}) => React$Element>, +|}; + +type CellRangeRenderer = ( + params: CellRangeRendererParams +) => React$Element>[]; + export type Props = {| + cellRangeRenderer?: CellRangeRenderer, children: RenderComponent, className?: string, columnCount: number, @@ -127,6 +143,26 @@ const IS_SCROLLING_DEBOUNCE_INTERVAL = 150; const defaultItemKey = ({ columnIndex, data, rowIndex }) => `${rowIndex}:${columnIndex}`; +export function defaultCellRangeRenderer({ + rowStartIndex, + rowStopIndex, + columnStartIndex, + columnStopIndex, + childFactory, +}: CellRangeRendererParams): React$Element>[] { + const items = []; + for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) { + for ( + let columnIndex = columnStartIndex; + columnIndex <= columnStopIndex; + columnIndex++ + ) { + items.push(childFactory({ rowIndex, columnIndex })); + } + } + return items; +} + // In DEV mode, this Set helps us only log a warning once per component instance. // This avoids spamming the console every time a render happens. let devWarningsOverscanCount = null; @@ -348,6 +384,7 @@ export default function createGridComponent({ render() { const { + cellRangeRenderer = (defaultCellRangeRenderer: CellRangeRenderer), children, className, columnCount, @@ -373,30 +410,29 @@ export default function createGridComponent({ ] = this._getHorizontalRangeToRender(); const [rowStartIndex, rowStopIndex] = this._getVerticalRangeToRender(); - const items = []; + const resolvedIsScrolling = useIsScrolling ? isScrolling : undefined; + + const childFactory = ({ rowIndex, columnIndex }) => { + return createElement(children, { + columnIndex, + data: itemData, + isScrolling: resolvedIsScrolling, + key: itemKey({ columnIndex, data: itemData, rowIndex }), + rowIndex, + style: this._getItemStyle(rowIndex, columnIndex), + }); + }; + + let items = []; + if (columnCount > 0 && rowCount) { - for ( - let rowIndex = rowStartIndex; - rowIndex <= rowStopIndex; - rowIndex++ - ) { - for ( - let columnIndex = columnStartIndex; - columnIndex <= columnStopIndex; - columnIndex++ - ) { - items.push( - createElement(children, { - columnIndex, - data: itemData, - isScrolling: useIsScrolling ? isScrolling : undefined, - key: itemKey({ columnIndex, data: itemData, rowIndex }), - rowIndex, - style: this._getItemStyle(rowIndex, columnIndex), - }) - ); - } - } + items = cellRangeRenderer({ + rowStartIndex, + rowStopIndex, + columnStartIndex, + columnStopIndex, + childFactory, + }); } // Read this value AFTER items have been created, diff --git a/src/createListComponent.js b/src/createListComponent.js index a8c9b004..b6439533 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -38,6 +38,18 @@ type onScrollCallback = ({ type ScrollEvent = SyntheticEvent; type ItemStyleCache = { [index: number]: Object }; +type RowRangeRendererParams = {| + startIndex: number, + stopIndex: number, + childFactory: (params: {| index: number |}) => React$Element< + RenderComponent + >, +|}; + +type RowRangeRenderer = ( + params: RowRangeRendererParams +) => React$Element>[]; + export type Props = {| children: RenderComponent, className?: string, @@ -58,6 +70,7 @@ export type Props = {| outerElementType?: React$ElementType, outerTagName?: string, // deprecated overscanCount: number, + rowRangeRenderer?: RowRangeRenderer, style?: Object, useIsScrolling: boolean, width: number | string, @@ -107,6 +120,23 @@ const IS_SCROLLING_DEBOUNCE_INTERVAL = 150; const defaultItemKey = (index: number, data: any) => index; +export function defaultRowRangeRenderer({ + startIndex, + stopIndex, + childFactory, +}: RowRangeRendererParams): React$Element>[] { + const items = []; + for (let index = startIndex; index <= stopIndex; index++) { + items.push( + childFactory({ + index, + }) + ); + } + + return items; +} + // In DEV mode, this Set helps us only log a warning once per component instance. // This avoids spamming the console every time a render happens. let devWarningsDirection = null; @@ -266,6 +296,7 @@ export default function createListComponent({ layout, outerElementType, outerTagName, + rowRangeRenderer = (defaultRowRangeRenderer: RowRangeRenderer), style, useIsScrolling, width, @@ -282,19 +313,26 @@ export default function createListComponent({ const [startIndex, stopIndex] = this._getRangeToRender(); - const items = []; + const resolvedIsScrolling = useIsScrolling ? isScrolling : undefined; + + const childFactory = ({ index }) => { + return createElement(children, { + data: itemData, + key: itemKey(index, itemData), + index, + isScrolling: resolvedIsScrolling, + style: this._getItemStyle(index), + }); + }; + + let items = []; + if (itemCount > 0) { - for (let index = startIndex; index <= stopIndex; index++) { - items.push( - createElement(children, { - data: itemData, - key: itemKey(index, itemData), - index, - isScrolling: useIsScrolling ? isScrolling : undefined, - style: this._getItemStyle(index), - }) - ); - } + items = rowRangeRenderer({ + startIndex, + stopIndex, + childFactory, + }); } // Read this value AFTER items have been created, diff --git a/website/src/code/FixedSizeGridCellRangeRenderer.js b/website/src/code/FixedSizeGridCellRangeRenderer.js new file mode 100644 index 00000000..b6ff8b34 --- /dev/null +++ b/website/src/code/FixedSizeGridCellRangeRenderer.js @@ -0,0 +1,19 @@ +export function defaultCellRangeRenderer({ + rowStartIndex, + rowStopIndex, + columnStartIndex, + columnStopIndex, + childFactory +}: CellRangeRendererParams): React$Element>[] { + const items = []; + // rowStartIndex, rowStopIndex, columnStartIndex, and columnStopIndex are all integers + for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) { + for (let columnIndex = columnStartIndex; columnIndex <= columnStopIndex; columnIndex++) { + // childFactory is used to create the next item in the grid + const child = childFactory({ rowIndex, columnIndex }); + items.push(child); + } + } + // Return the items to be rendered to the DOM. + return items; +} diff --git a/website/src/code/FixedSizeListRowRangeRenderer.js b/website/src/code/FixedSizeListRowRangeRenderer.js new file mode 100644 index 00000000..467c79a3 --- /dev/null +++ b/website/src/code/FixedSizeListRowRangeRenderer.js @@ -0,0 +1,13 @@ +function rowRangeRenderer({ startIndex, stopIndex, childFactory }) { + const items = []; + + // startIndex and stopIndex are integers. + for (let index = startIndex; index <= stopIndex; index++) { + // childFactory is used to retrieve the child that is to be rendered + const child = childFactory({ index }); + items.push(child); + } + + // Return the items to be rendered to the DOM. + return items; +} diff --git a/website/src/routes/api/FixedSizeGrid.js b/website/src/routes/api/FixedSizeGrid.js index 0ba0261a..1aa0da1a 100644 --- a/website/src/routes/api/FixedSizeGrid.js +++ b/website/src/routes/api/FixedSizeGrid.js @@ -5,6 +5,7 @@ import ComponentApi from '../../components/ComponentApi'; import styles from './shared.module.css'; +import CODE_CELL_RANGE_RENDERER from '../../code/FixedSizeGridCellRangeRenderer.js'; import CODE_CHILDREN_CLASS from '../../code/FixedSizeGridChildrenClass.js'; import CODE_CHILDREN_FUNCTION from '../../code/FixedSizeGridChildrenFunction.js'; import CODE_ITEM_DATA from '../../code/FixedSizeGridItemData.js'; @@ -17,6 +18,27 @@ export default () => ( ); const PROPS = [ + { + description: ( + +

+ By default, lists render cells consecutively without decoration. + Occasionally, you may need to render dynamic content that spans + multiple cells. In general, this can be handled with the{' '} + children prop or the innerElementType prop. + However, if you truly need a fine level of control over the rendering + mechanism, rendering, you can do so here. This is an advanced + property. +

+
+ +
+
+ ), + isRequired: false, + name: 'cellRangeRenderer', + type: 'function', + }, { description: ( diff --git a/website/src/routes/api/FixedSizeList.js b/website/src/routes/api/FixedSizeList.js index 51887087..33256bbb 100644 --- a/website/src/routes/api/FixedSizeList.js +++ b/website/src/routes/api/FixedSizeList.js @@ -10,6 +10,7 @@ import CODE_CHILDREN_FUNCTION from '../../code/FixedSizeListChildrenFunction.js' import CODE_ITEM_DATA from '../../code/FixedSizeListItemData.js'; import CODE_ITEM_KEY from '../../code/FixedSizeListItemKey.js'; import CODE_ON_ITEMS_RENDERED from '../../code/FixedSizeListOnItemsRendered.js'; +import CODE_ROW_RANGE_RENDERER from '../../code/FixedSizeListRowRangeRenderer.js'; import CODE_ON_SCROLL from '../../code/FixedSizeListOnScroll.js'; export default () => ( @@ -325,6 +326,27 @@ const PROPS = [ name: 'overscanCount', type: 'number', }, + { + description: ( + +

+ By default, lists render rows consecutively without decoration. + Occasionally, you may need to render dynamic content that spans + multiple items. In general, this can be handled with the{' '} + children prop or the innerElementType prop. + However, if you truly need a fine level of control over the rendering + mechanism, rendering, you can do so here. This is an advanced + property. +

+
+ +
+
+ ), + isRequired: false, + name: 'rowRangeRenderer', + type: 'function', + }, { defaultValue: null, description: (