UNPKG

hyperlist

Version:

A performant virtual scrolling list library

454 lines (358 loc) 13.1 kB
'use strict' // Default configuration. const defaultConfig = { width: '100%', height: '100%' } // Check for valid number. const isNumber = input => Number(input) === Number(input) // Add a class to an element. const addClass = 'classList' in document.documentElement ? (element, className) => { element.classList.add(className) } : (element, className) => { const oldClass = element.getAttribute('class') || '' element.setAttribute('class', `${oldClass} ${className}`) } /** * Creates a HyperList instance that virtually scrolls very large amounts of * data effortlessly. */ export default class HyperList { static create (element, userProvidedConfig) { return new HyperList(element, userProvidedConfig) } /** * Merge given css style on an element * @param {DOMElement} element * @param {Object} style */ static mergeStyle (element, style) { for (let i in style) { if (element.style[i] !== style[i]) { element.style[i] = style[i] } } } static getMaxBrowserHeight () { // Create two elements, the wrapper is `1px` tall and is transparent and // positioned at the top of the page. Inside that is an element that gets // set to 1 billion pixels. Then reads the max height the browser can // calculate. const wrapper = document.createElement('div') const fixture = document.createElement('div') // As said above, these values get set to put the fixture elements into the // right visual state. HyperList.mergeStyle(wrapper, {position: 'absolute', height: '1px', opacity: 0}) HyperList.mergeStyle(fixture, {height: '1e7px'}) // Add the fixture into the wrapper element. wrapper.appendChild(fixture) // Apply to the page, the values won't kick in unless this is attached. document.body.appendChild(wrapper) // Get the maximum element height in pixels. const maxElementHeight = fixture.offsetHeight // Remove the element immediately after reading the value. document.body.removeChild(wrapper) return maxElementHeight } constructor (element, userProvidedConfig) { this._config = {} this._lastRepaint = null this._maxElementHeight = HyperList.getMaxBrowserHeight() this.refresh(element, userProvidedConfig) const config = this._config // Create internal render loop. const render = () => { const scrollTop = this._getScrollPosition() const lastRepaint = this._lastRepaint this._renderAnimationFrame = window.requestAnimationFrame(render) if (scrollTop === lastRepaint) { return } const diff = lastRepaint ? scrollTop - lastRepaint : 0 if (!lastRepaint || diff < 0 || diff > this._averageHeight) { let rendered = this._renderChunk() this._lastRepaint = scrollTop if (rendered !== false && typeof config.afterRender === 'function') { config.afterRender() } } } render() } destroy () { window.cancelAnimationFrame(this._renderAnimationFrame) } refresh (element, userProvidedConfig) { Object.assign(this._config, defaultConfig, userProvidedConfig) if (!element || element.nodeType !== 1) { throw new Error('HyperList requires a valid DOM Node container') } this._element = element const config = this._config const scroller = this._scroller || config.scroller || document.createElement(config.scrollerTagName || 'tr') // Default configuration option `useFragment` to `true`. if (typeof config.useFragment !== 'boolean') { this._config.useFragment = true } if (!config.generate) { throw new Error('Missing required `generate` function') } if (!isNumber(config.total)) { throw new Error('Invalid required `total` value, expected number') } if (!Array.isArray(config.itemHeight) && !isNumber(config.itemHeight)) { throw new Error(` Invalid required \`itemHeight\` value, expected number or array `.trim()) } else if (isNumber(config.itemHeight)) { this._itemHeights = Array(config.total).fill(config.itemHeight) } else { this._itemHeights = config.itemHeight } // Width and height should be coerced to string representations. Either in // `%` or `px`. Object.keys(defaultConfig).filter(prop => prop in config).forEach(prop => { const value = config[prop] const isValueNumber = isNumber(value) if (value && typeof value !== 'string' && typeof value !== 'number') { let msg = `Invalid optional \`${prop}\`, expected string or number` throw new Error(msg) } else if (isValueNumber) { config[prop] = `${value}px` } }) const isHoriz = Boolean(config.horizontal) const value = config[isHoriz ? 'width' : 'height'] if (value) { const isValueNumber = isNumber(value) const isValuePercent = isValueNumber ? false : value.slice(-1) === '%' // Compute the containerHeight as number const numberValue = isValueNumber ? value : parseInt(value.replace(/px|%/, ''), 10) const innerSize = window[isHoriz ? 'innerWidth' : 'innerHeight'] if (isValuePercent) { this._containerSize = (innerSize * numberValue) / 100 } else { this._containerSize = isNumber(value) ? value : numberValue } } const scrollContainer = config.scrollContainer const scrollerHeight = config.itemHeight * config.total const maxElementHeight = this._maxElementHeight if (scrollerHeight > maxElementHeight) { console.warn([ 'HyperList: The maximum element height', maxElementHeight + 'px has', 'been exceeded; please reduce your item height.' ].join(' ')) } // Decorate the container element with styles that will match // the user supplied configuration. const elementStyle = { width: `${config.width}`, height: scrollContainer ? `${scrollerHeight}px` : `${config.height}`, overflow: scrollContainer ? 'none' : 'auto', position: 'relative' } HyperList.mergeStyle(element, elementStyle) if (scrollContainer) { HyperList.mergeStyle(config.scrollContainer, {overflow: 'auto'}) } const scrollerStyle = { opacity: '0', position: 'absolute', [isHoriz ? 'height' : 'width']: '1px', [isHoriz ? 'width' : 'height']: `${scrollerHeight}px` } HyperList.mergeStyle(scroller, scrollerStyle) // Only append the scroller element once. if (!this._scroller) { element.appendChild(scroller) } const padding = this._computeScrollPadding() this._scrollPaddingBottom = padding.bottom this._scrollPaddingTop = padding.top // Set the scroller instance. this._scroller = scroller this._scrollHeight = this._computeScrollHeight() // Reuse the item positions if refreshed, otherwise set to empty array. this._itemPositions = this._itemPositions || Array(config.total).fill(0) // Each index in the array should represent the position in the DOM. this._computePositions(0) // Render after refreshing. Force render if we're calling refresh manually. this._renderChunk(this._lastRepaint !== null) if (typeof config.afterRender === 'function') { config.afterRender() } } _getRow (i) { const config = this._config let item = config.generate(i) let height = item.height if (height !== undefined && isNumber(height)) { item = item.element // The height isn't the same as predicted, compute positions again if (height !== this._itemHeights[i]) { this._itemHeights[i] = height this._computePositions(i) this._scrollHeight = this._computeScrollHeight(i) } } else { height = this._itemHeights[i] } if (!item || item.nodeType !== 1) { throw new Error(`Generator did not return a DOM Node for index: ${i}`) } addClass(item, config.rowClassName || 'vrow') const top = this._itemPositions[i] + this._scrollPaddingTop HyperList.mergeStyle(item, { position: 'absolute', [config.horizontal ? 'left' : 'top']: `${top}px` }) return item } _getScrollPosition () { const config = this._config if (typeof config.overrideScrollPosition === 'function') { return config.overrideScrollPosition() } return this._element[config.horizontal ? 'scrollLeft' : 'scrollTop'] } _renderChunk (force) { const config = this._config const element = this._element const scrollTop = this._getScrollPosition() const total = config.total let from = config.reverse ? this._getReverseFrom(scrollTop) : this._getFrom(scrollTop) - 1 if (from < 0 || from - this._screenItemsLen < 0) { from = 0 } if (!force && this._lastFrom === from) { return false } this._lastFrom = from let to = from + this._cachedItemsLen if (to > total || to + this._cachedItemsLen > total) { to = total } // Append all the new rows in a document fragment that we will later append // to the parent node const fragment = config.useFragment ? document.createDocumentFragment() : [ // Sometimes you'll pass fake elements to this tool and Fragments require // real elements. ] // The element that forces the container to scroll. const scroller = this._scroller // Keep the scroller in the list of children. fragment[config.useFragment ? 'appendChild' : 'push'](scroller) for (let i = from; i < to; i++) { let row = this._getRow(i) fragment[config.useFragment ? 'appendChild' : 'push'](row) } if (config.applyPatch) { return config.applyPatch(element, fragment) } element.innerHTML = '' element.appendChild(fragment) } _computePositions (from = 1) { const config = this._config const total = config.total const reverse = config.reverse if (from < 1 && !reverse) { from = 1 } for (let i = from; i < total; i++) { if (reverse) { if (i === 0) { this._itemPositions[0] = this._scrollHeight - this._itemHeights[0] } else { this._itemPositions[i] = this._itemPositions[i - 1] - this._itemHeights[i] } } else { this._itemPositions[i] = this._itemHeights[i - 1] + this._itemPositions[i - 1] } } } _computeScrollHeight () { const config = this._config const isHoriz = Boolean(config.horizontal) const total = config.total const scrollHeight = this._itemHeights.reduce((a, b) => a + b, 0) + this._scrollPaddingBottom + this._scrollPaddingTop HyperList.mergeStyle(this._scroller, { opacity: 0, position: 'absolute', top: '0px', [isHoriz ? 'height' : 'width']: '1px', [isHoriz ? 'width' : 'height']: `${scrollHeight}px` }) // Calculate the height median const sortedItemHeights = this._itemHeights.slice(0).sort((a, b) => a - b) const middle = Math.floor(total / 2) const averageHeight = total % 2 === 0 ? (sortedItemHeights[middle] + sortedItemHeights[middle - 1]) / 2 : sortedItemHeights[middle] const clientProp = isHoriz ? 'clientWidth' : 'clientHeight' const element = config.scrollContainer ? config.scrollContainer : this._element const containerHeight = element[clientProp] ? element[clientProp] : this._containerSize this._screenItemsLen = Math.ceil(containerHeight / averageHeight) this._containerSize = containerHeight // Cache 3 times the number of items that fit in the container viewport. this._cachedItemsLen = Math.max(this._cachedItemsLen || 0, this._screenItemsLen * 3) this._averageHeight = averageHeight if (config.reverse) { window.requestAnimationFrame(() => { if (isHoriz) { this._element.scrollLeft = scrollHeight } else { this._element.scrollTop = scrollHeight } }) } return scrollHeight } _computeScrollPadding () { const config = this._config const isHoriz = Boolean(config.horizontal) const isReverse = config.reverse const styles = window.getComputedStyle(this._element) const padding = location => { const cssValue = styles.getPropertyValue(`padding-${location}`) return parseInt(cssValue, 10) || 0 } if (isHoriz && isReverse) { return { bottom: padding('left'), top: padding('right') } } else if (isHoriz) { return { bottom: padding('right'), top: padding('left') } } else if (isReverse) { return { bottom: padding('top'), top: padding('bottom') } } else { return { bottom: padding('bottom'), top: padding('top') } } } _getFrom (scrollTop) { let i = 0 while (this._itemPositions[i] < scrollTop) { i++ } return i } _getReverseFrom (scrollTop) { let i = this._config.total - 1 while (i > 0 && this._itemPositions[i] < scrollTop + this._containerSize) { i-- } return i } }