UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

875 lines (874 loc) 71.4 kB
import "../../CommonImports"; import "../../Core/core.css"; import "./DropdownList.css"; import "./List.css"; import "./ListDropIndicator.css"; import * as React from "react"; import { ObservableArray, ObservableLike } from '../../Core/Observable'; import * as Utils_Accessibility from '../../Core/Util/Accessibility'; import { FocusWithin } from '../../FocusWithin'; import { FocusZone, FocusZoneContext, FocusZoneDirection } from '../../FocusZone'; import { Icon } from '../../Icon'; import { Intersection, IntersectionContext } from '../../Intersection'; import { getDefaultLinkProps, Link } from '../../Link'; import { Observer, UncheckedObserver } from '../../Observer'; import * as Resources from '../../Resources.Widgets'; import { Tooltip } from '../../TooltipEx'; import { css, eventTargetContainsNode, getSafeId, KeyCode } from '../../Util'; import { EventDispatch } from '../../Utilities/Dispatch'; import { getDragInProgress } from '../../Utilities/DragDrop'; import { getTabIndex } from '../../Utilities/Focus'; /** * The List component is used to render a collection of items with a series of rows. */ export class List extends React.Component { constructor(props) { super(props); // Track the table element used to render the rows. this.bodyElement = React.createRef(); this.listElement = React.createRef(); // Manage data about pages, including their spacers. this.spacerElements = {}; this.scrollToIndex = -1; this.scrollToOptions = undefined; // Focus/Selection management members. this.selectOnFocus = true; this.focusIndex = -1; this.pivotIndex = -1; this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS = window.__VSS_INTERSECTION_PERFORMANCE_IMPROVEMENT_ENABLED === true; // Previous materialized range — used to skip attachContentVisibilityListeners when unchanged. this.prevFirstMaterialized = -1; this.prevLastMaterialized = -1; this.onVirtualizeKeyDown = (e) => { if (this.state.virtualize && e.ctrlKey && e.altKey && e.key === "v") { const rowCount = this.props.itemProvider.length; this.setState({ virtualize: false, lastMaterialized: rowCount - 1, lastRendered: rowCount - 1, firstMaterialized: 0, firstRendered: 0 }); Utils_Accessibility.announce(Resources.VirtualizationDisabled); } }; this.onBlur = () => { this.focusIndex = -1; }; this.onClick = (event) => { this.onDispatch(event); if (!event.defaultPrevented && !(event.altKey && this.props.selectableText)) { if (this.listElement.current) { const { cellElement, rowIndex } = cellFromEvent(event); if (!cellElement || !eventTargetContainsNode(event, ["A"], cellElement)) { const item = ObservableLike.getValue(this.state.rows[rowIndex]); if (rowIndex >= 0 && item) { const listRow = { data: item, index: rowIndex }; // Even for singleClickActivation we fire the selection before activation. if (this.props.selectRowOnClick) { this.processSelectionEvent(event, listRow); } // For singleClickActivation we want the activation as well. if (this.props.singleClickActivation) { this.rowActivated(event, listRow); } } } } } }; this.onDispatch = (event) => { this.state.eventDispatch.dispatchEvent(event); }; this.onDoubleClick = (event) => { this.onDispatch(event); if (!event.defaultPrevented && !this.props.singleClickActivation) { const rowIndex = cellFromEvent(event).rowIndex; const item = ObservableLike.getValue(this.state.rows[rowIndex]); if (rowIndex >= 0 && item) { this.rowActivated(event, { data: item, index: rowIndex }); } } }; this.onFocusBody = (event) => { // The first time the list gets focus we need to select initial row if we are performing // selection of focus. if (this.selectOnFocus) { const { selection } = this.props; if (!selection || selection.selectOnFocus) { const rowIndex = this.focusIndex; if (rowIndex >= 0) { const item = ObservableLike.getValue(this.state.rows[rowIndex]); if (item) { this.processSelectionEvent(event, { data: item, index: rowIndex }); } } } this.selectOnFocus = false; } }; this.onFocusItem = (rowIndex, event) => { const { focusIndex } = this; if (focusIndex !== rowIndex) { // We need to re-render the previously focused row and newly focused row so we will // clear the cached values. if (focusIndex >= 0) { delete this.state.renderedRows[focusIndex]; } else { // If there was a tabble row that was not the focusIndex row we need to update this // row as well to get it re-rendered without the tabIndex. delete this.state.renderedRows[this.getInitialTabbableRow()]; } delete this.state.renderedRows[rowIndex]; this.focusIndex = rowIndex; const item = ObservableLike.getValue(this.state.rows[rowIndex]); if (item) { this.rowFocused(event, { data: item, index: rowIndex }); } } }; this.onKeyDown = (event) => { this.onDispatch(event); if (!event.defaultPrevented) { const nodeName = event.target.nodeName; if (nodeName === "INPUT" || nodeName === "TEXTAREA") { // Don't handle keyboard events when target is an input return; } const { focusIndex } = this; const item = ObservableLike.getValue(this.state.rows[focusIndex]); if (item) { if (event.which === KeyCode.enter) { if (focusIndex >= 0 && !eventTargetContainsNode(event, ["A"])) { this.rowActivated(event, { data: item, index: focusIndex }); } } else if (event.which === KeyCode.space) { this.processSelectionEvent(event, { data: item, index: focusIndex }); event.preventDefault(); } else if (event.which === KeyCode.upArrow || event.which === KeyCode.downArrow) { const { selection } = this.props; if (!selection || (selection.selectOnFocus && (event.shiftKey || !event.ctrlKey))) { event.persist(); // Need to wait for the keyboard event to be processed by the focuszone. window.setTimeout(() => { if (this.focusIndex != focusIndex) { const data = ObservableLike.getValue(this.state.rows[this.focusIndex]); if (data) { this.processSelectionEvent(event, { data, index: this.focusIndex }); } } }, 0); } } else if (event.which === KeyCode.pageDown) { const stats = this.getStats(); this.focusRow(Math.min(focusIndex + (stats.lastRendered - stats.firstRendered), this.state.rowCount - 1), 1); event.preventDefault(); } else if (event.which === KeyCode.pageUp) { const stats = this.getStats(); this.focusRow(Math.max(focusIndex - (stats.lastRendered - stats.firstRendered), 0), -1); event.preventDefault(); } else if (event.which === KeyCode.home) { this.focusRow(0, 1); event.preventDefault(); } else if (event.which === KeyCode.end) { this.focusRow(this.state.rowCount - 1, -1); event.preventDefault(); } } } }; this.onIntersect = (entries) => { // If virtualization is disabled, we will not attempt to adjust the viewport. if (!this.state.virtualize) { return; } if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { this.processIntersectOptimized(entries); } else { this.processIntersect(entries); } }; // Original master virtualization logic: per-row getBoundingClientRect() based culling // with rowProportion-based spacer scaling for large lists. this.processIntersect = (entries) => { const { scrollTop } = this.context.root; const { firstRendered, firstMaterialized, lastRendered, lastMaterialized, rowCount, rowProportion } = this.state; let { rowHeight } = this.state; // Don't process an intersection while scroll event is pending. if (scrollTop !== this.state.scrollTop && entries.length) { return; } // Ignore events if we dont have a our basic elements resolved (this should never happen). if (!this.listElement.current || !this.bodyElement.current) { return; } // We are going to enumerate all the children, if the row is in the viewport // we will determine if it should be paged out. const rowElements = this.bodyElement.current.children; // If a rowHeight was specified we will compute one based on the average rowHeight in the // first page rendered. if (rowHeight === 0) { if (rowElements.length > 0) { let totalHeight = 0; let childCount = 0; // Loop through all children and average the rowHeight's. for (let childIndex = 0; childIndex < rowElements.length; childIndex++) { const child = this.bodyElement.current.children[childIndex]; const childHeight = child.getBoundingClientRect().height; const isSpacer = child.classList.contains("bolt-list-row-spacer"); if (childHeight > 0 && !isSpacer) { totalHeight += childHeight; childCount++; } } // Make sure we have at least one child row that has size. if (childCount > 0) { rowHeight = totalHeight / childCount; } } if (rowHeight === 0) { return; } // If we have a pending scrollIntoView we will schedule it now that we have the rowHeight if (this.scrollToIndex !== -1) { this.setState({ firstMaterialized: Math.max(0, this.scrollToIndex - this.state.pageSize), lastMaterialized: this.scrollToIndex + Math.min(this.props.initialPageCount * this.state.pageSize, rowCount - 1), rowHeight }); return; } } // Determine the location of the intersection within the page. This is the element // we are scrolling within. const intersectionRect = this.context.root.getBoundingClientRect(); // Track the first and last row elements for adjusting the range. let firstMaterializedElement; let lastMaterializedElement; let firstMaterializedUpdated = firstMaterialized; let lastMaterializedUpdated = lastMaterialized; let firstRenderedUpdated = lastMaterializedUpdated; let lastRenderedUpdated = firstMaterializedUpdated; // Go through the viewport pages and determine if any are out of range and should be // paged out. Range is defined as more than 1 page of estimated rows away from the // nearest edge. If you dont allow for 1 page of estimated rows it may thrash pages // in and out of materialization. for (let childIndex = 0; childIndex < rowElements.length; childIndex++) { // Determine if this child is in the viewport, ignore rows that are not. const rowElement = rowElements[childIndex]; const rowIndex = getAttributeAsNumber(rowElement, "data-row-index"); const rowRect = rowElement.getBoundingClientRect(); if (rowIndex >= firstMaterialized && rowIndex <= lastMaterialized) { // Make sure to leave some extra room above and below the visible rectangle to handle // variable height rows. This helps prevent jittering when paging rows out. if (rowRect.bottom < intersectionRect.top - this.state.pageSize * (rowProportion * rowHeight)) { firstMaterializedUpdated++; } else if (rowRect.top > intersectionRect.bottom + this.state.pageSize * (rowProportion * rowHeight)) { lastMaterializedUpdated--; } // We will save the first and last rows for later computations. if (rowIndex === firstMaterialized) { firstMaterializedElement = rowElement; } if (rowIndex === lastMaterialized) { lastMaterializedElement = rowElement; } } // If the row is within the intersection rect, update the first and last rendered rows. These might be the focused items if (rowIndex > -1 && rowRect.top < intersectionRect.bottom && rowRect.bottom > intersectionRect.top) { lastRenderedUpdated = Math.max(lastRenderedUpdated, rowIndex); firstRenderedUpdated = Math.min(firstRenderedUpdated, rowIndex); } } // When we are scaling the size of the list, we want to keep a pageSize worth of elements materiaized but not rendered. // This allows users to scroll a few items. If they quickly scroll past the last materialized element or drag the scroll wheel, we recalculate where we should be // instead of paging in rows. if (rowProportion < 1) { if (firstMaterializedUpdated > lastMaterializedUpdated || firstRenderedUpdated === firstMaterializedUpdated || lastRenderedUpdated === lastMaterializedUpdated) { if (lastRenderedUpdated >= rowCount - 1) { firstMaterializedUpdated = Math.ceil(lastMaterializedUpdated - (intersectionRect.height / rowHeight + this.state.pageSize)); } else { const offsetTop = scrollTop - (this.listElement.current.offsetTop - this.context.root.offsetTop); firstMaterializedUpdated = Math.max(0, Math.min(rowCount - 1, Math.floor(offsetTop / (rowProportion * rowHeight))) - this.state.pageSize); lastMaterializedUpdated = Math.min(rowCount - 1, firstMaterializedUpdated + Math.ceil(intersectionRect.height / (rowProportion * rowHeight) + this.state.pageSize - 1)); lastRenderedUpdated = -1; firstRenderedUpdated = -1; } } else { firstMaterializedUpdated = Math.min(firstMaterializedUpdated, firstRenderedUpdated - this.state.pageSize); //-1 helps to avoid jittering when paging rows out lastMaterializedUpdated = Math.max(lastMaterializedUpdated, lastRenderedUpdated + this.state.pageSize - 1); lastRenderedUpdated = -1; firstRenderedUpdated = -1; } } // If the row range is inverted (top above bottom) then all rows have been hidden and we should // recompute the viewport based on the scrollTop of our intersection and intersection height. else if (firstMaterializedUpdated > lastMaterializedUpdated) { const offsetTop = scrollTop - (this.listElement.current.offsetTop - this.context.root.offsetTop); const index = this.props.rowHeights ? this.getFirstMaterializedItemBaseOnRowHeights(this.props.rowHeights, rowHeight, offsetTop) : undefined; if (index) { firstMaterializedUpdated = Math.max(0, Math.min(rowCount - 1, index - this.state.pageSize)); } else { firstMaterializedUpdated = Math.max(0, Math.min(rowCount - 1, Math.floor(offsetTop / rowHeight)) - this.state.pageSize); } lastMaterializedUpdated = Math.min(rowCount - 1, firstMaterializedUpdated + Math.ceil(intersectionRect.height / rowHeight + this.state.pageSize - 1)); lastRenderedUpdated = -1; firstRenderedUpdated = -1; } else { // If the firstPage didn't move down, we may need more pages above. if (firstMaterializedUpdated === firstMaterialized && firstMaterializedElement) { const rowRect = firstMaterializedElement.getBoundingClientRect(); const availableSpace = rowRect.top - intersectionRect.top; if (availableSpace > 0) { firstMaterializedUpdated -= Math.ceil(availableSpace / rowHeight); } } // If the lastPage didn't move up, we may need more pages below. if (lastMaterializedUpdated === lastMaterialized && lastMaterializedElement) { const rowRect = lastMaterializedElement.getBoundingClientRect(); const availableSpace = intersectionRect.bottom - rowRect.bottom; if (availableSpace > 0) { lastMaterializedUpdated += Math.ceil(availableSpace / rowHeight); } } } // Make sure our page boundary stays in the available page range. firstMaterializedUpdated = Math.max(firstMaterializedUpdated, 0); lastMaterializedUpdated = Math.min(lastMaterializedUpdated, rowCount - 1); // Update our state if and only if something has changed. if (firstMaterializedUpdated !== firstMaterialized || firstRenderedUpdated !== firstRendered || lastMaterializedUpdated !== lastMaterialized || lastRenderedUpdated !== lastRendered || rowHeight !== this.state.rowHeight || scrollTop !== this.state.scrollTop) { this.setState({ firstMaterialized: firstMaterializedUpdated, firstRendered: firstRenderedUpdated, lastMaterialized: lastMaterializedUpdated, lastRendered: lastRenderedUpdated, rowHeight, scrollTop }); } }; // Optimized scroll-position-based virtualization. Instead of querying per-row // getBoundingClientRect() on every scroll event, we compute the materialized // range from scrollTop and estimated rowHeight. The browser's // content-visibility: auto on .bolt-list-row handles skipping layout/paint for // off-screen rows, so we only need DOM virtualization to bound node count. this.processIntersectOptimized = (_entries) => { // Ignore events if we dont have our basic elements resolved. if (!this.context.root || !this.listElement.current || !this.bodyElement.current) { return; } const { scrollTop } = this.context.root; const { firstRendered, firstMaterialized, lastRendered, lastMaterialized, rowCount } = this.state; let { rowHeight } = this.state; const rowElements = this.bodyElement.current.children; // Compute rowHeight from rendered rows if not yet known. if (rowHeight === 0) { if (rowElements.length > 0) { let totalHeight = 0; let childCount = 0; for (let childIndex = 0; childIndex < rowElements.length; childIndex++) { const child = rowElements[childIndex]; const childHeight = child.getBoundingClientRect().height; const isSpacer = child.classList.contains("bolt-list-row-spacer"); if (childHeight > 0 && !isSpacer) { totalHeight += childHeight; childCount++; } } if (childCount > 0) { rowHeight = totalHeight / childCount; } } if (rowHeight === 0) { return; } // If we have a pending scrollIntoView, schedule it now that we have the rowHeight. if (this.scrollToIndex !== -1) { this.setState({ firstMaterialized: Math.max(0, this.scrollToIndex - this.state.pageSize), lastMaterialized: this.scrollToIndex + Math.min(this.props.initialPageCount * this.state.pageSize, rowCount - 1), rowHeight }); return; } } // Compute the materialized range from scroll position. // Use getBoundingClientRect() so that intermediate positioned ancestors // (e.g. position:relative widget cards in Dashboards) don't skew the offset. const rootRect = this.context.root.getBoundingClientRect(); const listRect = this.listElement.current.getBoundingClientRect(); const offsetTop = rootRect.top - listRect.top; const viewportHeight = this.context.root.clientHeight; const bufferRows = this.state.pageSize; let firstMaterializedUpdated; let lastMaterializedUpdated; if (this.props.rowHeights) { const index = this.getFirstMaterializedItemBaseOnRowHeights(this.props.rowHeights, rowHeight, offsetTop); firstMaterializedUpdated = Math.max(0, Math.min(index - bufferRows, rowCount - 1)); } else { firstMaterializedUpdated = Math.max(0, Math.min(Math.floor(offsetTop / rowHeight) - bufferRows, rowCount - 1)); } const visibleRows = Math.ceil(viewportHeight / rowHeight); lastMaterializedUpdated = Math.min(rowCount - 1, firstMaterializedUpdated + visibleRows + 2 * bufferRows); // Determine first/last rendered (actually in viewport) for stats. let firstRenderedUpdated = firstMaterializedUpdated + bufferRows; let lastRenderedUpdated = lastMaterializedUpdated - bufferRows; firstRenderedUpdated = Math.max(0, Math.min(firstRenderedUpdated, rowCount - 1)); lastRenderedUpdated = Math.max(firstRenderedUpdated, Math.min(lastRenderedUpdated, rowCount - 1)); // Clamp to valid range. firstMaterializedUpdated = Math.max(firstMaterializedUpdated, 0); lastMaterializedUpdated = Math.min(lastMaterializedUpdated, rowCount - 1); // Update state only if something has changed. if (firstMaterializedUpdated !== firstMaterialized || firstRenderedUpdated !== firstRendered || lastMaterializedUpdated !== lastMaterialized || lastRenderedUpdated !== lastRendered || rowHeight !== this.state.rowHeight || scrollTop !== this.state.scrollTop) { this.setState({ firstMaterialized: firstMaterializedUpdated, firstRendered: firstRenderedUpdated, lastMaterialized: lastMaterializedUpdated, lastRendered: lastRenderedUpdated, rowHeight, scrollTop }); } }; this.onPointerDownBody = (event) => { // If the table body gets a mousedown, we will never need to fire the selection event when // the list gets focus since the mouse event will cause the selection. this.selectOnFocus = false; }; this.getInitialTabbableRow = () => { const { defaultTabbableRow, itemProvider, selection } = this.props; if (defaultTabbableRow) { return defaultTabbableRow; } if (selection) { for (let i = 0; i < itemProvider.length; i++) { if (selection.selectable(i)) { return i; } } } return 0; }; this.getHeight = (rowIndex, countFromBottom) => { let height = 0; const rowHeights = this.props.rowHeights || []; const start = countFromBottom ? this.state.rowCount - rowIndex : 0; const end = countFromBottom ? this.state.rowCount : rowIndex; for (let i = start; i < end; i++) { height += rowHeights[i] || this.state.rowHeight; } return height; }; if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { this.contentVisibilityListenerRows = new WeakSet(); this.contentVisibilityAbortController = new AbortController(); this.skippedRows = new Set(); } const rowCount = props.itemProvider.length; const pageSize = props.pageSize; this.state = { columnCount: 1, eventDispatch: props.eventDispatch || new EventDispatch(), firstMaterialized: 0, firstRendered: 0, itemProvider: props.itemProvider, lastMaterialized: this.props.virtualize ? Math.min(props.initialPageCount * pageSize, rowCount - 1) : rowCount - 1, lastRendered: this.props.virtualize ? Math.min(props.initialPageCount * pageSize, rowCount - 1) : rowCount - 1, overlays: new ObservableArray(), pageSize, renderedRows: {}, rowCount, rowHeight: props.rowHeight || 0, rowProportion: props.rowHeight && props.maxHeight ? Math.min(1, props.maxHeight / (props.rowHeight * rowCount)) : 1, rows: {}, scrollTop: 0, virtualize: !!props.virtualize }; // Initialize the supplied behaviors. if (props.behaviors) { for (const behavior of props.behaviors) { if (behavior.initialize) { behavior.initialize(props, this, this.state.eventDispatch); } } } } static getDerivedStateFromProps(props, state) { const rowCount = props.itemProvider.length; let firstMaterialized = state.firstMaterialized; let lastMaterialized = state.lastMaterialized; if (rowCount !== state.rowCount) { firstMaterialized = Math.max(0, Math.min(state.firstMaterialized, rowCount)); lastMaterialized = state.virtualize ? Math.max(firstMaterialized, Math.min(state.lastMaterialized + (state.lastMaterialized === state.rowCount - 1 || state.lastMaterialized === state.rowCount ? props.pageSize : 0), rowCount - 1)) : rowCount - 1; } // Ensure pages and providers are appropriately computed. const updatedState = { firstMaterialized, itemProvider: props.itemProvider, lastMaterialized, pageSize: props.pageSize, rowCount, rowProportion: Math.min(1, (props.maxHeight || 100000) / (state.rowHeight * (rowCount - (lastMaterialized - firstMaterialized)))) }; // If there are changes to the props that affect the cached data, we need to clear it. if (props.itemProvider !== state.itemProvider || props.columnCount !== state.columnCount) { updatedState.columnCount = props.columnCount; updatedState.renderedRows = {}; updatedState.rows = {}; } return updatedState; } render() { const { ariaRowOffset, className, focuszoneProps, id, maxWidth, minWidth, width } = this.props; const { firstMaterialized, lastMaterialized, rowCount, rowProportion } = this.state; const { focusIndex } = this; const role = this.props.role ? this.props.role : this.props.selection ? "listbox" : "list"; const useAriaCounts = role === "table" || role === "grid" || role === "treegrid"; const rows = []; const { bottomSpacer1, bottomSpacer2, firstFocusRow, lastFocusRow, topSpacer1, topSpacer2 } = this.computeSpacerLayout(firstMaterialized, lastMaterialized, rowCount, this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS ? 1 : rowProportion, focusIndex, this.state.pageSize); rows.push(this.renderSpacer("st1", topSpacer1)); // If the focus pages are before the viewport render them up to // the first page but not including the first page. if (firstFocusRow < firstMaterialized) { for (let rowIndex = firstFocusRow; rowIndex <= lastFocusRow; rowIndex++) { rows.push(this.renderRow(rowIndex)); } } rows.push(this.renderSpacer("st2", topSpacer2)); // Go through each of the rendered pages and generate the child component. for (let rowIndex = firstMaterialized; rowIndex <= lastMaterialized; rowIndex++) { rows.push(this.renderRow(rowIndex)); } rows.push(this.renderSpacer("sb2", bottomSpacer2, { countFromBottom: true, estimateRowHeight: !this.props.rowHeights })); // If the focus pages are after the last page in the viewport render // them but not including the last page. if (lastFocusRow > lastMaterialized) { for (let rowIndex = firstFocusRow; rowIndex <= lastFocusRow; rowIndex++) { rows.push(this.renderRow(rowIndex)); } } rows.push(this.renderSpacer("sb1", bottomSpacer1, { countFromBottom: true, estimateRowHeight: !this.props.rowHeights })); return (React.createElement(UncheckedObserver, { itemProvider: { // Supply an IObservableExpression to elevate the provider change to a state // update for the entire component instead of just the observer. filter: (change, action) => { var _a; // Notify the selection about the change to the items. if (this.props.selection) { this.props.selection.onItemsChanged(change, action); } // @NOTE: For now we will just wipe out the entire cache, we can do an optimized // update to the cache based on the rows that changed. const updatedState = { renderedRows: {}, rows: {} }; // Row indices in skippedRows are no longer valid after a provider change. (_a = this.skippedRows) === null || _a === void 0 ? void 0 : _a.clear(); // If the focused row was removed, we will clear the focus index. if (change.removedItems && this.focusIndex >= change.index && change.index + change.removedItems.length >= this.focusIndex) { this.focusIndex = -1; } // If there is a well defined rowcount we will update it and the maxPage. if (this.state.rowCount !== -1) { const countChange = (change.addedItems ? change.addedItems.length : 0) - (change.removedItems ? change.removedItems.length : 0); if (countChange) { updatedState.rowCount = this.state.rowCount + countChange; updatedState.firstMaterialized = Math.max(0, Math.min(this.state.firstMaterialized, updatedState.rowCount - 1)); updatedState.lastMaterialized = this.state.virtualize ? Math.max(updatedState.firstMaterialized, Math.min(this.state.lastMaterialized + (change.index >= this.state.firstMaterialized && change.index <= this.state.lastMaterialized + 1 ? Math.min(this.state.pageSize, countChange) : 0), updatedState.rowCount - 1)) : updatedState.rowCount - 1; } } // console.log(updatedState); this.setState(updatedState); return false; }, observableValue: this.props.itemProvider } }, React.createElement(FocusWithin, { onBlur: this.onBlur }, (focusStatus) => { // @TODO: Once we get the line-height: 20px in the body the body-m should be removed from the list. let list = (React.createElement("table", { "aria-colcount": useAriaCounts ? (this.props.ariaColumnCount ? this.props.ariaColumnCount : this.props.columnCount) : undefined, "aria-label": this.props.ariaLabel, "aria-rowcount": useAriaCounts ? this.state.itemProvider.length + ariaRowOffset : undefined, className: css(className, "bolt-list body-m relative", this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS && "bolt-list-cv-optimized", this.props.showScroll ? undefined : "scroll-hidden"), id: getSafeId(id), onBlur: focusStatus.onBlur, onClick: this.onClick, onContextMenu: this.onDispatch, onDoubleClick: this.onDoubleClick, onDragEnd: this.onDispatch, onDragEnter: this.onDispatch, onDragExit: this.onDispatch, onDragOver: this.onDispatch, onDragStart: this.onDispatch, onDrop: this.onDispatch, onFocus: focusStatus.onFocus, onKeyDown: this.onKeyDown, onKeyUp: this.onDispatch, onPointerDown: this.onDispatch, ref: this.listElement, role: role, style: { maxWidth, minWidth, width }, tabIndex: this.props.excludeTabStop ? -1 : 0 }, this.props.renderHeader && this.props.renderHeader(), React.createElement("tbody", { className: "relative", onFocus: this.onFocusBody, onPointerDown: this.onPointerDownBody, ref: this.bodyElement, role: role === "listbox" || role === "list" || role === "menu" || role === "tree" ? "presentation" : undefined }, this.renderOverlay(this.listElement), rows))); if (focuszoneProps) { list = (React.createElement(FocusZone, Object.assign({}, focuszoneProps, { skipHiddenCheck: true }), list)); } return list; }))); } componentDidMount() { this.context.register(this.onIntersect); if (this.props.virtualize) { document.addEventListener("keydown", this.onVirtualizeKeyDown); } if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { this.attachContentVisibilityListeners(); } } componentDidUpdate() { const { scrollToIndex, onScrollComplete } = this; if (scrollToIndex !== -1 && this.state.rowHeight) { const parentElement = this.bodyElement.current; const { firstMaterialized, lastMaterialized } = this.state; // If the row is materialized, we will ensure it is in the viewport. if (scrollToIndex >= firstMaterialized && scrollToIndex <= lastMaterialized && parentElement) { for (let currentIndex = 0; currentIndex < parentElement.children.length; currentIndex++) { const childElement = parentElement.children[currentIndex]; const cellDetails = cellFromElement(childElement); if (cellDetails.rowIndex === scrollToIndex) { childElement.scrollIntoView(this.scrollToOptions); break; } } } // Reset the scroll state before we notify the complete function, it may start a new scroll operation. this.onScrollComplete = undefined; this.scrollToIndex = -1; this.scrollToOptions = undefined; // Notify any pending scrollComplete method that scrolling has completed. if (onScrollComplete) { onScrollComplete(scrollToIndex); } } if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { const { firstMaterialized, lastMaterialized } = this.state; if (firstMaterialized !== this.prevFirstMaterialized || lastMaterialized !== this.prevLastMaterialized) { this.prevFirstMaterialized = firstMaterialized; this.prevLastMaterialized = lastMaterialized; this.attachContentVisibilityListeners(); } } } componentWillUnmount() { var _a, _b; this.context.unregister(this.onIntersect); if (this.props.virtualize) { document.removeEventListener("keydown", this.onVirtualizeKeyDown); } (_a = this.contentVisibilityAbortController) === null || _a === void 0 ? void 0 : _a.abort(); (_b = this.skippedRows) === null || _b === void 0 ? void 0 : _b.clear(); } addOverlay(id, rowIndex, render, zIndex = 0, columnIndex) { const { overlays } = this.state; const overlayIndex = overlays.value.findIndex((overlay) => overlay.id === id); const rowOverlay = { render, id, rowIndex, zIndex: zIndex + 1, columnIndex }; // Update the overlay if it exists for that id, otherwise add it if (overlayIndex >= 0) { overlays.change(overlayIndex, rowOverlay); } else { overlays.push(rowOverlay); } } removeOverlay(id) { const { overlays } = this.state; const overlayIndex = overlays.value.findIndex((overlay) => overlay.id === id); // Remove the overlay if it exists. if (overlayIndex >= 0) { overlays.splice(overlayIndex, 1); } } getFocusIndex() { return this.focusIndex; } getStats() { return { firstMaterialized: this.state.firstMaterialized, firstRendered: this.state.firstRendered, lastMaterialized: this.state.lastMaterialized, lastRendered: this.state.lastRendered }; } scrollIntoView(rowIndex, options, onScrollComplete) { const { firstMaterialized, lastMaterialized, pageSize, rowCount, rowHeight, rowProportion } = this.state; if (rowIndex >= 0 && rowIndex < this.state.rowCount) { const parentElement = this.bodyElement.current; // If the row is materialized, we will ensure it is in the viewport. if (rowIndex >= firstMaterialized && rowIndex <= lastMaterialized && parentElement) { for (let currentIndex = 0; currentIndex < parentElement.children.length; currentIndex++) { const childElement = parentElement.children[currentIndex]; const cellDetails = cellFromElement(childElement); if (cellDetails.rowIndex === rowIndex) { childElement.scrollIntoView(options); break; } } // If the caller wants to know when the scroll has completed, notify them. if (onScrollComplete) { onScrollComplete(rowIndex); } } else { // We only notify the last caller for now, if someone was waiting and another // scroll request was made we will send -1 as the rowIndex scrolled into view. if (this.onScrollComplete) { this.onScrollComplete(-1); } // Set the scrollToOptions that will be applied after the next update. this.onScrollComplete = onScrollComplete; this.scrollToIndex = rowIndex; this.scrollToOptions = options; // We need to add some padding when we grow proportionally, since the spacers do not fill up enough room if // the list starts in the middle of the scrollable region const padding = !this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS && rowProportion < 1 ? pageSize : 0; // If we havent computed the rowHeight at this point we need to wait until // we know how big rows are to get the row in the right location. if (rowHeight) { this.setState({ firstMaterialized: Math.max(0, rowIndex - padding), lastMaterialized: Math.min(rowCount - 1, rowIndex + padding) }); } } } } focusRow(rowIndex, direction = 1) { return new Promise(resolve => { this.scrollIntoView(rowIndex, { block: "center" }, (completedIndex) => { if (completedIndex === rowIndex && this.bodyElement.current) { const rowElement = this.bodyElement.current.querySelector("[data-row-index='" + completedIndex + "']"); if (rowElement) { // We need to ensure the requested row is focusable, if not we will move in the // requested direction to find the first focusable row. if (!rowElement.getAttribute("tabindex")) { const newIndex = Math.min(this.state.rowCount - 1, Math.max(0, completedIndex + direction)); if (newIndex !== completedIndex) { this.focusRow(newIndex, direction); } else if (newIndex !== this.focusIndex) { this.focusRow(newIndex, -direction); } } else { rowElement.focus(); } } } resolve(); }); }); } computeSpacerLayout(firstMaterialized, lastMaterialized, rowCount, rowProportion, focusIndex, pageSize) { if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS && (rowCount <= 0 || (firstMaterialized <= 0 && lastMaterialized >= rowCount - 1))) { return { topSpacer1: 0, topSpacer2: 0, bottomSpacer1: 0, bottomSpacer2: 0, firstFocusRow: Number.MAX_SAFE_INTEGER, lastFocusRow: 0 }; } let topSpacer1 = 0; let topSpacer2 = firstMaterialized; let bottomSpacer2 = Math.max(0, rowCount - lastMaterialized - 1); let bottomSpacer1 = 0; let firstFocusRow = Number.MAX_SAFE_INTEGER; let lastFocusRow = 0; if (focusIndex !== -1) { firstFocusRow = Math.max(0, focusIndex - 3); lastFocusRow = Math.min(rowCount, focusIndex + 3); if (firstFocusRow < firstMaterialized) { lastFocusRow = Math.min(lastFocusRow, firstMaterialized - 1); topSpacer1 = firstFocusRow; topSpacer2 = firstMaterialized - lastFocusRow - 1; } else if (lastFocusRow > lastMaterialized) { firstFocusRow = Math.max(firstFocusRow, lastMaterialized + 1); bottomSpacer2 = firstFocusRow - lastMaterialized - 1; bottomSpacer1 = Math.max(0, rowCount - lastFocusRow - 1); } } if (rowProportion < 1) { topSpacer2 += Math.min(pageSize, firstMaterialized); } return { topSpacer1, topSpacer2, bottomSpacer1, bottomSpacer2, firstFocusRow, lastFocusRow }; } processSelectionEvent(event, listRow) { const { selection, enforceSingleSelect } = this.props; if (!selection || selection.selectable(listRow.index)) { if (selection) { const { index } = listRow; const multiSelect = enforceSingleSelect ? false : selection.multiSelect; // Determine the type of change being made to the selection based on key states. if (this.pivotIndex >= 0 && event.shiftKey && multiSelect) { selection.select(Math.min(this.pivotIndex, index), Math.abs(this.pivotIndex - index) + 1, event.ctrlKey || event.metaKey, multiSelect); } else { const isSpaceBarStroke = event.which === KeyCode.space; if ((event.ctrlKey || event.metaKey || selection.alwaysMerge || isSpaceBarStroke) && multiSelect) { selection.toggle(index, true, multiSelect); } else { selection.select(index, 1, false, multiSelect); } } // Save the last selectionIndex that we selected, this will allow // us to perform range based selection. if (!event.shiftKey) { this.pivotIndex = index; } } this.rowSelected(event, listRow); } } renderLoadingRow(rowIndex, details) { return (React.createElement(ListItem, { className: "bolt-list-row-loading", details: details, index: rowIndex }, React.createElement("div", { className: "shimmer shimmer-line", style: { width: Math.random() * 80 + 20 + "%" } }, "\u00A0"))); } renderOverlay(listElementRef) { const { firstMaterialized, lastMaterialized, overlays } = this.state; return (React.createElement(Observer, { overlays: overlays }, (props) => { const bodyElement = this.bodyElement.current; if (props.overlays.length > 0 && bodyElement) { return (React.createElement("div", { className: "bolt-list-overlay-container absolute" }, props.overlays.map((overlay) => { var _a, _b; // Make sure the row is in the rendered range of rows before starting. // Explicitly include column headers at row -1 if (overlay.rowIndex !== -1 && (overlay.rowIndex < firstMaterialized || overlay.rowIndex > lastMaterialized) && !getDragInProgress()) { return null; } // If the browser has marked this row as content-visibility skipped, its // getBoundingClientRect() returns a zeroed rect — the reflow is both wrong // and wasted. Skip it; the overlay will re-render when the row comes back // into view and the skipped state clears. If the row has re-entered the // materialized range, let it through and defer clearing the stale flag