diff --git a/CHANGELOG.md b/CHANGELOG.md index 7442a1260..61e5bd2e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ Changelog ------------ +##### 8.7.1 +Reverted part of the change introduced in version 8.6.0 that changed the behavior regarding controlled/uncontrolled `scrollTop` and `scrollLeft` props and `Grid` in a way that was not backwards compatible. (See issue #490 for more.) + +##### 8.7.0 +Added `updatePosition` to `WindowScroller` to handle case when header items change or resize. `WindowScroller` also better handles window resize events. + +##### 8.6.1 +Updated `CellSizeCache` interface for the better perfomance by removing `has` methods, reducing a double hashtable lookup to a single lookup. Special thanks to @arusakov for this contribution! + +##### 8.6.0 +`CellMeasurer` passes `index` param (duplicate of `rowIndex`) in order to more easily integrate with `List` by default. + +`Grid` now better handles a mix of controlled and uncontrolled scroll offsets. Previously changes to one offset would wipe out the other causing cells to disappear (see PR #482). This is an edge-case bug and only impacted an uncommon usecase for `Grid`. As such this change is expected to only impact only a small percetange of users. + ##### 8.5.3 Changed overscan rows/cols behavior as described [here](https://github.com/bvaughn/react-virtualized/pull/478). This change targets performance improvements only and should have no other noticeable impact. diff --git a/docs/CellMeasurer.md b/docs/CellMeasurer.md index 3bfe9f956..8b1641c7f 100644 --- a/docs/CellMeasurer.md +++ b/docs/CellMeasurer.md @@ -12,7 +12,7 @@ This is an advanced component and has some limitations and performance considera ### Prop Types | Property | Type | Required? | Description | |:---|:---|:---:|:---| -| cellRenderer | Function | ✓ | Renders a cell given its indices. `({ columnIndex: number, rowIndex: number }): PropTypes.node` | +| cellRenderer | Function | ✓ | Renders a cell given its indices. `({ columnIndex: number, rowIndex: number, index: number }): PropTypes.node`.
**NOTE**: `index` is just an alias to `rowIndex` | | cellSizeCache | Object | | Optional, custom caching strategy for cell sizes. Learn more [here](#cellsizecache). | | children | Function | ✓ | Function responsible for rendering a virtualized component; `({ getColumnWidth: Function, getRowHeight: Function, resetMeasurements: Function }) => PropTypes.element` | | columnCount | number | ✓ | Number of columns in the `Grid`; in order to measure a row's height, all of that row's columns must be rendered. | @@ -43,10 +43,8 @@ class CellSizeCache { clearAllRowHeights (): void; clearColumnWidth (index: number): void; clearRowHeight (index: number): void; - getColumnWidth (index: number): number; - getRowHeight (index: number): number; - hasColumnWidth (index: number): boolean; - hasRowHeight (index: number): boolean; + getColumnWidth (index: number): number | undefined | null; + getRowHeight (index: number): number | undefined | null; setColumnWidth (index: number, width: number): void; setRowHeight (index: number, height: number): void; } @@ -162,35 +160,3 @@ For this reason it may not be a good idea to use this HOC for `Grid`s containing Since this component measures one cell at a time to determine it's width/height, it will likely be slow if a user skips many rows (or columns) at once by scrolling with a scrollbar or via a scroll-to-cell prop. There is (unfortunately) no workaround for this performance limitation at the moment. - -### Using `CellMeasurer` with `List` - -This HOC is intended for use with a `Grid`. -That means it passes `cellRenderer` the same named index parameter used by `Grid` (`rowIndex`). -However the `rowRenderer` used by `List` expects a slightly different named index parameter (`index`). -To use `CellMeasurerer` with `List` then you need to provide an adapter method that converts the param names. -For example: - -```jsx -import { CellMeasurer, List } from 'react-virtualized'; - -function renderList(listProps, cellMeasurerProps = Object.create(null)) { - return ( - listProps.rowRenderer({ index: rowIndex, ...rest }) - } - columnCount={1} - rowCount={listProps.rowCount} - > - {({ getRowHeight }) => ( - - )} - - ) -} -``` diff --git a/docs/WindowScroller.md b/docs/WindowScroller.md index 923b0761d..08f0428eb 100644 --- a/docs/WindowScroller.md +++ b/docs/WindowScroller.md @@ -14,6 +14,14 @@ This may change with a future release but for the time being this HOC is should | onResize | Function | | Callback to be invoked on-resize; it is passed the following named parameters: `({ height: number })`. | | onScroll | Function | | Callback to be invoked on-scroll; it is passed the following named parameters: `({ scrollTop: number })`. | +### Public Methods + +##### updatePosition + +Recalculates scroll position from the top of page. + +This methoed is automatically triggered when the component mounts as well as when the browser resizes. It should be manually called if the page header (eg any items in the DOM "above" the `WindowScroller`) resizes or changes. + ### Examples ```javascript diff --git a/package.json b/package.json index 432d4d7ad..3aabcd6e8 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "React components for efficiently rendering large, scrollable lists and tabular data", "author": "Brian Vaughn ", "user": "bvaughn", - "version": "8.5.3", + "version": "8.7.1", "homepage": "https://github.com/bvaughn/react-virtualized", "main": "dist/commonjs/index.js", "module": "dist/es/index.js", diff --git a/source/CellMeasurer/CellMeasurer.js b/source/CellMeasurer/CellMeasurer.js index bfe75efb4..64ee2b55f 100644 --- a/source/CellMeasurer/CellMeasurer.js +++ b/source/CellMeasurer/CellMeasurer.js @@ -71,8 +71,9 @@ export default class CellMeasurer extends Component { } getColumnWidth ({ index }) { - if (this._cellSizeCache.hasColumnWidth(index)) { - return this._cellSizeCache.getColumnWidth(index) + const columnWidth = this._cellSizeCache.getColumnWidth(index) + if (columnWidth != null) { + return columnWidth } const { rowCount } = this.props @@ -95,8 +96,9 @@ export default class CellMeasurer extends Component { } getRowHeight ({ index }) { - if (this._cellSizeCache.hasRowHeight(index)) { - return this._cellSizeCache.getRowHeight(index) + const rowHeight = this._cellSizeCache.getRowHeight(index) + if (rowHeight != null) { + return rowHeight } const { columnCount } = this.props @@ -189,6 +191,7 @@ export default class CellMeasurer extends Component { const rendered = cellRenderer({ columnIndex, + index: rowIndex, // Simplify List :rowRenderer use case rowIndex }) diff --git a/source/CellMeasurer/CellMeasurer.test.js b/source/CellMeasurer/CellMeasurer.test.js index 1d648fbd9..f44538888 100644 --- a/source/CellMeasurer/CellMeasurer.test.js +++ b/source/CellMeasurer/CellMeasurer.test.js @@ -72,7 +72,7 @@ describe('CellMeasurer', () => { }) expect(cellRendererParams).toEqual([]) expect(getRowHeight({ index: 0 })).toEqual(75) - expect(cellRendererParams).toEqual([{ columnIndex: 0, rowIndex: 0 }]) + expect(cellRendererParams).toEqual([{ columnIndex: 0, index: 0, rowIndex: 0 }]) expect(getColumnWidth({ index: 0 })).toEqual(100) // For some reason this explicit unmount is necessary. @@ -94,7 +94,7 @@ describe('CellMeasurer', () => { }) expect(cellRendererParams).toEqual([]) expect(getColumnWidth({ index: 0 })).toEqual(125) - expect(cellRendererParams).toEqual([{ columnIndex: 0, rowIndex: 0 }]) + expect(cellRendererParams).toEqual([{ columnIndex: 0, index: 0, rowIndex: 0 }]) expect(getRowHeight({ index: 0 })).toEqual(50) }) @@ -136,6 +136,23 @@ describe('CellMeasurer', () => { expect(getRowHeight({ index: 0 })).toEqual(50) }) + it('should support :rowRenderer via :index param for easier List integration', () => { + const { + cellRenderer, + cellRendererParams + } = createCellRenderer() + const { getColumnWidth } = renderHelper({ + cellRenderer, + rowCount: 5, + rowHeight: 50 + }) + getColumnWidth({ index: 0 }) + expect(cellRendererParams.length).toEqual(5) + for (let i = 0; i < 5; i++) { + expect(cellRendererParams[i].index).toEqual(i) + } + }) + it('should cache cell measurements once a cell has been rendered', () => { const { cellRenderer, @@ -149,15 +166,15 @@ describe('CellMeasurer', () => { getRowHeight({ index: 0 }) getRowHeight({ index: 1 }) expect(cellRendererParams).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 1 } + { columnIndex: 0, index: 0, rowIndex: 0 }, + { columnIndex: 0, index: 1, rowIndex: 1 } ]) getRowHeight({ index: 0 }) getRowHeight({ index: 1 }) expect(cellRendererParams).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 1 } + { columnIndex: 0, index: 0, rowIndex: 0 }, + { columnIndex: 0, index: 1, rowIndex: 1 } ]) }) @@ -175,8 +192,8 @@ describe('CellMeasurer', () => { getRowHeight({ index: 0 }) getRowHeight({ index: 1 }) expect(cellRendererParams).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 1 } + { columnIndex: 0, index: 0, rowIndex: 0 }, + { columnIndex: 0, index: 1, rowIndex: 1 } ]) resetMeasurements() @@ -184,10 +201,10 @@ describe('CellMeasurer', () => { getRowHeight({ index: 0 }) getRowHeight({ index: 1 }) expect(cellRendererParams).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 1 }, - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 1 } + { columnIndex: 0, index: 0, rowIndex: 0 }, + { columnIndex: 0, index: 1, rowIndex: 1 }, + { columnIndex: 0, index: 0, rowIndex: 0 }, + { columnIndex: 0, index: 1, rowIndex: 1 } ]) }) @@ -205,8 +222,8 @@ describe('CellMeasurer', () => { getColumnWidth({ index: 0 }) getColumnWidth({ index: 1 }) expect(cellRendererParams).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 1, rowIndex: 0 } + { columnIndex: 0, index: 0, rowIndex: 0 }, + { columnIndex: 1, index: 0, rowIndex: 0 } ]) resetMeasurementForColumn(0) @@ -214,9 +231,9 @@ describe('CellMeasurer', () => { getColumnWidth({ index: 0 }) getColumnWidth({ index: 1 }) expect(cellRendererParams).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 1, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 0 } + { columnIndex: 0, index: 0, rowIndex: 0 }, + { columnIndex: 1, index: 0, rowIndex: 0 }, + { columnIndex: 0, index: 0, rowIndex: 0 } ]) }) @@ -234,8 +251,8 @@ describe('CellMeasurer', () => { getRowHeight({ index: 0 }) getRowHeight({ index: 1 }) expect(cellRendererParams).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 1 } + { columnIndex: 0, index: 0, rowIndex: 0 }, + { columnIndex: 0, index: 1, rowIndex: 1 } ]) resetMeasurementForRow(0) @@ -243,9 +260,9 @@ describe('CellMeasurer', () => { getRowHeight({ index: 0 }) getRowHeight({ index: 1 }) expect(cellRendererParams).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 1 }, - { columnIndex: 0, rowIndex: 0 } + { columnIndex: 0, index: 0, rowIndex: 0 }, + { columnIndex: 0, index: 1, rowIndex: 1 }, + { columnIndex: 0, index: 0, rowIndex: 0 } ]) }) @@ -267,19 +284,17 @@ describe('CellMeasurer', () => { rowHeight: 50 }) - expect(customCellSizeCache.hasColumnWidth(0)).toEqual(false) + expect(customCellSizeCache.getColumnWidth(0)).toEqual(undefined) expect(cellRendererParams.length).toEqual(0) expect(getColumnWidth({ index: 0 })).toEqual(200) - expect(customCellSizeCache.hasColumnWidth(0)).toEqual(true) expect(customCellSizeCache.getColumnWidth(0)).toEqual(200) expect(cellRendererParams.length).toEqual(2) expect(getColumnWidth({ index: 0 })).toEqual(200) expect(cellRendererParams.length).toEqual(2) - expect(customCellSizeCache.hasRowHeight(0)).toEqual(false) + expect(customCellSizeCache.getRowHeight(0)).toEqual(undefined) expect(cellRendererParams.length).toEqual(2) expect(getRowHeight({ index: 0 })).toEqual(50) - expect(customCellSizeCache.hasRowHeight(0)).toEqual(true) expect(customCellSizeCache.getRowHeight(0)).toEqual(50) expect(cellRendererParams.length).toEqual(7) expect(getRowHeight({ index: 0 })).toEqual(50) @@ -296,9 +311,9 @@ describe('CellMeasurer', () => { columnCount: 5, columnWidth: 200 }) - expect(customCellSizeCacheA.hasColumnWidth(0)).toEqual(false) + expect(customCellSizeCacheA.getColumnWidth(0)).toEqual(undefined) expect(getColumnWidthA({ index: 0 })).toEqual(200) - expect(customCellSizeCacheA.hasColumnWidth(0)).toEqual(true) + expect(customCellSizeCacheA.getColumnWidth(0)).toEqual(200) const { getColumnWidth: getColumnWidthB } = renderHelper({ cellRenderer, @@ -306,9 +321,9 @@ describe('CellMeasurer', () => { columnCount: 5, columnWidth: 100 }) - expect(customCellSizeCacheA.hasColumnWidth(0)).toEqual(true) + expect(customCellSizeCacheA.getColumnWidth(0)).toEqual(200) expect(getColumnWidthB({ index: 0 })).toEqual(200) - expect(customCellSizeCacheA.hasColumnWidth(0)).toEqual(true) + expect(customCellSizeCacheA.getColumnWidth(0)).toEqual(200) const { getColumnWidth: getColumnWidthC } = renderHelper({ cellRenderer, @@ -316,9 +331,9 @@ describe('CellMeasurer', () => { columnCount: 5, columnWidth: 50 }) - expect(customCellSizeCacheB.hasColumnWidth(0)).toEqual(false) + expect(customCellSizeCacheB.getColumnWidth(0)).toEqual(undefined) expect(getColumnWidthC({ index: 0 })).toEqual(50) - expect(customCellSizeCacheB.hasColumnWidth(0)).toEqual(true) + expect(customCellSizeCacheB.getColumnWidth(0)).toEqual(50) }) it('should calculate row height just once when using the alternative uniform-size cell size cache', () => { @@ -342,7 +357,7 @@ describe('CellMeasurer', () => { const height2 = getRowHeight({ index: 1 }) const height3 = getRowHeight({ index: 0 }) expect(cellRendererParams).toEqual([ - { columnIndex: 0, rowIndex: 0 } + { columnIndex: 0, index: 0, rowIndex: 0 } ]) const expectedHeight = HEIGHTS[0] @@ -373,7 +388,7 @@ describe('CellMeasurer', () => { const width2 = getColumnWidth({ index: 1 }) const width3 = getColumnWidth({ index: 0 }) expect(cellRendererParams).toEqual([ - { columnIndex: 0, rowIndex: 0 } + { columnIndex: 0, index: 0, rowIndex: 0 } ]) const expectedWidth = WIDTHS[0] diff --git a/source/CellMeasurer/defaultCellSizeCache.js b/source/CellMeasurer/defaultCellSizeCache.js index c10027024..60a8a0371 100644 --- a/source/CellMeasurer/defaultCellSizeCache.js +++ b/source/CellMeasurer/defaultCellSizeCache.js @@ -11,6 +11,9 @@ export default class CellSizeCache { this._uniformRowHeight = uniformRowHeight this._uniformColumnWidth = uniformColumnWidth + this._cachedColumnWidth = undefined + this._cachedRowHeight = undefined + this._cachedColumnWidths = {} this._cachedRowHeights = {} } @@ -37,30 +40,18 @@ export default class CellSizeCache { delete this._cachedRowHeights[index] } - getColumnWidth (index: number): number { + getColumnWidth (index: number): ?number { return this._uniformColumnWidth ? this._cachedColumnWidth : this._cachedColumnWidths[index] } - getRowHeight (index: number): number { + getRowHeight (index: number): ?number { return this._uniformRowHeight ? this._cachedRowHeight : this._cachedRowHeights[index] } - hasColumnWidth (index: number): boolean { - return this._uniformColumnWidth - ? !!this._cachedColumnWidth - : !!this._cachedColumnWidths[index] - } - - hasRowHeight (index: number): boolean { - return this._uniformRowHeight - ? !!this._cachedRowHeight - : !!this._cachedRowHeights[index] - } - setColumnWidth (index: number, width: number) { this._cachedColumnWidth = width this._cachedColumnWidths[index] = width diff --git a/source/Grid/Grid.js b/source/Grid/Grid.js index 15f6f4302..214cc7ac2 100644 --- a/source/Grid/Grid.js +++ b/source/Grid/Grid.js @@ -766,10 +766,16 @@ export default class Grid extends Component { } if (scrollLeft >= 0) { + newState.scrollDirectionHorizontal = scrollLeft > this.state.scrollLeft + ? SCROLL_DIRECTION_FORWARD + : SCROLL_DIRECTION_BACKWARD newState.scrollLeft = scrollLeft } if (scrollTop >= 0) { + newState.scrollDirectionVertical = scrollTop > this.state.scrollTop + ? SCROLL_DIRECTION_FORWARD + : SCROLL_DIRECTION_BACKWARD newState.scrollTop = scrollTop } @@ -866,8 +872,8 @@ export default class Grid extends Component { this.state.scrollTop !== scrollTop ) { // Track scrolling direction so we can more efficiently overscan rows to reduce empty space around the edges while scrolling. - const scrollDirectionVertical = scrollTop > this.state.scrollTop ? SCROLL_DIRECTION_FORWARD : SCROLL_DIRECTION_BACKWARD const scrollDirectionHorizontal = scrollLeft > this.state.scrollLeft ? SCROLL_DIRECTION_FORWARD : SCROLL_DIRECTION_BACKWARD + const scrollDirectionVertical = scrollTop > this.state.scrollTop ? SCROLL_DIRECTION_FORWARD : SCROLL_DIRECTION_BACKWARD this.setState({ isScrolling: true, diff --git a/source/Grid/Grid.test.js b/source/Grid/Grid.test.js index 848a0f9a4..0fceb4f11 100644 --- a/source/Grid/Grid.test.js +++ b/source/Grid/Grid.test.js @@ -830,10 +830,16 @@ describe('Grid', () => { }) it('should set the correct scroll direction', () => { - const grid = render(getMarkup({ + // Do not pass in the initial state as props, otherwise the internal state is forbidden from + // updating itself + const grid = render(getMarkup()) + + // Simulate a scroll to set the initial internal state + simulateScroll({ + grid, scrollLeft: 50, scrollTop: 50 - })) + }) expect(grid.state.scrollDirectionHorizontal).toEqual(SCROLL_DIRECTION_FORWARD) expect(grid.state.scrollDirectionVertical).toEqual(SCROLL_DIRECTION_FORWARD) @@ -857,6 +863,32 @@ describe('Grid', () => { expect(grid.state.scrollDirectionVertical).toEqual(SCROLL_DIRECTION_FORWARD) }) + it('should set the correct scroll direction when scroll position is updated from props', () => { + let grid = render(getMarkup({ + scrollLeft: 50, + scrollTop: 50 + })) + + expect(grid.state.scrollDirectionHorizontal).toEqual(SCROLL_DIRECTION_FORWARD) + expect(grid.state.scrollDirectionVertical).toEqual(SCROLL_DIRECTION_FORWARD) + + grid = render(getMarkup({ + scrollLeft: 0, + scrollTop: 0 + })) + + expect(grid.state.scrollDirectionHorizontal).toEqual(SCROLL_DIRECTION_BACKWARD) + expect(grid.state.scrollDirectionVertical).toEqual(SCROLL_DIRECTION_BACKWARD) + + grid = render(getMarkup({ + scrollLeft: 100, + scrollTop: 100 + })) + + expect(grid.state.scrollDirectionHorizontal).toEqual(SCROLL_DIRECTION_FORWARD) + expect(grid.state.scrollDirectionVertical).toEqual(SCROLL_DIRECTION_FORWARD) + }) + it('should overscan in the direction being scrolled', async (done) => { const helper = createHelper() diff --git a/source/WindowScroller/WindowScroller.example.js b/source/WindowScroller/WindowScroller.example.js index be6f9ecb8..877de32de 100644 --- a/source/WindowScroller/WindowScroller.example.js +++ b/source/WindowScroller/WindowScroller.example.js @@ -9,19 +9,33 @@ import AutoSizer from '../AutoSizer' import shallowCompare from 'react-addons-shallow-compare' import styles from './WindowScroller.example.css' -export default class AutoSizerExample extends Component { +export default class WindowScrollerExample extends Component { static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired + list: PropTypes.instanceOf(Immutable.List).isRequired, + customElement: PropTypes.any, + isScrollingCustomElement: PropTypes.bool.isRequired, + setScrollingCustomElement: PropTypes.func } constructor (props) { super(props) + this.state = { + showHeaderText: true + } + + this._hideHeader = this._hideHeader.bind(this) this._rowRenderer = this._rowRenderer.bind(this) + this.onChangeCustomElementCheckbox = this.onChangeCustomElementCheckbox.bind(this) + } + + onChangeCustomElementCheckbox (event) { + this.context.setScrollingCustomElement(event.target.checked) } render () { - const { list } = this.context + const { list, isScrollingCustomElement, customElement } = this.context + const { showHeaderText } = this.state return ( @@ -31,13 +45,41 @@ export default class AutoSizerExample extends Component { docsLink='https://github.com/bvaughn/react-virtualized/blob/master/docs/WindowScroller.md' /> + {showHeaderText && ( + + This component decorates List, Table, or any other component + and manages the window scroll to scroll through the list + + )} + + {showHeaderText && ( + + + + )} + - This component decorates List, Table, or any other component - and manages the window scroll to scroll through the list +
- + { + this._windowScroller = ref + }} + scrollElement={isScrollingCustomElement ? customElement : null} + > {({ height, isScrolling, scrollTop }) => ( {({ width }) => ( @@ -45,9 +87,10 @@ export default class AutoSizerExample extends Component { autoHeight className={styles.List} height={height} + overscanRowCount={2} rowCount={list.size} rowHeight={30} - rowRenderer={({ index, key, style }) => this._rowRenderer({ index, isScrolling, key, style })} + rowRenderer={({ index, isVisible, key, style }) => this._rowRenderer({ index, isScrolling, isVisible, key, style })} scrollTop={scrollTop} width={width} /> @@ -64,11 +107,22 @@ export default class AutoSizerExample extends Component { return shallowCompare(this, nextProps, nextState) } - _rowRenderer ({ index, isScrolling, key, style }) { + _hideHeader () { + const { showHeaderText } = this.state + + this.setState({ + showHeaderText: !showHeaderText + }, () => { + this._windowScroller.updatePosition() + }) + } + + _rowRenderer ({ index, isScrolling, isVisible, key, style }) { const { list } = this.context const row = list.get(index) const className = cn(styles.row, { - [styles.rowScrolling]: isScrolling + [styles.rowScrolling]: isScrolling, + isVisible: isVisible }) return ( diff --git a/source/WindowScroller/WindowScroller.js b/source/WindowScroller/WindowScroller.js index 2cd894205..0cb406cd3 100644 --- a/source/WindowScroller/WindowScroller.js +++ b/source/WindowScroller/WindowScroller.js @@ -3,6 +3,7 @@ import { Component, PropTypes } from 'react' import ReactDOM from 'react-dom' import shallowCompare from 'react-addons-shallow-compare' import { registerScrollListener, unregisterScrollListener } from './utils/onScroll' +import { getVerticalScroll, getPositionFromTop, getHeight } from './utils/dimensions' export default class WindowScroller extends Component { static propTypes = { @@ -13,6 +14,9 @@ export default class WindowScroller extends Component { */ children: PropTypes.func.isRequired, + /** Element to attach scroll event listeners. Defaults to window. */ + scrollElement: PropTypes.any, + /** Callback to be invoked on-resize: ({ height }) */ onResize: PropTypes.func.isRequired, @@ -28,13 +32,13 @@ export default class WindowScroller extends Component { constructor (props) { super(props) - const height = typeof window !== 'undefined' - ? window.innerHeight + const height = typeof this.scrollElement !== 'undefined' + ? getHeight(this.scrollElement) : 0 this.state = { - isScrolling: false, height, + isScrolling: false, scrollTop: 0 } @@ -43,27 +47,33 @@ export default class WindowScroller extends Component { this._enablePointerEventsAfterDelayCallback = this._enablePointerEventsAfterDelayCallback.bind(this) } - componentDidMount () { - const { height } = this.state + get scrollElement () { + return this.props.scrollElement || window + } - // Subtract documentElement top to handle edge-case where a user is navigating back (history) from an already-scrolled bage. - // In this case the body's top position will be a negative number and this element's top will be increased (by that amount). - this._positionFromTop = - ReactDOM.findDOMNode(this).getBoundingClientRect().top - - document.documentElement.getBoundingClientRect().top + updatePosition () { + this._updateDimensions() + } - if (height !== window.innerHeight) { - this.setState({ - height: window.innerHeight - }) - } + componentDidMount () { + this._updateDimensions() + + registerScrollListener(this, this.scrollElement) - registerScrollListener(this) window.addEventListener('resize', this._onResizeWindow, false) } + componentWillReceiveProps (nextProps) { + if (nextProps.scrollElement !== this.scrollElement) { + this._updateDimensions(nextProps.scrollElement || window) + + unregisterScrollListener(this, this.scrollElement) + registerScrollListener(this, nextProps.scrollElement || window) + } + } + componentWillUnmount () { - unregisterScrollListener(this) + unregisterScrollListener(this, this.scrollElement) window.removeEventListener('resize', this._onResizeWindow, false) } @@ -89,23 +99,31 @@ export default class WindowScroller extends Component { }) } - _onResizeWindow (event) { + _updateDimensions (scrollElement = this.scrollElement) { const { onResize } = this.props + const { height } = this.state + + this._positionFromTop = getPositionFromTop(ReactDOM.findDOMNode(this), scrollElement) - const height = window.innerHeight || 0 + const newHeight = getHeight(scrollElement) - this.setState({ height }) + if (height !== newHeight) { + this.setState({ + height: newHeight + }) + + onResize({ height: newHeight }) + } + } - onResize({ height }) + _onResizeWindow (event) { + this._updateDimensions() } _onScrollWindow (event) { const { onScroll } = this.props - // In IE10+ scrollY is undefined, so we replace that with the latter - const scrollY = ('scrollY' in window) - ? window.scrollY - : document.documentElement.scrollTop + const scrollY = getVerticalScroll(this.scrollElement) const scrollTop = Math.max(0, scrollY - this._positionFromTop) diff --git a/source/WindowScroller/WindowScroller.test.js b/source/WindowScroller/WindowScroller.test.js index b0c52969b..89941b22d 100644 --- a/source/WindowScroller/WindowScroller.test.js +++ b/source/WindowScroller/WindowScroller.test.js @@ -27,8 +27,11 @@ describe('WindowScroller', () => { document.dispatchEvent(new window.Event('resize', { bubbles: true })) } - function getMarkup (props = {}) { - return ( + function getMarkup ({ + headerElements, + ...props + } = {}) { + const windowScroller = ( {({ height, isScrolling, scrollTop }) => ( { )} ) + + if (headerElements) { + return ( +
+ {headerElements} + {windowScroller} +
+ ) + } else { + return windowScroller + } } // Starts updating scrollTop only when the top position is reached @@ -185,4 +199,59 @@ describe('WindowScroller', () => { window.innerHeight = 500 }) }) + + describe('updatePosition', () => { + it('should calculate the initial offset from the top of the page when mounted', () => { + let windowScroller + + render(getMarkup({ + headerElements:
, + ref: (ref) => { + windowScroller = ref + } + })) + + expect(windowScroller._positionFromTop > 100).toBeTruthy() + }) + + it('should recalculate the offset from the top when the window resizes', () => { + let windowScroller + + const rendered = render(getMarkup({ + headerElements: , + ref: (ref) => { + windowScroller = ref + } + })) + + expect(windowScroller._positionFromTop < 200).toBeTruthy() + + const header = findDOMNode(rendered).querySelector('#header') + header.style.height = '200px' + + simulateWindowResize({ height: 1000 }) + + expect(windowScroller._positionFromTop > 200).toBeTruthy() + }) + + it('should recalculate the offset from the top if called externally', () => { + let windowScroller + + const rendered = render(getMarkup({ + headerElements: , + ref: (ref) => { + windowScroller = ref + } + })) + + expect(windowScroller._positionFromTop < 200).toBeTruthy() + + const header = findDOMNode(rendered).querySelector('#header') + header.style.height = '200px' + + windowScroller.updatePosition() + + expect(windowScroller._positionFromTop > 200).toBeTruthy() + }) + }) }) diff --git a/source/WindowScroller/utils/dimensions.js b/source/WindowScroller/utils/dimensions.js new file mode 100644 index 000000000..9c7b90950 --- /dev/null +++ b/source/WindowScroller/utils/dimensions.js @@ -0,0 +1,30 @@ +/** + * Gets the vertical scroll amount of the element, accounting for IE compatibility + * and API differences between `window` and other DOM elements. + */ +export function getVerticalScroll (element) { + return element === window + ? (('scrollY' in window) ? window.scrollY : document.documentElement.scrollTop) + : element.scrollTop +} + +/** + * Gets the height of the element, accounting for API differences between + * `window` and other DOM elements. + */ +export function getHeight (element) { + return element === window + ? window.innerHeight + : element.getBoundingClientRect().height +} + +/** + * Gets the vertical position of an element within its scroll container. + * Elements that have been “scrolled past” return negative values. + * Handles edge-case where a user is navigating back (history) from an already-scrolled page. + * In this case the body’s top position will be a negative number and this element’s top will be increased (by that amount). + */ +export function getPositionFromTop (element, container) { + const containerElement = container === window ? document.documentElement : container + return element.getBoundingClientRect().top + getVerticalScroll(container) - containerElement.getBoundingClientRect().top +} diff --git a/source/WindowScroller/utils/onScroll.js b/source/WindowScroller/utils/onScroll.js index 9dfac4e18..985f7968b 100644 --- a/source/WindowScroller/utils/onScroll.js +++ b/source/WindowScroller/utils/onScroll.js @@ -35,27 +35,30 @@ function enablePointerEventsAfterDelay () { } function onScrollWindow (event) { - if (originalBodyPointerEvents == null) { + if (event.currentTarget === window && originalBodyPointerEvents == null) { originalBodyPointerEvents = document.body.style.pointerEvents document.body.style.pointerEvents = 'none' - - enablePointerEventsAfterDelay() } - mountedInstances.forEach(component => component._onScrollWindow(event)) + enablePointerEventsAfterDelay() + mountedInstances.forEach(component => { + if (component.scrollElement === event.currentTarget) { + component._onScrollWindow(event) + } + }) } -export function registerScrollListener (component) { - if (!mountedInstances.length) { - window.addEventListener('scroll', onScrollWindow) +export function registerScrollListener (component, element = window) { + if (!mountedInstances.some(c => c.scrollElement === element)) { + element.addEventListener('scroll', onScrollWindow) } mountedInstances.push(component) } -export function unregisterScrollListener (component) { +export function unregisterScrollListener (component, element = window) { mountedInstances = mountedInstances.filter(c => (c !== component)) if (!mountedInstances.length) { - window.removeEventListener('scroll', onScrollWindow) + element.removeEventListener('scroll', onScrollWindow) if (disablePointerEventsTimeoutId) { clearTimeout(disablePointerEventsTimeoutId) enablePointerEventsIfDisabled() diff --git a/source/demo/Application.css b/source/demo/Application.css index 0d117bdc4..7d04e0279 100644 --- a/source/demo/Application.css +++ b/source/demo/Application.css @@ -105,6 +105,11 @@ a { flex-wrap: wrap; justify-content: center; } +.ScrollingBody { + composes: Body; + overflow: auto; + flex: 1 1 auto; +} .column { display: flex; flex-direction: column; diff --git a/source/demo/Application.js b/source/demo/Application.js index 4fdaf6b3f..8e9610ee8 100644 --- a/source/demo/Application.js +++ b/source/demo/Application.js @@ -42,16 +42,38 @@ const list = Immutable.List(generateRandomList()) export default class Application extends Component { static childContextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired + list: PropTypes.instanceOf(Immutable.List).isRequired, + customElement: PropTypes.any, + isScrollingCustomElement: PropTypes.bool.isRequired, + setScrollingCustomElement: PropTypes.func }; + state = { + isScrollingCustomElement: false + } + + constructor (props) { + super(props) + this.setScrollingCustomElement = this.setScrollingCustomElement.bind(this) + } + + setScrollingCustomElement (custom) { + this.setState({ isScrollingCustomElement: custom }) + } + getChildContext () { + const { customElement, isScrollingCustomElement } = this.state return { - list + list, + customElement, + isScrollingCustomElement, + setScrollingCustomElement: this.setScrollingCustomElement } } render () { + const { isScrollingCustomElement } = this.state + const bodyStyle = isScrollingCustomElement ? styles.ScrollingBody : styles.Body return (
@@ -98,7 +120,7 @@ export default class Application extends Component {
-
+
this.setState({ customElement: e })}>
{Object.keys(COMPONENT_EXAMPLES_MAP).map((route) => ( diff --git a/source/demo/ContentBox.css b/source/demo/ContentBox.css index fd96aa22c..b9344c372 100644 --- a/source/demo/ContentBox.css +++ b/source/demo/ContentBox.css @@ -1,5 +1,5 @@ .ContentBox { - flex: 1; + flex: 1 0 auto; display: flex; flex-direction: column; background-color: #FFF;