From 17f42a44c19bd430af4a5853e9e55907a6af8936 Mon Sep 17 00:00:00 2001 From: Jason Woods Date: Sat, 6 Jun 2026 10:43:40 +0100 Subject: [PATCH] feat: Add additional scrollToOffset (prop) / offset (method calls) to allow scrolling to cells but offset by some pixels --- docs/Grid.md | 5 ++- docs/List.md | 45 +++++++++++----------- source/Grid/Grid.jest.js | 81 ++++++++++++++++++++++++++++++++++++++++ source/Grid/Grid.js | 42 +++++++++++++++------ source/Grid/types.js | 6 ++- source/List/List.jest.js | 48 ++++++++++++++++++++++++ source/List/List.js | 10 ++++- 7 files changed, 202 insertions(+), 35 deletions(-) diff --git a/docs/Grid.md b/docs/Grid.md index 746fa6136..aa55a2bbc 100644 --- a/docs/Grid.md +++ b/docs/Grid.md @@ -38,6 +38,7 @@ A windowed grid of elements. `Grid` only renders cells necessary to fill itself | scrollLeft | Number | | Horizontal offset | | scrollToAlignment | String | | Controls the alignment of scrolled-to-cells. The default ("_auto_") scrolls the least amount possible to ensure that the specified cell is fully visible. Use "_start_" to always align cells to the top/left of the `Grid` and "_end_" to align them bottom/right. Use "_center_" to align specified cell in the middle of container. | | scrollToColumn | Number | | Column index to ensure visible (by forcefully scrolling if necessary). Takes precedence over `scrollLeft`. | +| scrollToOffset | Number | | Additional pixel offset applied on top of the scroll-to-cell result. Only takes effect when a scroll is triggered (e.g. by `scrollToRow`/`scrollToColumn` changing or `scrollToCell()` being called); changing this prop alone has no effect. Useful for accounting for a sticky header so that the target cell is not obscured. | | scrollToRow | Number | | Row index to ensure visible (by forcefully scrolling if necessary). Takes precedence over `scrollTop`. | | scrollTop | Number | | Vertical offset | | style | Object | | Optional custom inline style to attach to root `Grid` element. | @@ -79,11 +80,13 @@ Since `Grid` only receives `columnCount` and `rowCount` it has no way of detecti This method will also force a render cycle (via `forceUpdate`) to ensure that the updated measurements are reflected in the rendered grid. -##### scrollToCell ({ columnIndex: number, rowIndex: number }) +##### scrollToCell ({ columnIndex: number, rowIndex: number, offset?: number }) Ensure column and row are visible. This method can be used to safely scroll back to a cell that a user has scrolled away from even if it was previously scrolled to. +The optional `offset` parameter applies an additional pixel offset on top of the calculated scroll position, overriding the `scrollToOffset` prop for this call. Useful for accounting for a sticky header so that the target cell is not obscured. + ##### scrollToPosition ({ scrollLeft, scrollTop }) Scroll to the specified offset(s). diff --git a/docs/List.md b/docs/List.md index ded07b303..40710e4a8 100644 --- a/docs/List.md +++ b/docs/List.md @@ -6,26 +6,27 @@ That means that `List` also accepts [`Grid` props](Grid.md) in addition to the p ### Prop Types -| Property | Type | Required? | Description | -| :---------------- | :----------------- | :-------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| autoHeight | Boolean | | Outer `height` of `List` is set to "auto". This property should only be used in conjunction with the `WindowScroller` HOC. | -| className | String | | Optional custom CSS class name to attach to root `List` element. | -| estimatedRowSize | Number | | Used to estimate the total height of a `List` before all of its rows have actually been measured. The estimated total height is adjusted as rows are rendered. | -| height | Number | ✓ | Height constraint for list (determines how many actual rows are rendered) | -| id | String | | Optional custom id to attach to root `List` element. | -| noRowsRenderer | Function | | Callback used to render placeholder content when `rowCount` is 0 | -| onRowsRendered | Function | | Callback invoked with information about the slice of rows that were just rendered: `({ overscanStartIndex: number, overscanStopIndex: number, startIndex: number, stopIndex: number }): void` | -| onScroll | Function | | Callback invoked whenever the scroll offset changes within the inner scrollable region: `({ clientHeight: number, scrollHeight: number, scrollTop: number }): void` | -| overscanRowCount | Number | | Number of rows to render above/below the visible bounds of the list. This can help reduce flickering during scrolling on certain browsers/devices. See [here](overscanUsage.md) for an important note about this property. | -| rowCount | Number | ✓ | Number of rows in list. | -| rowHeight | Number or Function | ✓ | Either a fixed row height (number) or a function that returns the height of a row given its index: `({ index: number }): number` | -| rowRenderer | Function | ✓ | Responsible for rendering a row. [Learn more](#rowrenderer). | -| scrollToAlignment | String | | Controls the alignment scrolled-to-rows. The default ("_auto_") scrolls the least amount possible to ensure that the specified row is fully visible. Use "_start_" to always align rows to the top of the list and "_end_" to align them bottom. Use "_center_" to align them in the middle of container. | -| scrollToIndex | Number | | Row index to ensure visible (by forcefully scrolling if necessary) | -| scrollTop | Number | | Forced vertical scroll offset; can be used to synchronize scrolling between components | -| style | Object | | Optional custom inline style to attach to root `List` element. | -| tabIndex | Number | | Optional override of tab index default; defaults to `0`. | -| width | Number | ✓ | Width of the list | +| Property | Type | Required? | Description | +| :---------------- | :----------------- | :-------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| autoHeight | Boolean | | Outer `height` of `List` is set to "auto". This property should only be used in conjunction with the `WindowScroller` HOC. | +| className | String | | Optional custom CSS class name to attach to root `List` element. | +| estimatedRowSize | Number | | Used to estimate the total height of a `List` before all of its rows have actually been measured. The estimated total height is adjusted as rows are rendered. | +| height | Number | ✓ | Height constraint for list (determines how many actual rows are rendered) | +| id | String | | Optional custom id to attach to root `List` element. | +| noRowsRenderer | Function | | Callback used to render placeholder content when `rowCount` is 0 | +| onRowsRendered | Function | | Callback invoked with information about the slice of rows that were just rendered: `({ overscanStartIndex: number, overscanStopIndex: number, startIndex: number, stopIndex: number }): void` | +| onScroll | Function | | Callback invoked whenever the scroll offset changes within the inner scrollable region: `({ clientHeight: number, scrollHeight: number, scrollTop: number }): void` | +| overscanRowCount | Number | | Number of rows to render above/below the visible bounds of the list. This can help reduce flickering during scrolling on certain browsers/devices. See [here](overscanUsage.md) for an important note about this property. | +| rowCount | Number | ✓ | Number of rows in list. | +| rowHeight | Number or Function | ✓ | Either a fixed row height (number) or a function that returns the height of a row given its index: `({ index: number }): number` | +| rowRenderer | Function | ✓ | Responsible for rendering a row. [Learn more](#rowrenderer). | +| scrollToAlignment | String | | Controls the alignment scrolled-to-rows. The default ("_auto_") scrolls the least amount possible to ensure that the specified row is fully visible. Use "_start_" to always align rows to the top of the list and "_end_" to align them bottom. Use "_center_" to align them in the middle of container. | +| scrollToIndex | Number | | Row index to ensure visible (by forcefully scrolling if necessary) | +| scrollToOffset | Number | | Additional pixel offset applied on top of the scroll-to-row result. Only takes effect when a scroll is triggered (e.g. by `scrollToIndex` changing or `scrollToRow()` being called); changing this prop alone has no effect. Useful for accounting for a sticky header so that the target row is not obscured. | +| scrollTop | Number | | Forced vertical scroll offset; can be used to synchronize scrolling between components | +| style | Object | | Optional custom inline style to attach to root `List` element. | +| tabIndex | Number | | Optional override of tab index default; defaults to `0`. | +| width | Number | ✓ | Width of the list | ### Public Methods @@ -63,11 +64,13 @@ This method will also force a render cycle (via `forceUpdate`) to ensure that th Scroll to the specified offset. Useful for animating position changes. -##### scrollToRow (index: number) +##### scrollToRow (index: number, offset?: number) Ensure row is visible. This method can be used to safely scroll back to a cell that a user has scrolled away from even if it was previously scrolled to. +The optional `offset` parameter applies an additional pixel offset on top of the calculated scroll position, overriding the `scrollToOffset` prop for this call. Useful for accounting for a sticky header so that the target row is not obscured. + ### rowRenderer Responsible for rendering a single row, given its index. diff --git a/source/Grid/Grid.jest.js b/source/Grid/Grid.jest.js index e030b9f16..f66b32191 100644 --- a/source/Grid/Grid.jest.js +++ b/source/Grid/Grid.jest.js @@ -761,6 +761,87 @@ describe('Grid', () => { expect(grid.state.scrollLeft).toEqual(2450 + getScrollbarSize20()); expect(grid.state.scrollTop).toEqual(920 + getScrollbarSize20()); }); + + describe(':scrollToOffset', () => { + it('should apply scrollToOffset to initial scrollToRow with alignment start', () => { + const grid = render( + getMarkup({ + scrollToAlignment: 'start', + scrollToRow: 49, + scrollToOffset: 10, + }), + ); + // Row 49 starts at offset 980; align=start so idealOffset=980; +10 = 990 + expect(grid.state.scrollTop).toEqual(990); + }); + + it('should apply scrollToOffset to initial scrollToColumn with alignment start', () => { + const grid = render( + getMarkup({ + scrollToAlignment: 'start', + scrollToColumn: 24, + scrollToOffset: 10, + }), + ); + // Col 24 starts at offset 1200; align=start so idealOffset=1200; +10 = 1210 + expect(grid.state.scrollLeft).toEqual(1210); + }); + + it('should apply offset parameter in scrollToCell() method call', () => { + const grid = render(getMarkup()); + grid.scrollToCell({rowIndex: 49, offset: 10}); + // auto align from 0: minOffset=900, clamp to 900; +10 = 910 + expect(grid.state.scrollTop).toEqual(910); + grid.scrollToCell({columnIndex: 24, offset: 10}); + // auto align from 0: minOffset=1050, clamp to 1050; +10 = 1060 + expect(grid.state.scrollLeft).toEqual(1060); + }); + + it('should allow offset=0 to override a non-zero scrollToOffset prop', () => { + const grid = render(getMarkup({scrollToOffset: 10})); + grid.scrollToCell({rowIndex: 49, offset: 0}); + // offset=0 must override prop=10; auto from 0 = 900; +0 = 900 + expect(grid.state.scrollTop).toEqual(900); + }); + + it('should apply scrollToOffset when scrollToRow prop changes', () => { + render( + getMarkup({ + scrollToAlignment: 'start', + scrollToRow: 0, + scrollToOffset: 10, + }), + ); + const grid = render( + getMarkup({ + scrollToAlignment: 'start', + scrollToRow: 49, + scrollToOffset: 10, + }), + ); + // Row 49 at offset 980; +10 = 990 + expect(grid.state.scrollTop).toEqual(990); + }); + + it('should apply scrollToOffset when scrollToColumn prop changes', () => { + render( + getMarkup({ + scrollToAlignment: 'start', + scrollToColumn: 0, + scrollToOffset: 10, + }), + ); + const grid = render( + getMarkup({ + scrollToAlignment: 'start', + scrollToColumn: 24, + scrollToOffset: 10, + }), + ); + // Col 24 at offset 1200; +10 = 1210 + expect(grid.state.scrollLeft).toEqual(1210); + }); + }); }); describe('property updates', () => { diff --git a/source/Grid/Grid.js b/source/Grid/Grid.js index e7caff40b..c323ee9d9 100644 --- a/source/Grid/Grid.js +++ b/source/Grid/Grid.js @@ -204,6 +204,11 @@ type Props = { */ scrollToAlignment: Alignment, + /** + * Allows an additional offset to be applied to scroll-to-cell behavior of the Grid. + */ + scrollToOffset: number, + /** Column index to ensure visible (by forcefully scrolling if necessary) */ scrollToColumn: number, @@ -233,6 +238,7 @@ type InstanceProps = { prevColumnCount: number, prevRowCount: number, prevIsScrolling: boolean, + prevScrollToOffset: number, prevScrollToColumn: number, prevScrollToRow: number, @@ -281,6 +287,7 @@ class Grid extends React.PureComponent { role: 'grid', scrollingResetTimeInterval: DEFAULT_SCROLLING_RESET_TIME_INTERVAL, scrollToAlignment: 'auto', + scrollToOffset: 0, scrollToColumn: -1, scrollToRow: -1, style: {}, @@ -344,6 +351,7 @@ class Grid extends React.PureComponent { prevColumnCount: props.columnCount, prevRowCount: props.rowCount, prevIsScrolling: props.isScrolling === true, + prevScrollToOffset: props.scrollToOffset, prevScrollToColumn: props.scrollToColumn, prevScrollToRow: props.scrollToRow, @@ -567,7 +575,7 @@ class Grid extends React.PureComponent { /** * Ensure column and row are visible. */ - scrollToCell({columnIndex, rowIndex}: CellPosition) { + scrollToCell({columnIndex, rowIndex, offset}: CellPosition) { const {columnCount} = this.props; const props = this.props; @@ -578,6 +586,7 @@ class Grid extends React.PureComponent { this._updateScrollLeftForScrollToColumn({ ...props, scrollToColumn: columnIndex, + scrollToOffset: offset !== undefined ? offset : props.scrollToOffset, }); } @@ -585,6 +594,7 @@ class Grid extends React.PureComponent { this._updateScrollTopForScrollToRow({ ...props, scrollToRow: rowIndex, + scrollToOffset: offset !== undefined ? offset : props.scrollToOffset, }); } } @@ -1467,6 +1477,7 @@ class Grid extends React.PureComponent { scrollToAlignment, scrollToColumn, width, + scrollToOffset, } = nextProps; const {scrollLeft, instanceProps} = prevState; @@ -1482,13 +1493,13 @@ class Grid extends React.PureComponent { ? instanceProps.scrollbarSize : 0; - return instanceProps.columnSizeAndPositionManager.getUpdatedOffsetForIndex( - { + return ( + instanceProps.columnSizeAndPositionManager.getUpdatedOffsetForIndex({ align: scrollToAlignment, containerSize: width - scrollBarSize, currentOffset: scrollLeft, targetIndex, - }, + }) + scrollToOffset ); } return 0; @@ -1540,7 +1551,14 @@ class Grid extends React.PureComponent { } static _getCalculatedScrollTop(nextProps: Props, prevState: State) { - const {height, rowCount, scrollToAlignment, scrollToRow, width} = nextProps; + const { + height, + rowCount, + scrollToAlignment, + scrollToRow, + width, + scrollToOffset, + } = nextProps; const {scrollTop, instanceProps} = prevState; if (rowCount > 0) { @@ -1553,12 +1571,14 @@ class Grid extends React.PureComponent { ? instanceProps.scrollbarSize : 0; - return instanceProps.rowSizeAndPositionManager.getUpdatedOffsetForIndex({ - align: scrollToAlignment, - containerSize: height - scrollBarSize, - currentOffset: scrollTop, - targetIndex, - }); + return ( + instanceProps.rowSizeAndPositionManager.getUpdatedOffsetForIndex({ + align: scrollToAlignment, + containerSize: height - scrollBarSize, + currentOffset: scrollTop, + targetIndex, + }) + scrollToOffset + ); } return 0; } diff --git a/source/Grid/types.js b/source/Grid/types.js index 190d48e59..b54ac8fa0 100644 --- a/source/Grid/types.js +++ b/source/Grid/types.js @@ -3,7 +3,11 @@ import * as React from 'react'; import ScalingCellSizeAndPositionManager from './utils/ScalingCellSizeAndPositionManager'; -export type CellPosition = {columnIndex: number, rowIndex: number}; +export type CellPosition = { + columnIndex: number, + rowIndex: number, + offset?: number, +}; export type CellRendererParams = { columnIndex: number, diff --git a/source/List/List.jest.js b/source/List/List.jest.js index cbaa5729e..45e7fa202 100644 --- a/source/List/List.jest.js +++ b/source/List/List.jest.js @@ -147,6 +147,54 @@ describe('List', () => { }); }); + describe(':scrollToOffset', () => { + it('should apply scrollToOffset prop when scrollToIndex prop is set', () => { + const list = render( + getMarkup({ + scrollToAlignment: 'start', + scrollToIndex: 49, + scrollToOffset: 5, + }), + ); + // Row 49 starts at offset 490; align=start so idealOffset=490; +5 = 495 + expect(list.Grid.state.scrollTop).toEqual(495); + }); + + it('should apply offset parameter in scrollToRow() method call', () => { + const list = render(getMarkup()); + list.scrollToPosition(0); // reset scroll from any prior test state + list.scrollToRow(49, 5); + // auto align from 0: minOffset=400, clamp to 400; +5 = 405 + expect(list.Grid.state.scrollTop).toEqual(405); + }); + + it('should allow offset=0 to override a non-zero scrollToOffset prop', () => { + const list = render(getMarkup({scrollToOffset: 5})); + list.scrollToRow(49, 0); + // offset=0 must override prop=5; auto from 0 = 400; +0 = 400 + expect(list.Grid.state.scrollTop).toEqual(400); + }); + + it('should apply scrollToOffset when scrollToIndex prop changes', () => { + render( + getMarkup({ + scrollToAlignment: 'start', + scrollToIndex: 0, + scrollToOffset: 5, + }), + ); + const list = render( + getMarkup({ + scrollToAlignment: 'start', + scrollToIndex: 49, + scrollToOffset: 5, + }), + ); + // Row 49 at offset 490; +5 = 495 + expect(list.Grid.state.scrollTop).toEqual(495); + }); + }); + describe('property updates', () => { it('should update :scrollToIndex position when :rowHeight changes', () => { let rendered = findDOMNode(render(getMarkup({scrollToIndex: 50}))); diff --git a/source/List/List.js b/source/List/List.js index fd85ec691..5ca11e5d7 100644 --- a/source/List/List.js +++ b/source/List/List.js @@ -83,6 +83,12 @@ type Props = { /** Row index to ensure visible (by forcefully scrolling if necessary) */ scrollToIndex: number, + /** + * Allows an additional offset to be applied to scroll-to-row behaviour. + * See Grid#scrollToOffset + */ + scrollToOffset: number, + /** Vertical offset. */ scrollTop?: number, @@ -107,6 +113,7 @@ export default class List extends React.PureComponent { overscanRowCount: 10, scrollToAlignment: 'auto', scrollToIndex: -1, + scrollToOffset: 0, style: {}, }; @@ -177,11 +184,12 @@ export default class List extends React.PureComponent { } /** See Grid#scrollToCell */ - scrollToRow(index: number = 0) { + scrollToRow(index: number = 0, offset?: number) { if (this.Grid) { this.Grid.scrollToCell({ columnIndex: 0, rowIndex: index, + offset, }); } }