UNPKG

react-virtualized

Version:

React components for efficiently rendering large, scrollable lists and tabular data

675 lines (557 loc) 25.1 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); var _react = require('react'); var _react2 = _interopRequireDefault(_react); var _classnames = require('classnames'); var _classnames2 = _interopRequireDefault(_classnames); var _createCallbackMemoizer = require('../utils/createCallbackMemoizer'); var _createCallbackMemoizer2 = _interopRequireDefault(_createCallbackMemoizer); var _scrollbarSize = require('dom-helpers/util/scrollbarSize'); var _scrollbarSize2 = _interopRequireDefault(_scrollbarSize); var _raf = require('raf'); var _raf2 = _interopRequireDefault(_raf); var _reactAddonsShallowCompare = require('react-addons-shallow-compare'); var _reactAddonsShallowCompare2 = _interopRequireDefault(_reactAddonsShallowCompare); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } // @TODO It would be nice to refactor Grid to use this code as well. /** * Specifies the number of miliseconds during which to disable pointer events while a scroll is in progress. * This improves performance and makes scrolling smoother. */ var IS_SCROLLING_TIMEOUT = 150; /** * Controls whether the Grid updates the DOM element's scrollLeft/scrollTop based on the current state or just observes it. * This prevents Grid from interrupting mouse-wheel animations (see issue #2). */ var SCROLL_POSITION_CHANGE_REASONS = { OBSERVED: 'observed', REQUESTED: 'requested' }; /** * Monitors changes in properties (eg. cellCount) and state (eg. scroll offsets) to determine when rendering needs to occur. * This component does not render any visible content itself; it defers to the specified :cellLayoutManager. */ var CollectionView = function (_Component) { _inherits(CollectionView, _Component); function CollectionView(props, context) { _classCallCheck(this, CollectionView); var _this = _possibleConstructorReturn(this, (CollectionView.__proto__ || Object.getPrototypeOf(CollectionView)).call(this, props, context)); _this.state = { calculateSizeAndPositionDataOnNextUpdate: false, isScrolling: false, scrollLeft: 0, scrollTop: 0 }; // Invokes callbacks only when their values have changed. _this._onSectionRenderedMemoizer = (0, _createCallbackMemoizer2.default)(); _this._onScrollMemoizer = (0, _createCallbackMemoizer2.default)(false); // Bind functions to instance so they don't lose context when passed around. _this._invokeOnSectionRenderedHelper = _this._invokeOnSectionRenderedHelper.bind(_this); _this._onScroll = _this._onScroll.bind(_this); _this._updateScrollPositionForScrollToCell = _this._updateScrollPositionForScrollToCell.bind(_this); return _this; } /** * Forced recompute of cell sizes and positions. * This function should be called if cell sizes have changed but nothing else has. * Since cell positions are calculated by callbacks, the collection view has no way of detecting when the underlying data has changed. */ _createClass(CollectionView, [{ key: 'recomputeCellSizesAndPositions', value: function recomputeCellSizesAndPositions() { this.setState({ calculateSizeAndPositionDataOnNextUpdate: true }); } /* ---------------------------- Component lifecycle methods ---------------------------- */ }, { key: 'componentDidMount', value: function componentDidMount() { var _props = this.props; var cellLayoutManager = _props.cellLayoutManager; var scrollLeft = _props.scrollLeft; var scrollToCell = _props.scrollToCell; var scrollTop = _props.scrollTop; // If this component was first rendered server-side, scrollbar size will be undefined. // In that event we need to remeasure. if (!this._scrollbarSizeMeasured) { this._scrollbarSize = (0, _scrollbarSize2.default)(); this._scrollbarSizeMeasured = true; this.setState({}); } if (scrollToCell >= 0) { this._updateScrollPositionForScrollToCell(); } else if (scrollLeft >= 0 || scrollTop >= 0) { this._setScrollPosition({ scrollLeft: scrollLeft, scrollTop: scrollTop }); } // Update onSectionRendered callback. this._invokeOnSectionRenderedHelper(); var _cellLayoutManager$ge = cellLayoutManager.getTotalSize(); var totalHeight = _cellLayoutManager$ge.height; var totalWidth = _cellLayoutManager$ge.width; // Initialize onScroll callback. this._invokeOnScrollMemoizer({ scrollLeft: scrollLeft || 0, scrollTop: scrollTop || 0, totalHeight: totalHeight, totalWidth: totalWidth }); } }, { key: 'componentDidUpdate', value: function componentDidUpdate(prevProps, prevState) { var _props2 = this.props; var height = _props2.height; var scrollToCell = _props2.scrollToCell; var width = _props2.width; var _state = this.state; var scrollLeft = _state.scrollLeft; var scrollPositionChangeReason = _state.scrollPositionChangeReason; var scrollToAlignment = _state.scrollToAlignment; var scrollTop = _state.scrollTop; // Make sure requested changes to :scrollLeft or :scrollTop get applied. // Assigning to scrollLeft/scrollTop tells the browser to interrupt any running scroll animations, // And to discard any pending async changes to the scroll position that may have happened in the meantime (e.g. on a separate scrolling thread). // So we only set these when we require an adjustment of the scroll position. // See issue #2 for more information. if (scrollPositionChangeReason === SCROLL_POSITION_CHANGE_REASONS.REQUESTED) { if (scrollLeft >= 0 && scrollLeft !== prevState.scrollLeft && scrollLeft !== this._scrollingContainer.scrollLeft) { this._scrollingContainer.scrollLeft = scrollLeft; } if (scrollTop >= 0 && scrollTop !== prevState.scrollTop && scrollTop !== this._scrollingContainer.scrollTop) { this._scrollingContainer.scrollTop = scrollTop; } } // Update scroll offsets if the current :scrollToCell values requires it if (height !== prevProps.height || scrollToAlignment !== prevProps.scrollToAlignment || scrollToCell !== prevProps.scrollToCell || width !== prevProps.width) { this._updateScrollPositionForScrollToCell(); } // Update onRowsRendered callback if start/stop indices have changed this._invokeOnSectionRenderedHelper(); } }, { key: 'componentWillMount', value: function componentWillMount() { var cellLayoutManager = this.props.cellLayoutManager; cellLayoutManager.calculateSizeAndPositionData(); // If this component is being rendered server-side, getScrollbarSize() will return undefined. // We handle this case in componentDidMount() this._scrollbarSize = (0, _scrollbarSize2.default)(); if (this._scrollbarSize === undefined) { this._scrollbarSizeMeasured = false; this._scrollbarSize = 0; } else { this._scrollbarSizeMeasured = true; } } }, { key: 'componentWillUnmount', value: function componentWillUnmount() { if (this._disablePointerEventsTimeoutId) { clearTimeout(this._disablePointerEventsTimeoutId); } if (this._setNextStateAnimationFrameId) { _raf2.default.cancel(this._setNextStateAnimationFrameId); } } /** * @private * This method updates scrollLeft/scrollTop in state for the following conditions: * 1) Empty content (0 rows or columns) * 2) New scroll props overriding the current state * 3) Cells-count or cells-size has changed, making previous scroll offsets invalid */ }, { key: 'componentWillUpdate', value: function componentWillUpdate(nextProps, nextState) { if (nextProps.cellCount === 0 && (nextState.scrollLeft !== 0 || nextState.scrollTop !== 0)) { this._setScrollPosition({ scrollLeft: 0, scrollTop: 0 }); } else if (nextProps.scrollLeft !== this.props.scrollLeft || nextProps.scrollTop !== this.props.scrollTop) { this._setScrollPosition({ scrollLeft: nextProps.scrollLeft, scrollTop: nextProps.scrollTop }); } if (nextProps.cellCount !== this.props.cellCount || nextProps.cellLayoutManager !== this.props.cellLayoutManager || nextState.calculateSizeAndPositionDataOnNextUpdate) { nextProps.cellLayoutManager.calculateSizeAndPositionData(); } if (nextState.calculateSizeAndPositionDataOnNextUpdate) { this.setState({ calculateSizeAndPositionDataOnNextUpdate: false }); } } }, { key: 'render', value: function render() { var _this2 = this; var _props3 = this.props; var autoHeight = _props3.autoHeight; var cellCount = _props3.cellCount; var cellLayoutManager = _props3.cellLayoutManager; var className = _props3.className; var height = _props3.height; var horizontalOverscanSize = _props3.horizontalOverscanSize; var noContentRenderer = _props3.noContentRenderer; var style = _props3.style; var verticalOverscanSize = _props3.verticalOverscanSize; var width = _props3.width; var _state2 = this.state; var isScrolling = _state2.isScrolling; var scrollLeft = _state2.scrollLeft; var scrollTop = _state2.scrollTop; var _cellLayoutManager$ge2 = cellLayoutManager.getTotalSize(); var totalHeight = _cellLayoutManager$ge2.height; var totalWidth = _cellLayoutManager$ge2.width; // Safely expand the rendered area by the specified overscan amount var left = Math.max(0, scrollLeft - horizontalOverscanSize); var top = Math.max(0, scrollTop - verticalOverscanSize); var right = Math.min(totalWidth, scrollLeft + width + horizontalOverscanSize); var bottom = Math.min(totalHeight, scrollTop + height + verticalOverscanSize); var childrenToDisplay = height > 0 && width > 0 ? cellLayoutManager.cellRenderers({ height: bottom - top, isScrolling: isScrolling, width: right - left, x: left, y: top }) : []; var collectionStyle = { height: autoHeight ? 'auto' : height, width: width }; // Force browser to hide scrollbars when we know they aren't necessary. // Otherwise once scrollbars appear they may not disappear again. // For more info see issue #116 var verticalScrollBarSize = totalHeight > height ? this._scrollbarSize : 0; var horizontalScrollBarSize = totalWidth > width ? this._scrollbarSize : 0; if (totalWidth + verticalScrollBarSize <= width) { collectionStyle.overflowX = 'hidden'; } if (totalHeight + horizontalScrollBarSize <= height) { collectionStyle.overflowY = 'hidden'; } return _react2.default.createElement( 'div', { ref: function ref(_ref) { _this2._scrollingContainer = _ref; }, 'aria-label': this.props['aria-label'], className: (0, _classnames2.default)('ReactVirtualized__Collection', className), onScroll: this._onScroll, role: 'grid', style: _extends({}, collectionStyle, style), tabIndex: 0 }, cellCount > 0 && _react2.default.createElement( 'div', { className: 'ReactVirtualized__Collection__innerScrollContainer', style: { height: totalHeight, maxHeight: totalHeight, maxWidth: totalWidth, pointerEvents: isScrolling ? 'none' : '', width: totalWidth } }, childrenToDisplay ), cellCount === 0 && noContentRenderer() ); } }, { key: 'shouldComponentUpdate', value: function shouldComponentUpdate(nextProps, nextState) { return (0, _reactAddonsShallowCompare2.default)(this, nextProps, nextState); } /* ---------------------------- Helper methods ---------------------------- */ /** * Sets an :isScrolling flag for a small window of time. * This flag is used to disable pointer events on the scrollable portion of the Collection. * This prevents jerky/stuttery mouse-wheel scrolling. */ }, { key: '_enablePointerEventsAfterDelay', value: function _enablePointerEventsAfterDelay() { var _this3 = this; if (this._disablePointerEventsTimeoutId) { clearTimeout(this._disablePointerEventsTimeoutId); } this._disablePointerEventsTimeoutId = setTimeout(function () { var isScrollingChange = _this3.props.isScrollingChange; isScrollingChange(false); _this3._disablePointerEventsTimeoutId = null; _this3.setState({ isScrolling: false }); }, IS_SCROLLING_TIMEOUT); } }, { key: '_invokeOnSectionRenderedHelper', value: function _invokeOnSectionRenderedHelper() { var _props4 = this.props; var cellLayoutManager = _props4.cellLayoutManager; var onSectionRendered = _props4.onSectionRendered; this._onSectionRenderedMemoizer({ callback: onSectionRendered, indices: { indices: cellLayoutManager.getLastRenderedIndices() } }); } }, { key: '_invokeOnScrollMemoizer', value: function _invokeOnScrollMemoizer(_ref2) { var _this4 = this; var scrollLeft = _ref2.scrollLeft; var scrollTop = _ref2.scrollTop; var totalHeight = _ref2.totalHeight; var totalWidth = _ref2.totalWidth; this._onScrollMemoizer({ callback: function callback(_ref3) { var scrollLeft = _ref3.scrollLeft; var scrollTop = _ref3.scrollTop; var _props5 = _this4.props; var height = _props5.height; var onScroll = _props5.onScroll; var width = _props5.width; onScroll({ clientHeight: height, clientWidth: width, scrollHeight: totalHeight, scrollLeft: scrollLeft, scrollTop: scrollTop, scrollWidth: totalWidth }); }, indices: { scrollLeft: scrollLeft, scrollTop: scrollTop } }); } /** * Updates the state during the next animation frame. * Use this method to avoid multiple renders in a small span of time. * This helps performance for bursty events (like onScroll). */ }, { key: '_setNextState', value: function _setNextState(state) { var _this5 = this; if (this._setNextStateAnimationFrameId) { _raf2.default.cancel(this._setNextStateAnimationFrameId); } this._setNextStateAnimationFrameId = (0, _raf2.default)(function () { _this5._setNextStateAnimationFrameId = null; _this5.setState(state); }); } }, { key: '_setScrollPosition', value: function _setScrollPosition(_ref4) { var scrollLeft = _ref4.scrollLeft; var scrollTop = _ref4.scrollTop; var newState = { scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED }; if (scrollLeft >= 0) { newState.scrollLeft = scrollLeft; } if (scrollTop >= 0) { newState.scrollTop = scrollTop; } if (scrollLeft >= 0 && scrollLeft !== this.state.scrollLeft || scrollTop >= 0 && scrollTop !== this.state.scrollTop) { this.setState(newState); } } }, { key: '_updateScrollPositionForScrollToCell', value: function _updateScrollPositionForScrollToCell() { var _props6 = this.props; var cellLayoutManager = _props6.cellLayoutManager; var height = _props6.height; var scrollToAlignment = _props6.scrollToAlignment; var scrollToCell = _props6.scrollToCell; var width = _props6.width; var _state3 = this.state; var scrollLeft = _state3.scrollLeft; var scrollTop = _state3.scrollTop; if (scrollToCell >= 0) { var scrollPosition = cellLayoutManager.getScrollPositionForCell({ align: scrollToAlignment, cellIndex: scrollToCell, height: height, scrollLeft: scrollLeft, scrollTop: scrollTop, width: width }); if (scrollPosition.scrollLeft !== scrollLeft || scrollPosition.scrollTop !== scrollTop) { this._setScrollPosition(scrollPosition); } } } }, { key: '_onScroll', value: function _onScroll(event) { // In certain edge-cases React dispatches an onScroll event with an invalid target.scrollLeft / target.scrollTop. // This invalid event can be detected by comparing event.target to this component's scrollable DOM element. // See issue #404 for more information. if (event.target !== this._scrollingContainer) { return; } // Prevent pointer events from interrupting a smooth scroll this._enablePointerEventsAfterDelay(); // When this component is shrunk drastically, React dispatches a series of back-to-back scroll events, // Gradually converging on a scrollTop that is within the bounds of the new, smaller height. // This causes a series of rapid renders that is slow for long lists. // We can avoid that by doing some simple bounds checking to ensure that scrollTop never exceeds the total height. var _props7 = this.props; var cellLayoutManager = _props7.cellLayoutManager; var height = _props7.height; var isScrollingChange = _props7.isScrollingChange; var width = _props7.width; var scrollbarSize = this._scrollbarSize; var _cellLayoutManager$ge3 = cellLayoutManager.getTotalSize(); var totalHeight = _cellLayoutManager$ge3.height; var totalWidth = _cellLayoutManager$ge3.width; var scrollLeft = Math.max(0, Math.min(totalWidth - width + scrollbarSize, event.target.scrollLeft)); var scrollTop = Math.max(0, Math.min(totalHeight - height + scrollbarSize, event.target.scrollTop)); // Certain devices (like Apple touchpad) rapid-fire duplicate events. // Don't force a re-render if this is the case. // The mouse may move faster then the animation frame does. // Use requestAnimationFrame to avoid over-updating. if (this.state.scrollLeft !== scrollLeft || this.state.scrollTop !== scrollTop) { // Browsers with cancelable scroll events (eg. Firefox) interrupt scrolling animations if scrollTop/scrollLeft is set. // Other browsers (eg. Safari) don't scroll as well without the help under certain conditions (DOM or style changes during scrolling). // All things considered, this seems to be the best current work around that I'm aware of. // For more information see https://github.com/bvaughn/react-virtualized/pull/124 var scrollPositionChangeReason = event.cancelable ? SCROLL_POSITION_CHANGE_REASONS.OBSERVED : SCROLL_POSITION_CHANGE_REASONS.REQUESTED; // Synchronously set :isScrolling the first time (since _setNextState will reschedule its animation frame each time it's called) if (!this.state.isScrolling) { isScrollingChange(true); this.setState({ isScrolling: true }); } this._setNextState({ isScrolling: true, scrollLeft: scrollLeft, scrollPositionChangeReason: scrollPositionChangeReason, scrollTop: scrollTop }); } this._invokeOnScrollMemoizer({ scrollLeft: scrollLeft, scrollTop: scrollTop, totalWidth: totalWidth, totalHeight: totalHeight }); } }]); return CollectionView; }(_react.Component); CollectionView.propTypes = { 'aria-label': _react.PropTypes.string, /** * Removes fixed height from the scrollingContainer so that the total height * of rows can stretch the window. Intended for use with WindowScroller */ autoHeight: _react.PropTypes.bool, /** * Number of cells in collection. */ cellCount: _react.PropTypes.number.isRequired, /** * Calculates cell sizes and positions and manages rendering the appropriate cells given a specified window. */ cellLayoutManager: _react.PropTypes.object.isRequired, /** * Optional custom CSS class name to attach to root Collection element. */ className: _react.PropTypes.string, /** * Height of Collection; this property determines the number of visible (vs virtualized) rows. */ height: _react.PropTypes.number.isRequired, /** * Enables the `Collection` to horiontally "overscan" its content similar to how `Grid` does. * This can reduce flicker around the edges when a user scrolls quickly. */ horizontalOverscanSize: _react.PropTypes.number.isRequired, isScrollingChange: _react.PropTypes.func, /** * Optional renderer to be used in place of rows when either :rowCount or :cellCount is 0. */ noContentRenderer: _react.PropTypes.func.isRequired, /** * Callback invoked whenever the scroll offset changes within the inner scrollable region. * This callback can be used to sync scrolling between lists, tables, or grids. * ({ clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth }): void */ onScroll: _react.PropTypes.func.isRequired, /** * Callback invoked with information about the section of the Collection that was just rendered. * This callback is passed a named :indices parameter which is an Array of the most recently rendered section indices. */ onSectionRendered: _react.PropTypes.func.isRequired, /** * Horizontal offset. */ scrollLeft: _react.PropTypes.number, /** * Controls scroll-to-cell behavior of the Grid. * The default ("auto") scrolls the least amount possible to ensure that the specified cell is fully visible. * Use "start" to align cells to the top/left of the Grid and "end" to align bottom/right. */ scrollToAlignment: _react.PropTypes.oneOf(['auto', 'end', 'start', 'center']).isRequired, /** * Cell index to ensure visible (by forcefully scrolling if necessary). */ scrollToCell: _react.PropTypes.number, /** * Vertical offset. */ scrollTop: _react.PropTypes.number, /** * Optional custom inline style to attach to root Collection element. */ style: _react.PropTypes.object, /** * Enables the `Collection` to vertically "overscan" its content similar to how `Grid` does. * This can reduce flicker around the edges when a user scrolls quickly. */ verticalOverscanSize: _react.PropTypes.number.isRequired, /** * Width of Collection; this property determines the number of visible (vs virtualized) columns. */ width: _react.PropTypes.number.isRequired }; CollectionView.defaultProps = { 'aria-label': 'grid', horizontalOverscanSize: 0, noContentRenderer: function noContentRenderer() { return null; }, onScroll: function onScroll() { return null; }, onSectionRendered: function onSectionRendered() { return null; }, scrollToAlignment: 'auto', style: {}, verticalOverscanSize: 0 }; exports.default = CollectionView;