azure-devops-ui
Version:
React components for building web UI in Azure DevOps
884 lines • 53.7 kB
JavaScript
import "../../CommonImports";
import "../../Core/core.css";
import "./DropdownList.css";
import "./List.css";
import "./ListDropIndicator.css";
import * as React from "react";
import * as Utils_Accessibility from '../../Core/Util/Accessibility';
import { ObservableArray, ObservableLike } from '../../Core/Observable';
import { FocusWithin } from '../../FocusWithin';
import { FocusZone, FocusZoneContext, FocusZoneDirection } from '../../FocusZone';
import { IntersectionContext } from '../../Intersection';
import { getDefaultLinkProps } from '../../Link';
import { Observer, UncheckedObserver } from '../../Observer';
import * as Resources from '../../Resources.Widgets';
import { css, eventTargetContainsNode, getSafeId, KeyCode } from '../../Util';
import { EventDispatch } from '../../Utilities/Dispatch';
import { getDragInProgress } from '../../Utilities/DragDrop';
import { getTabIndex } from '../../Utilities/Focus';
/**
* The DropdownList component is used to render a collection of items with a series of rows.
*/
export class DropdownList 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.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;
}
const { scrollTop } = this.context.root;
const { firstRendered, firstMaterialized, lastRendered, lastMaterialized, rowCount, rowProportion } = this.state;
let { rowHeight } = this.state;
// console.log({ phase: "onIntersect - Start", firstMaterialized, lastMaterialized, rowHeight });
// Don't process an intersection while scroll event is pending.
if (scrollTop !== this.state.scrollTop && entries.length) {
// console.log("Don't process an intersection while scroll event is pending.");
return;
}
// Ignore events if we dont have a our basic elements resolved (this should never happen).
if (!this.listElement.current || !this.bodyElement.current) {
// console.log("Elements not available at this point.");
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 childHeight = this.bodyElement.current.children[childIndex].getBoundingClientRect().height;
if (childHeight > 0) {
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;
}
// console.log({ phase: "onIntersect - Compute RowHeight", rowHeight });
}
// 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);
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);
// console.log({ phase: "onIntersect - End", firstMaterializedUpdated, lastMaterializedUpdated, rowHeight });
// 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) {
//
// @TODO: We need to unload data for pages that are no longer rendererd.
// This means not in the viewport or within any other rendered range.
//
// console.log({ phase: "onIntersect - stateChange", firstMaterializedUpdated, firstRenderedUpdated, lastRenderedUpdated, lastMaterializedUpdated, 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;
};
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 out 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 it clear it.
if (props.itemProvider !== state.itemProvider || props.columnCount !== state.columnCount) {
updatedState.columnCount = props.columnCount;
updatedState.renderedRows = {};
updatedState.rows = {};
}
// console.log(updatedState);
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 = [];
// Number of pages each spacer takes up. There are potentially two spacers above
// or below the view port. They surround the focus range when the focus range is
// not within the viewport.
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;
// Compute the range of focus pages, these will be either before or after the pages
// in the viewport. We need to ensure we have one row before and one row after the
// focus row to support arrowing up and down.
if (focusIndex !== -1) {
firstFocusRow = Math.max(0, focusIndex - 3);
lastFocusRow = Math.min(rowCount, focusIndex + 3);
// Make sure we dont draw any of the pages that are in the viewport.
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) {
// Ensure that the spacers leave room for 1 pageSize above the viewport
topSpacer2 += Math.min(this.state.pageSize, firstMaterialized);
}
// console.log({ phase: "render", firstMaterialized, lastMaterialized, topSpacer1, topSpacer2, bottomSpacer2, bottomSpacer1 });
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) => {
// 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: {}
};
// 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("ul", { "aria-colcount": useAriaCounts ? (this.props.ariaColumnCount ? this.props.ariaColumnCount : this.props.columnCount) : undefined, "aria-label": this.props.ariaLabel || Resources.DropdownSelection, "aria-rowcount": useAriaCounts ? this.state.itemProvider.length + ariaRowOffset : undefined, className: css(className, "bolt-list body-m relative", 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: 0 },
this.props.renderHeader && this.props.renderHeader(),
React.createElement("div", { className: css("relative", "listbox"), 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);
}
}
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);
}
}
}
componentWillUnmount() {
this.context.unregister(this.onIntersect);
if (this.props.virtualize) {
document.removeEventListener("keydown", this.onVirtualizeKeyDown);
}
}
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 = 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();
});
});
}
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(DropdownListItem, { 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;
// 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;
}
// Find the row for the given rowIndex
const defaultRowElement = listElementRef.current &&
listElementRef.current.querySelector("[data-row-index='" + overlay.rowIndex + "']");
const rowElement = this.props.overlay
? defaultRowElement === null || defaultRowElement === void 0 ? void 0 : defaultRowElement.querySelector(this.props.overlay)
: defaultRowElement;
// Special case for column overlay
const columnElement = (_a = listElementRef.current) === null || _a === void 0 ? void 0 : _a.querySelector("[data-column-index='" + overlay.columnIndex + "']");
// We cant render the overlay if the row is paged out since we can't determine
// the location of the row.
if (rowElement) {
return !columnElement ? (React.createElement("div", { className: "bolt-list-overlay flex-row absolute", id: getSafeId(overlay.id), key: overlay.id, style: {
height: rowElement.offsetHeight,
top: rowElement.getBoundingClientRect().top - bodyElement.getBoundingClientRect().top,
zIndex: overlay.zIndex * 10
} }, overlay.render({ rowElement: rowElement }))) : (React.createElement("div", { className: "bolt-list-overlay flex-row absolute", id: getSafeId(overlay.id), key: overlay.id, style: {
height: rowElement.offsetHeight,
width: columnElement.offsetWidth,
top: rowElement.getBoundingClientRect().top - bodyElement.getBoundingClientRect().top,
left: columnElement.getBoundingClientRect().left - bodyElement.getBoundingClientRect().left,
zIndex: overlay.zIndex * 10
} }, overlay.render({ rowElement: columnElement })));
}
return null;
})));
}
return null;
}));
}
renderRow(rowIndex) {
const { itemProvider } = this.props;
const { renderedRows, rows } = this.state;
let renderedRow = renderedRows[rowIndex];
if (!renderedRow) {
let item = rows[rowIndex];
if (!item) {
if (itemProvider.getItem) {
item = itemProvider.getItem(rowIndex);
}
else {
item = itemProvider.value[rowIndex];
}
}
// @TODO: If there are no more rows, we need to handle an itemProvider with -1 length.
if (!item) {
return null;
}
// Save the current item in the item cache.
rows[rowIndex] = item;
const { selection } = this.props;
let selectionObservable;
if (selection) {
selectionObservable = {
observableValue: selection,
filter: (selectedRanges) => {
for (const selectionRange of selectedRanges) {
if (rowIndex >= selectionRange.beginIndex && rowIndex <= selectionRange.endIndex) {
return true;
}
}
return false;
}
};
}
// console.log("render row - " + rowIndex);
// Render the row, save it in the cache, and add it to the current page.
renderedRow = (React.createElement(UncheckedObserver, { item: item, key: rowIndex, selection: selectionObservable }, (props) => {
const { selectableText, renderRow, renderLoadingRow } = this.props;
const { focusIndex } = this;
const tabbableIndex = focusIndex >= 0 ? focusIndex : this.getInitialTabbableRow();
const rowItem = ObservableLike.getValue(item);
const itemDetails = {
selectableText,
ariaBusy: !props.item,
ariaRowOffset: this.props.ariaRowOffset + 1,
data: rowItem,
eventDispatch: this.state.eventDispatch,
excludeTabStop: this.props.excludeTabStop || tabbableIndex !== rowIndex,
listProps: this.props,
onFocusItem: this.onFocusItem,
singleClickActivation: this.props.onActivate && this.props.singleClickActivation
};
if (props.item) {
return renderRow(rowIndex, props.item, itemDetails);
}
else if (renderLoadingRow) {
return renderLoadingRow(rowIndex, itemDetails);
}
else {
return this.renderLoadingRow(rowIndex, itemDetails);
}
}));
// Save the row in our cache.
this.state.renderedRows[rowIndex] = renderedRow;
}
return renderedRow;
}
renderSpacer(key, rowCount, options) {
var _a;
const height = !(options === null || options === void 0 ? void 0 : options.estimateRowHeight) && ((_a = this.props.rowHeights) === null || _a === void 0 ? void 0 : _a.length)
? this.getHeight(rowCount, options === null || options === void 0 ? void 0 : options.countFromBottom)
: rowCount * this.state.rowHeight * this.state.rowProportion;
return (React.createElement("div", { className: "bolt-list-row-spacer invisible", key: key, ref: spacerElement => {
const existingElement = this.spacerElements[key];
if (spacerElement) {
if (existingElement !== spacerElement) {
if (existingElement) {
this.context.unobserve(spacerElement);
}
this.context.observe(spacerElement);
this.spacerElements[key] = spacerElement;
}
}
else if (existingElement) {
this.context.unobserve(existingElement);
delete this.spacerElements[key];
}
}, role: "presentation" },
React.createElement("div", { className: "bolt-list-cell-spacer invisible", style: { height: height + "px" } })));
}
rowActivated(event, listRow) {
this.state.eventDispatch.dispatchEvent(event, listRow, "activate");
if (this.props.onActivate) {
this.props.onActivate(event, listRow);
}
}
rowSelected(event, listRow) {
this.state.eventDispatch.dispatchEvent(event, listRow, "select");
if (this.props.onSelect) {
this.props.onSelect(event, listRow);
}
}
rowFocused(event, listRow) {
this.state.eventDispatch.dispatchEvent(event, listRow, "focus");
if (this.props.onFocus) {
this.props.onFocus(event, listRow);
}
}
}
DropdownList.contextType = IntersectionContext;
DropdownList.defaultProps = {
ariaRowOffset: 0,
columnCount: 1,
focuszoneProps: { direction: FocusZoneDirection.Vertical },
initialPageCount: 3,
maxHeight: 100000,
pageSize: 10,
singleClickActivation: false,
selectRowOnClick: true,
virtualize: true
};
export function DropdownListItem(props) {
const onFocus = function (event)