azure-devops-ui
Version:
React components for building web UI in Azure DevOps
875 lines (874 loc) • 71.4 kB
JavaScript
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