UNPKG

office-ui-fabric-react

Version:

Reusable React components for building experiences for Office 365.

505 lines (503 loc) • 23.8 kB
"use strict"; var __extends = (this && this.__extends) || function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; function __() { this.constructor = d; } d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); }; var React = require('react'); var Utilities_1 = require('../../Utilities'); var scroll_1 = require('../../utilities/scroll'); var RESIZE_DELAY = 16; var MIN_SCROLL_UPDATE_DELAY = 100; var MAX_SCROLL_UPDATE_DELAY = 500; var IDLE_DEBOUNCE_DELAY = 200; var DEFAULT_ITEMS_PER_PAGE = 10; var DEFAULT_PAGE_HEIGHT = 30; var DEFAULT_RENDERED_WINDOWS_BEHIND = 2; var DEFAULT_RENDERED_WINDOWS_AHEAD = 2; var EMPTY_RECT = { top: -1, bottom: -1, left: -1, right: -1, width: 0, height: 0 }; // Naming expensive measures so that they're named in profiles. var _measurePageRect = function (element) { return element.getBoundingClientRect(); }; var _measureSurfaceRect = _measurePageRect; /** * The List renders virtualized pages of items. Each page's item count is determined by the getItemCountForPage callback if * provided by the caller, or 10 as default. Each page's height is determined by the getPageHeight callback if provided by * the caller, or by cached measurements if available, or by a running average, or a default fallback. * * The algorithm for rendering pages works like this: * * 1. Predict visible pages based on "current measure data" (page heights, surface position, visible window) * 2. If changes are necessary, apply changes (add/remove pages) * 3. For pages that are added, measure the page heights if we need to using getBoundingClientRect * 4. If measurements don't match predictions, update measure data and goto step 1 asynchronously * * Measuring too frequently can pull performance down significantly. To compensate, we cache measured values so that * we can avoid re-measuring during operations that should not alter heights, like scrolling. * * However, certain operations can make measure data stale. For example, resizing the list, or passing in new props, * or forcing an update change cause pages to shrink/grow. When these operations occur, we increment a measureVersion * number, which we associate with cached measurements and use to determine if a remeasure should occur. */ var List = (function (_super) { __extends(List, _super); function List(props) { _super.call(this, props); this.state = { pages: [] }; this._estimatedPageHeight = 0; this._totalEstimates = 0; this._requiredWindowsAhead = 0; this._requiredWindowsBehind = 0; // Track the measure version for everything. this._measureVersion = 0; // Ensure that scrolls are lazy updated. this._onAsyncScroll = this._async.debounce(this._onAsyncScroll, MIN_SCROLL_UPDATE_DELAY, { leading: false, maxWait: MAX_SCROLL_UPDATE_DELAY }); this._onAsyncIdle = this._async.debounce(this._onAsyncIdle, IDLE_DEBOUNCE_DELAY, { leading: false }); this._onAsyncResize = this._async.debounce(this._onAsyncResize, RESIZE_DELAY, { leading: false }); this._cachedPageHeights = {}; this._estimatedPageHeight = 0; this._focusedIndex = -1; this._scrollingToIndex = -1; } List.prototype.componentDidMount = function () { this._updatePages(); this._measureVersion++; this._scrollElement = scroll_1.findScrollableParent(this.refs.root); this._events.on(window, 'resize', this._onAsyncResize); this._events.on(this.refs.root, 'focus', this._onFocus, true); if (this._scrollElement) { this._events.on(this._scrollElement, 'scroll', this._onScroll); this._events.on(this._scrollElement, 'scroll', this._onAsyncScroll); } }; List.prototype.componentWillReceiveProps = function (newProps) { if (newProps.items !== this.props.items || newProps.renderCount !== this.props.renderCount || newProps.startIndex !== this.props.startIndex) { this._measureVersion++; this._updatePages(newProps); } }; List.prototype.shouldComponentUpdate = function (newProps, newState) { var _a = this.props, renderedWindowsAhead = _a.renderedWindowsAhead, renderedWindowsBehind = _a.renderedWindowsBehind; var oldPages = this.state.pages; var newPages = newState.pages, measureVersion = newState.measureVersion; var shouldComponentUpdate = false; if (this._measureVersion === measureVersion && newProps.renderedWindowsAhead === renderedWindowsAhead, newProps.renderedWindowsBehind === renderedWindowsBehind, newProps.items === this.props.items && oldPages.length === newPages.length) { for (var i = 0; i < oldPages.length; i++) { var oldPage = oldPages[i]; var newPage = newPages[i]; if ((oldPage.key !== newPage.key || oldPage.itemCount !== newPage.itemCount)) { shouldComponentUpdate = true; break; } } } else { shouldComponentUpdate = true; } return shouldComponentUpdate; }; List.prototype.forceUpdate = function () { // Ensure that when the list is force updated we update the pages first before render. this._updateRenderRects(this.props, true); this._updatePages(); this._measureVersion++; _super.prototype.forceUpdate.call(this); }; List.prototype.render = function () { var className = this.props.className; var pages = this.state.pages; var pageElements = []; for (var i = 0; i < pages.length; i++) { pageElements.push(this._renderPage(pages[i])); } return (React.createElement("div", {ref: 'root', className: Utilities_1.css('ms-List', className)}, React.createElement("div", {ref: 'surface', className: 'ms-List-surface'}, pageElements) )); }; List.prototype._renderPage = function (page) { var onRenderCell = this.props.onRenderCell; var cells = []; var pageStyle = this._getPageStyle(page); for (var i = 0; page.items && i < page.items.length; i++) { var item = page.items[i]; var itemKey = (item ? item.key : null); if (itemKey === null || itemKey === undefined) { itemKey = page.startIndex + i; } cells.push(React.createElement("div", {className: 'ms-List-cell', key: itemKey, "data-list-index": i + page.startIndex, "data-automationid": 'ListCell'}, onRenderCell(item, page.startIndex + i))); } return (React.createElement("div", {className: 'ms-List-page', key: page.key, ref: page.key, style: pageStyle}, cells)); }; /** Generate the style object for the page. */ List.prototype._getPageStyle = function (page) { var style; var getPageStyle = this.props.getPageStyle; if (getPageStyle) { style = getPageStyle(page); } if (!page.items) { style = style || {}; style.height = page.height; } return style; }; /** Track the last item index focused so that we ensure we keep it rendered. */ List.prototype._onFocus = function (ev) { var target = ev.target; while (target !== this.refs.surface) { var indexString = target.getAttribute('data-list-index'); if (indexString) { this._focusedIndex = Number(indexString); break; } target = Utilities_1.getParent(target); } }; /** * Called synchronously to reset the required render range to 0 on scrolling. After async scroll has executed, * we will call onAsyncIdle which will reset it back to it's correct value. */ List.prototype._onScroll = function () { this._requiredWindowsAhead = 0; this._requiredWindowsBehind = 0; }; /** * Debounced method to asynchronously update the visible region on a scroll event. */ List.prototype._onAsyncScroll = function () { this._updateRenderRects(); // Only update pages when the visible rect falls outside of the materialized rect. if (!this._materializedRect || !_isContainedWithin(this._requiredRect, this._materializedRect)) { this._updatePages(); } else { } }; /** * This is an async debounced method that will try and increment the windows we render. If we can increment * either, we increase the amount we render and re-evaluate. */ List.prototype._onAsyncIdle = function () { var _a = this.props, renderedWindowsAhead = _a.renderedWindowsAhead, renderedWindowsBehind = _a.renderedWindowsBehind; var _b = this, requiredWindowsAhead = _b._requiredWindowsAhead, requiredWindowsBehind = _b._requiredWindowsBehind; var windowsAhead = Math.min(renderedWindowsAhead, requiredWindowsAhead + 1); var windowsBehind = Math.min(renderedWindowsBehind, requiredWindowsBehind + 1); if (windowsAhead !== requiredWindowsAhead || windowsBehind !== requiredWindowsBehind) { // console.log('idling', windowsBehind, windowsAhead); this._requiredWindowsAhead = windowsAhead; this._requiredWindowsBehind = windowsBehind; this._updateRenderRects(); this._updatePages(); } if (renderedWindowsAhead > windowsAhead || renderedWindowsBehind > windowsBehind) { // Async increment on next tick. this._onAsyncIdle(); } }; List.prototype._onAsyncResize = function () { this.forceUpdate(); }; List.prototype._updatePages = function (props) { var _this = this; var _a = (props || this.props), items = _a.items, startIndex = _a.startIndex, renderCount = _a.renderCount; renderCount = this._getRenderCount(props); // console.log('updating pages'); if (!this._requiredRect) { this._updateRenderRects(props); } var newListState = this._buildPages(items, startIndex, renderCount); var oldListPages = this.state.pages; this.setState(newListState, function () { // If measured version is invalid since we've updated the DOM var heightsChanged = _this._updatePageMeasurements(oldListPages, newListState.pages); // On first render, we should re-measure so that we don't get a visual glitch. if (heightsChanged) { _this._materializedRect = null; if (!_this._hasCompletedFirstRender) { _this._hasCompletedFirstRender = true; _this._updatePages(); } else { _this._onAsyncScroll(); } } else { // Enqueue an idle bump. _this._onAsyncIdle(); } }); }; List.prototype._updatePageMeasurements = function (oldPages, newPages) { var renderedIndexes = {}; var heightChanged = false; var renderCount = this._getRenderCount(); for (var i = 0; i < oldPages.length; i++) { var page = oldPages[i]; if (page.items) { renderedIndexes[page.startIndex] = page; } } for (var i = 0; i < newPages.length; i++) { var page = newPages[i]; if (page.items) { // Only evaluate page height if the page contains less items than total. if (page.items.length < renderCount) { heightChanged = this._measurePage(page) || heightChanged; } if (!renderedIndexes[page.startIndex]) { this._onPageAdded(page); } else { delete renderedIndexes[page.startIndex]; } } } for (var index in renderedIndexes) { if (renderedIndexes.hasOwnProperty(index)) { this._onPageRemoved(renderedIndexes[index]); } } return heightChanged; }; /** * Given a page, measure its dimensions, update cache. * @returns True if the height has changed. */ List.prototype._measurePage = function (page) { var hasChangedHeight = false; var pageElement = this.refs[page.key]; var cachedHeight = this._cachedPageHeights[page.startIndex]; // console.log(' * measure attempt', page.startIndex, cachedHeight); if (pageElement && (!cachedHeight || cachedHeight.measureVersion !== this._measureVersion)) { var newClientRect = _measurePageRect(pageElement); hasChangedHeight = page.height !== newClientRect.height; // console.warn(' *** expensive page measure', page.startIndex, page.height, newClientRect.height); page.height = newClientRect.height; this._cachedPageHeights[page.startIndex] = { height: newClientRect.height, measureVersion: this._measureVersion }; this._estimatedPageHeight = Math.round(((this._estimatedPageHeight * this._totalEstimates) + newClientRect.height) / (this._totalEstimates + 1)); this._totalEstimates++; } return hasChangedHeight; }; /** Called when a page has been added to the DOM. */ List.prototype._onPageAdded = function (page) { var onPageAdded = this.props.onPageAdded; // console.log('page added', page.startIndex, this.state.pages.map(page=>page.key).join(', ')); if (onPageAdded) { onPageAdded(page); } }; /** Called when a page has been removed from the DOM. */ List.prototype._onPageRemoved = function (page) { var onPageRemoved = this.props.onPageRemoved; // console.log(' --- page removed', page.startIndex, this.state.pages.map(page=>page.key).join(', ')); if (onPageRemoved) { onPageRemoved(page); } }; /** Build up the pages that should be rendered. */ List.prototype._buildPages = function (items, startIndex, renderCount) { var materializedRect = Utilities_1.assign({}, EMPTY_RECT); var itemsPerPage = 1; var pages = []; var pageTop = 0; var currentSpacer = null; var focusedIndex = this._focusedIndex; var endIndex = startIndex + renderCount; // First render is very important to track; when we render cells, we have no idea of estimated page height. // So we should default to rendering only the first page so that we can get information. // However if the user provides a measure function, let's just assume they know the right heights. var isFirstRender = this._estimatedPageHeight === 0 && !this.props.getPageHeight; var _loop_1 = function(itemIndex) { itemsPerPage = this_1._getItemCountForPage(itemIndex, this_1._allowedRect); var pageHeight = this_1._getPageHeight(itemIndex, itemsPerPage, this_1._surfaceRect); var pageBottom = pageTop + pageHeight - 1; var isPageRendered = Utilities_1.findIndex(this_1.state.pages, function (page) { return page.items && page.startIndex === itemIndex; }) > -1; var isPageInAllowedRange = pageBottom >= this_1._allowedRect.top && pageTop <= this_1._allowedRect.bottom; var isPageInRequiredRange = pageBottom >= this_1._requiredRect.top && pageTop <= this_1._requiredRect.bottom; var isPageVisible = !isFirstRender && (isPageInRequiredRange || (isPageInAllowedRange && isPageRendered)); var isPageFocused = focusedIndex >= itemIndex && focusedIndex < (itemIndex + itemsPerPage); var isFirstPage = itemIndex === startIndex; // console.log('building page', itemIndex, 'pageTop: ' + pageTop, 'inAllowed: ' + isPageInAllowedRange, 'inRequired: ' + isPageInRequiredRange); // Only render whats visible, focused, or first page. if (isPageVisible || isPageFocused || isFirstPage) { if (currentSpacer) { pages.push(currentSpacer); currentSpacer = null; } var itemsInPage = Math.min(itemsPerPage, endIndex - itemIndex); var newPage = this_1._createPage(null, items.slice(itemIndex, itemIndex + itemsInPage), itemIndex); newPage.top = pageTop; newPage.height = pageHeight; pages.push(newPage); if (isPageInRequiredRange) { _mergeRect(materializedRect, { top: pageTop, bottom: pageBottom, height: pageHeight, left: this_1._allowedRect.left, right: this_1._allowedRect.right, width: this_1._allowedRect.width }); } } else { if (!currentSpacer) { currentSpacer = this_1._createPage('spacer-' + itemIndex, null, itemIndex, 0); } currentSpacer.height = (currentSpacer.height || 0) + (pageBottom - pageTop) + 1; currentSpacer.itemCount += itemsPerPage; } pageTop += (pageBottom - pageTop + 1); if (isFirstRender) { return "break"; } }; var this_1 = this; for (var itemIndex = startIndex; itemIndex < endIndex; itemIndex += itemsPerPage) { var state_1 = _loop_1(itemIndex); if (state_1 === "break") break; } if (currentSpacer) { currentSpacer.key = 'spacer-end'; pages.push(currentSpacer); } this._materializedRect = materializedRect; // console.log('materialized: ', materializedRect); return { pages: pages, measureVersion: this._measureVersion }; }; /** * Get the pixel height of a give page. Will use the props getPageHeight first, and if not provided, fallback to * cached height, or estimated page height, or default page height. */ List.prototype._getPageHeight = function (itemIndex, itemsPerPage, visibleRect) { if (this.props.getPageHeight) { return this.props.getPageHeight(itemIndex, visibleRect); } else { var cachedHeight = (this._cachedPageHeights[itemIndex]); return cachedHeight ? cachedHeight.height : (this._estimatedPageHeight || DEFAULT_PAGE_HEIGHT); } }; List.prototype._getItemCountForPage = function (itemIndex, visibileRect) { var itemsPerPage = this.props.getItemCountForPage ? this.props.getItemCountForPage(itemIndex, visibileRect) : DEFAULT_ITEMS_PER_PAGE; return itemsPerPage ? itemsPerPage : DEFAULT_ITEMS_PER_PAGE; }; List.prototype._createPage = function (pageKey, items, startIndex, count, style) { pageKey = pageKey || ('page-' + startIndex); // Fill undefined cells because array.map will ignore undefined cells. if (items) { for (var i = 0; i < items.length; i++) { items[i] = items[i] || null; } } return { key: pageKey, startIndex: startIndex === undefined ? -1 : startIndex, itemCount: (count === undefined) ? (items ? items.length : 0) : count, items: items, style: style || {}, top: 0, height: 0 }; }; List.prototype._getRenderCount = function (props) { var _a = props || this.props, items = _a.items, startIndex = _a.startIndex, renderCount = _a.renderCount; return (renderCount === undefined ? (items ? items.length - startIndex : 0) : renderCount); }; /** Calculate the visible rect within the list where top: 0 and left: 0 is the top/left of the list. */ List.prototype._updateRenderRects = function (props, forceUpdate) { var _a = (props || this.props), renderedWindowsAhead = _a.renderedWindowsAhead, renderedWindowsBehind = _a.renderedWindowsBehind; var pages = this.state.pages; var renderCount = this._getRenderCount(props); var surfaceRect = this._surfaceRect; // WARNING: EXPENSIVE CALL! We need to know the surface top relative to the window. if (forceUpdate || !pages || !this._surfaceRect || (pages.length > 0 && pages[0].items && pages[0].items.length < renderCount)) { surfaceRect = this._surfaceRect = _measureSurfaceRect(this.refs.surface); } // If the surface is above the container top or below the container bottom, or if this is not the first // render return empty rect. // The first time the list gets rendered we need to calculate the rectangle. The width of the list is // used to calculate the width of the list items. var visibleTop = Math.max(0, -surfaceRect.top); var visibleRect = { top: visibleTop, left: surfaceRect.left, bottom: visibleTop + window.innerHeight, right: surfaceRect.right, width: surfaceRect.width, height: window.innerHeight }; // The required/allowed rects are adjusted versions of the visible rect. this._requiredRect = _expandRect(visibleRect, this._requiredWindowsBehind, this._requiredWindowsAhead); this._allowedRect = _expandRect(visibleRect, renderedWindowsBehind, renderedWindowsAhead); }; List.defaultProps = { startIndex: 0, onRenderCell: function (item, index, containsFocus) { return (React.createElement("div", null, (item && item.name) || '')); }, renderedWindowsAhead: DEFAULT_RENDERED_WINDOWS_AHEAD, renderedWindowsBehind: DEFAULT_RENDERED_WINDOWS_BEHIND }; return List; }(Utilities_1.BaseComponent)); exports.List = List; function _expandRect(rect, pagesBefore, pagesAfter) { var top = rect.top - (pagesBefore * rect.height); var height = rect.height + ((pagesBefore + pagesAfter) * rect.height); return { top: top, bottom: top + height, height: height, left: rect.left, right: rect.right, width: rect.width }; } function _isContainedWithin(innerRect, outerRect) { return (innerRect.top >= outerRect.top && innerRect.left >= outerRect.left && innerRect.bottom <= outerRect.bottom && innerRect.right <= outerRect.right); } function _mergeRect(targetRect, newRect) { targetRect.top = (newRect.top < targetRect.top || targetRect.top === -1) ? newRect.top : targetRect.top; targetRect.left = (newRect.left < targetRect.left || targetRect.left === -1) ? newRect.left : targetRect.left; targetRect.bottom = (newRect.bottom > targetRect.bottom || targetRect.bottom === -1) ? newRect.bottom : targetRect.bottom; targetRect.right = (newRect.right > targetRect.right || targetRect.right === -1) ? newRect.right : targetRect.right; targetRect.width = targetRect.right - targetRect.left + 1; targetRect.height = targetRect.bottom - targetRect.top + 1; return targetRect; } //# sourceMappingURL=List.js.map