UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

884 lines 53.7 kB
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)