@segment/react-tiny-virtual-list
Version:
A tiny but mighty list virtualization component, with zero dependencies 💪
561 lines (549 loc) • 24 kB
JavaScript
import { PureComponent, createElement } from 'react';
import { array, arrayOf, func, number, object, oneOf, oneOfType, string } from 'prop-types';
/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */
/* global Reflect, Promise */
var extendStatics = function(d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
return extendStatics(d, b);
};
function __extends(d, b) {
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
}
var __assign = function() {
__assign = Object.assign || function __assign(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
function __rest(s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) if (e.indexOf(p[i]) < 0)
t[p[i]] = s[p[i]];
return t;
}
var ALIGNMENT;
(function (ALIGNMENT) {
ALIGNMENT["AUTO"] = "auto";
ALIGNMENT["START"] = "start";
ALIGNMENT["CENTER"] = "center";
ALIGNMENT["END"] = "end";
})(ALIGNMENT || (ALIGNMENT = {}));
var DIRECTION;
(function (DIRECTION) {
DIRECTION["HORIZONTAL"] = "horizontal";
DIRECTION["VERTICAL"] = "vertical";
})(DIRECTION || (DIRECTION = {}));
var SCROLL_CHANGE_REASON;
(function (SCROLL_CHANGE_REASON) {
SCROLL_CHANGE_REASON["OBSERVED"] = "observed";
SCROLL_CHANGE_REASON["REQUESTED"] = "requested";
})(SCROLL_CHANGE_REASON || (SCROLL_CHANGE_REASON = {}));
var scrollProp = (_a = {}, _a[DIRECTION.VERTICAL] = 'scrollTop', _a[DIRECTION.HORIZONTAL] = 'scrollLeft', _a);
var sizeProp = (_b = {}, _b[DIRECTION.VERTICAL] = 'height', _b[DIRECTION.HORIZONTAL] = 'width', _b);
var positionProp = (_c = {}, _c[DIRECTION.VERTICAL] = 'top', _c[DIRECTION.HORIZONTAL] = 'left', _c);
var marginProp = (_d = {}, _d[DIRECTION.VERTICAL] = 'marginTop', _d[DIRECTION.HORIZONTAL] = 'marginLeft', _d);
var oppositeMarginProp = (_e = {}, _e[DIRECTION.VERTICAL] = 'marginBottom', _e[DIRECTION.HORIZONTAL] = 'marginRight', _e);
var _a;
var _b;
var _c;
var _d;
var _e;
/* Forked from react-virtualized 💖 */
var SizeAndPositionManager = /** @class */function () {
function SizeAndPositionManager(_a) {
var itemCount = _a.itemCount,
itemSizeGetter = _a.itemSizeGetter,
estimatedItemSize = _a.estimatedItemSize;
this.itemSizeGetter = itemSizeGetter;
this.itemCount = itemCount;
this.estimatedItemSize = estimatedItemSize;
// Cache of size and position data for items, mapped by item index.
this.itemSizeAndPositionData = {};
// Measurements for items up to this index can be trusted; items afterward should be estimated.
this.lastMeasuredIndex = -1;
}
SizeAndPositionManager.prototype.updateConfig = function (_a) {
var itemCount = _a.itemCount,
itemSizeGetter = _a.itemSizeGetter,
estimatedItemSize = _a.estimatedItemSize;
if (itemCount != null) {
this.itemCount = itemCount;
}
if (estimatedItemSize != null) {
this.estimatedItemSize = estimatedItemSize;
}
if (itemSizeGetter != null) {
this.itemSizeGetter = itemSizeGetter;
}
};
SizeAndPositionManager.prototype.getLastMeasuredIndex = function () {
return this.lastMeasuredIndex;
};
/**
* This method returns the size and position for the item at the specified index.
* It just-in-time calculates (or used cached values) for items leading up to the index.
*/
SizeAndPositionManager.prototype.getSizeAndPositionForIndex = function (index) {
if (index < 0 || index >= this.itemCount) {
throw Error("Requested index " + index + " is outside of range 0.." + this.itemCount);
}
if (index > this.lastMeasuredIndex) {
var lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
var offset = lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size;
for (var i = this.lastMeasuredIndex + 1; i <= index; i++) {
var size = this.itemSizeGetter(i);
if (size == null || isNaN(size)) {
throw Error("Invalid size returned for index " + i + " of value " + size);
}
this.itemSizeAndPositionData[i] = {
offset: offset,
size: size
};
offset += size;
}
this.lastMeasuredIndex = index;
}
return this.itemSizeAndPositionData[index];
};
SizeAndPositionManager.prototype.getSizeAndPositionOfLastMeasuredItem = function () {
return this.lastMeasuredIndex >= 0 ? this.itemSizeAndPositionData[this.lastMeasuredIndex] : { offset: 0, size: 0 };
};
/**
* Total size of all items being measured.
* This value will be completedly estimated initially.
* As items as measured the estimate will be updated.
*/
SizeAndPositionManager.prototype.getTotalSize = function () {
var lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
return lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size + (this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize;
};
/**
* Determines a new offset that ensures a certain item is visible, given the alignment.
*
* @param align Desired alignment within container; one of "start" (default), "center", or "end"
* @param containerSize Size (width or height) of the container viewport
* @return Offset to use to ensure the specified item is visible
*/
SizeAndPositionManager.prototype.getUpdatedOffsetForIndex = function (_a) {
var _b = _a.align,
align = _b === void 0 ? ALIGNMENT.START : _b,
containerSize = _a.containerSize,
currentOffset = _a.currentOffset,
targetIndex = _a.targetIndex;
if (containerSize <= 0) {
return 0;
}
var datum = this.getSizeAndPositionForIndex(targetIndex);
var maxOffset = datum.offset;
var minOffset = maxOffset - containerSize + datum.size;
var idealOffset;
switch (align) {
case ALIGNMENT.END:
idealOffset = minOffset;
break;
case ALIGNMENT.CENTER:
idealOffset = maxOffset - (containerSize - datum.size) / 2;
break;
case ALIGNMENT.START:
idealOffset = maxOffset;
break;
default:
idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset));
}
var totalSize = this.getTotalSize();
return Math.max(0, Math.min(totalSize - containerSize, idealOffset));
};
SizeAndPositionManager.prototype.getVisibleRange = function (_a) {
var containerSize = _a.containerSize,
offset = _a.offset,
overscanCount = _a.overscanCount;
var totalSize = this.getTotalSize();
if (totalSize === 0) {
return {};
}
var maxOffset = offset + containerSize;
var start = this.findNearestItem(offset);
if (typeof start === 'undefined') {
throw Error("Invalid offset " + offset + " specified");
}
var datum = this.getSizeAndPositionForIndex(start);
offset = datum.offset + datum.size;
var stop = start;
while (offset < maxOffset && stop < this.itemCount - 1) {
stop++;
offset += this.getSizeAndPositionForIndex(stop).size;
}
if (overscanCount) {
start = Math.max(0, start - overscanCount);
stop = Math.min(stop + overscanCount, this.itemCount - 1);
}
return {
start: start,
stop: stop
};
};
/**
* Clear all cached values for items after the specified index.
* This method should be called for any item that has changed its size.
* It will not immediately perform any calculations; they'll be performed the next time getSizeAndPositionForIndex() is called.
*/
SizeAndPositionManager.prototype.resetItem = function (index) {
this.lastMeasuredIndex = Math.min(this.lastMeasuredIndex, index - 1);
};
/**
* Searches for the item (index) nearest the specified offset.
*
* If no exact match is found the next lowest item index will be returned.
* This allows partially visible items (with offsets just before/above the fold) to be visible.
*/
SizeAndPositionManager.prototype.findNearestItem = function (offset) {
if (isNaN(offset)) {
throw Error("Invalid offset " + offset + " specified");
}
// Our search algorithms find the nearest match at or below the specified offset.
// So make sure the offset is at least 0 or no match will be found.
offset = Math.max(0, offset);
var lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
var lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex);
if (lastMeasuredSizeAndPosition.offset >= offset) {
// If we've already measured items within this range just use a binary search as it's faster.
return this.binarySearch({
high: lastMeasuredIndex,
low: 0,
offset: offset
});
} else {
// If we haven't yet measured this high, fallback to an exponential search with an inner binary search.
// The exponential search avoids pre-computing sizes for the full set of items as a binary search would.
// The overall complexity for this approach is O(log n).
return this.exponentialSearch({
index: lastMeasuredIndex,
offset: offset
});
}
};
SizeAndPositionManager.prototype.binarySearch = function (_a) {
var low = _a.low,
high = _a.high,
offset = _a.offset;
var middle = 0;
var currentOffset = 0;
while (low <= high) {
middle = low + Math.floor((high - low) / 2);
currentOffset = this.getSizeAndPositionForIndex(middle).offset;
if (currentOffset === offset) {
return middle;
} else if (currentOffset < offset) {
low = middle + 1;
} else if (currentOffset > offset) {
high = middle - 1;
}
}
if (low > 0) {
return low - 1;
}
return 0;
};
SizeAndPositionManager.prototype.exponentialSearch = function (_a) {
var index = _a.index,
offset = _a.offset;
var interval = 1;
while (index < this.itemCount && this.getSizeAndPositionForIndex(index).offset < offset) {
index += interval;
interval *= 2;
}
return this.binarySearch({
high: Math.min(index, this.itemCount - 1),
low: Math.floor(index / 2),
offset: offset
});
};
return SizeAndPositionManager;
}();
var STYLE_WRAPPER = {
overflow: 'auto',
willChange: 'transform',
WebkitOverflowScrolling: 'touch'
};
var STYLE_INNER = {
position: 'relative',
width: '100%',
minHeight: '100%'
};
var STYLE_ITEM = {
position: 'absolute',
top: 0,
left: 0,
width: '100%'
};
var STYLE_STICKY_ITEM = __assign({}, STYLE_ITEM, { position: 'sticky' });
var VirtualList = /** @class */function (_super) {
__extends(VirtualList, _super);
function VirtualList() {
var _this = _super !== null && _super.apply(this, arguments) || this;
_this.itemSizeGetter = function (itemSize) {
return function (index) {
return _this.getSize(index, itemSize);
};
};
_this.sizeAndPositionManager = new SizeAndPositionManager({
itemCount: _this.props.itemCount,
itemSizeGetter: _this.itemSizeGetter(_this.props.itemSize),
estimatedItemSize: _this.getEstimatedItemSize()
});
_this.state = {
offset: _this.props.scrollOffset || _this.props.scrollToIndex != null && _this.getOffsetForIndex(_this.props.scrollToIndex) || 0,
scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED
};
_this.styleCache = {};
_this.getRef = function (node) {
_this.rootNode = node;
};
_this.handleScroll = function (event) {
var onScroll = _this.props.onScroll;
var offset = _this.getNodeOffset();
if (offset < 0 || _this.state.offset === offset || event.target !== _this.rootNode) {
return;
}
_this.setState({
offset: offset,
scrollChangeReason: SCROLL_CHANGE_REASON.OBSERVED
});
if (typeof onScroll === 'function') {
onScroll(offset, event);
}
};
return _this;
}
VirtualList.prototype.componentDidMount = function () {
var _a = this.props,
scrollOffset = _a.scrollOffset,
scrollToIndex = _a.scrollToIndex;
this.rootNode.addEventListener('scroll', this.handleScroll, {
passive: true
});
if (scrollOffset != null) {
this.scrollTo(scrollOffset);
} else if (scrollToIndex != null) {
this.scrollTo(this.getOffsetForIndex(scrollToIndex));
}
};
VirtualList.prototype.UNSAFE_componentWillReceiveProps = function (nextProps) {
var _a = this.props,
estimatedItemSize = _a.estimatedItemSize,
itemCount = _a.itemCount,
itemSize = _a.itemSize,
scrollOffset = _a.scrollOffset,
scrollToAlignment = _a.scrollToAlignment,
scrollToIndex = _a.scrollToIndex;
var scrollPropsHaveChanged = nextProps.scrollToIndex !== scrollToIndex || nextProps.scrollToAlignment !== scrollToAlignment;
var itemPropsHaveChanged = nextProps.itemCount !== itemCount || nextProps.itemSize !== itemSize || nextProps.estimatedItemSize !== estimatedItemSize;
if (nextProps.itemSize !== itemSize) {
this.sizeAndPositionManager.updateConfig({
itemSizeGetter: this.itemSizeGetter(nextProps.itemSize)
});
}
if (nextProps.itemCount !== itemCount || nextProps.estimatedItemSize !== estimatedItemSize) {
this.sizeAndPositionManager.updateConfig({
itemCount: nextProps.itemCount,
estimatedItemSize: this.getEstimatedItemSize(nextProps)
});
}
if (itemPropsHaveChanged) {
this.recomputeSizes();
}
if (nextProps.scrollOffset !== scrollOffset) {
this.setState({
offset: nextProps.scrollOffset || 0,
scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED
});
} else if (typeof nextProps.scrollToIndex === 'number' && (scrollPropsHaveChanged || itemPropsHaveChanged)) {
this.setState({
offset: this.getOffsetForIndex(nextProps.scrollToIndex, nextProps.scrollToAlignment, nextProps.itemCount),
scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED
});
}
};
VirtualList.prototype.componentDidUpdate = function (_, prevState) {
var _a = this.state,
offset = _a.offset,
scrollChangeReason = _a.scrollChangeReason;
if (prevState.offset !== offset && scrollChangeReason === SCROLL_CHANGE_REASON.REQUESTED) {
this.scrollTo(offset);
}
};
VirtualList.prototype.componentWillUnmount = function () {
this.rootNode.removeEventListener('scroll', this.handleScroll);
};
VirtualList.prototype.scrollTo = function (value) {
var _a = this.props.scrollDirection,
scrollDirection = _a === void 0 ? DIRECTION.VERTICAL : _a;
this.rootNode[scrollProp[scrollDirection]] = value;
};
VirtualList.prototype.getOffsetForIndex = function (index, scrollToAlignment, itemCount) {
if (scrollToAlignment === void 0) {
scrollToAlignment = this.props.scrollToAlignment;
}
if (itemCount === void 0) {
itemCount = this.props.itemCount;
}
var _a = this.props.scrollDirection,
scrollDirection = _a === void 0 ? DIRECTION.VERTICAL : _a;
if (index < 0 || index >= itemCount) {
index = 0;
}
return this.sizeAndPositionManager.getUpdatedOffsetForIndex({
align: scrollToAlignment,
containerSize: this.props[sizeProp[scrollDirection]],
currentOffset: this.state && this.state.offset || 0,
targetIndex: index
});
};
VirtualList.prototype.recomputeSizes = function (startIndex) {
if (startIndex === void 0) {
startIndex = 0;
}
this.styleCache = {};
this.sizeAndPositionManager.resetItem(startIndex);
};
VirtualList.prototype.render = function () {
var _this = this;
var _a = this.props,
estimatedItemSize = _a.estimatedItemSize,
height = _a.height,
_b = _a.overscanCount,
overscanCount = _b === void 0 ? 3 : _b,
renderItem = _a.renderItem,
itemCount = _a.itemCount,
itemSize = _a.itemSize,
onItemsRendered = _a.onItemsRendered,
onScroll = _a.onScroll,
_c = _a.scrollDirection,
scrollDirection = _c === void 0 ? DIRECTION.VERTICAL : _c,
scrollOffset = _a.scrollOffset,
scrollToIndex = _a.scrollToIndex,
scrollToAlignment = _a.scrollToAlignment,
stickyIndices = _a.stickyIndices,
style = _a.style,
width = _a.width,
props = __rest(_a, ["estimatedItemSize", "height", "overscanCount", "renderItem", "itemCount", "itemSize", "onItemsRendered", "onScroll", "scrollDirection", "scrollOffset", "scrollToIndex", "scrollToAlignment", "stickyIndices", "style", "width"]);
var offset = this.state.offset;
var _d = this.sizeAndPositionManager.getVisibleRange({
containerSize: this.props[sizeProp[scrollDirection]] || 0,
offset: offset,
overscanCount: overscanCount
}),
start = _d.start,
stop = _d.stop;
var items = [];
var wrapperStyle = __assign({}, STYLE_WRAPPER, style, { height: height, width: width });
var innerStyle = __assign({}, STYLE_INNER, (_e = {}, _e[sizeProp[scrollDirection]] = this.sizeAndPositionManager.getTotalSize(), _e));
if (stickyIndices != null && stickyIndices.length !== 0) {
stickyIndices.forEach(function (index) {
return items.push(renderItem({
index: index,
style: _this.getStyle(index, true)
}));
});
if (scrollDirection === DIRECTION.HORIZONTAL) {
innerStyle.display = 'flex';
}
}
if (typeof start !== 'undefined' && typeof stop !== 'undefined') {
for (var index = start; index <= stop; index++) {
if (stickyIndices != null && stickyIndices.includes(index)) {
continue;
}
items.push(renderItem({
index: index,
style: this.getStyle(index, false)
}));
}
if (typeof onItemsRendered === 'function') {
onItemsRendered({
startIndex: start,
stopIndex: stop
});
}
}
return createElement("div", __assign({ ref: this.getRef }, props, { style: wrapperStyle }), createElement("div", { style: innerStyle }, items));
var _e;
};
VirtualList.prototype.getNodeOffset = function () {
var _a = this.props.scrollDirection,
scrollDirection = _a === void 0 ? DIRECTION.VERTICAL : _a;
return this.rootNode[scrollProp[scrollDirection]];
};
VirtualList.prototype.getEstimatedItemSize = function (props) {
if (props === void 0) {
props = this.props;
}
return props.estimatedItemSize || typeof props.itemSize === 'number' && props.itemSize || 50;
};
VirtualList.prototype.getSize = function (index, itemSize) {
if (typeof itemSize === 'function') {
return itemSize(index);
}
return Array.isArray(itemSize) ? itemSize[index] : itemSize;
};
VirtualList.prototype.getStyle = function (index, sticky) {
var style = this.styleCache[index];
if (style) {
return style;
}
var _a = this.props.scrollDirection,
scrollDirection = _a === void 0 ? DIRECTION.VERTICAL : _a;
var _b = this.sizeAndPositionManager.getSizeAndPositionForIndex(index),
size = _b.size,
offset = _b.offset;
return this.styleCache[index] = sticky ? __assign({}, STYLE_STICKY_ITEM, (_c = {}, _c[sizeProp[scrollDirection]] = size, _c[marginProp[scrollDirection]] = offset, _c[oppositeMarginProp[scrollDirection]] = -(offset + size), _c.zIndex = 1, _c)) : __assign({}, STYLE_ITEM, (_d = {}, _d[sizeProp[scrollDirection]] = size, _d[positionProp[scrollDirection]] = offset, _d));
var _c, _d;
};
VirtualList.defaultProps = {
overscanCount: 3,
scrollDirection: DIRECTION.VERTICAL,
width: '100%'
};
VirtualList.propTypes = {
estimatedItemSize: number,
height: oneOfType([number, string]).isRequired,
itemCount: number.isRequired,
itemSize: oneOfType([number, array, func]).isRequired,
onScroll: func,
onItemsRendered: func,
overscanCount: number,
renderItem: func.isRequired,
scrollOffset: number,
scrollToIndex: number,
scrollToAlignment: oneOf([ALIGNMENT.AUTO, ALIGNMENT.START, ALIGNMENT.CENTER, ALIGNMENT.END]),
scrollDirection: oneOf([DIRECTION.HORIZONTAL, DIRECTION.VERTICAL]),
stickyIndices: arrayOf(number),
style: object,
width: oneOfType([number, string])
};
return VirtualList;
}(PureComponent);
export { DIRECTION as ScrollDirection };
export default VirtualList;