UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

680 lines (679 loc) 35.1 kB
import "../../CommonImports"; import "../../Core/core.css"; import "./DropdownList.css"; import "./List.css"; import "./ListDropIndicator.css"; import * as React from "react"; import { ObservableLike, ObservableValue } from '../../Core/Observable'; import { FocusWithin } from '../../FocusWithin'; import { FocusZone, FocusZoneContext, FocusZoneDirection } from '../../FocusZone'; import { IntersectionContext } from '../../Intersection'; import { Observer, UncheckedObserver } from '../../Observer'; import { css, eventTargetContainsNode, getSafeId, KeyCode } from '../../Util'; import { EventDispatch } from '../../Utilities/Dispatch'; /** * The FixedHeightList component is used to render a collection of items with a series of rows. */ export class FixedHeightList extends React.Component { constructor(props) { super(props); // Manage data about pages, including their spacers. this.intersectionElements = {}; // Track the table element used to render the rows. this.bodyElement = React.createRef(); this.listElement = React.createRef(); this.scrollToIndex = -1; this.scrollToOptions = undefined; // Focus/Selection management members. this.selectOnFocus = true; this.focusIndex = new ObservableValue(-1); this.pivotIndex = -1; this.onBlur = () => { this.focusIndex.value = -1; }; this.onClick = (event) => { this.onDispatch(event); if (!event.defaultPrevented) { if (this.listElement.current) { const { cellElement, rowIndex } = rowFromEvent(event); if (!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 = rowFromEvent(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.value; 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.value !== rowIndex) { this.focusRow(rowIndex, 2); // We need to re-render the previously focused row and newly focused row so we will // clear the cached values. if (focusIndex.value >= 0) { delete this.state.renderedRows[focusIndex.value]; } else if (this.props.defaultTabbableRow !== undefined) { // If there was a tabble row that was not the focusIndex.value row we need to update this // row as well to get it re-rendered without the tabIndex. delete this.state.renderedRows[this.props.defaultTabbableRow]; } delete this.state.renderedRows[rowIndex]; this.focusIndex.value = 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.value]); if (item) { if (event.which === KeyCode.enter) { if (focusIndex.value >= 0 && !eventTargetContainsNode(event, ["A"])) { this.rowActivated(event, { data: item, index: focusIndex.value }); } } else if (event.which === KeyCode.space) { this.processSelectionEvent(event, { data: item, index: focusIndex.value }); 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.value != focusIndex.value) { this.processSelectionEvent(event, { data: item, index: this.focusIndex.value }); } }, 0); } } else if (event.which === KeyCode.pageDown) { this.focusRow(Math.min(focusIndex.value + this.props.pageSize, this.state.rowCount - 1), 1); event.preventDefault(); } else if (event.which === KeyCode.pageUp) { this.focusRow(Math.max(focusIndex.value - this.props.pageSize, 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) => { const { scrollTop } = this.context.root; const { rowCount } = this.state; const { firstMaterialized, lastMaterialized } = this.state; const { rowHeight, rowProportion } = 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; } // Determine the location of the intersection within the page. This is the element // we are scrolling within. const intersectionRect = this.context.root.getBoundingClientRect(); const scrollTopRect = Math.max(0, scrollTop + this.context.root.offsetTop - this.listElement.current.offsetTop); // Track the first and last row elements for adjusting the range. let firstMaterializedUpdated = Math.max(0, Math.min(rowCount - 1, Math.floor(scrollTopRect / (rowHeight * rowProportion)))); let lastMaterializedUpdated = Math.min(rowCount - 1, firstMaterializedUpdated + Math.ceil(intersectionRect.height / rowHeight)); if (scrollTopRect + (lastMaterializedUpdated - firstMaterializedUpdated) * rowHeight > this.state.maxHeight) { lastMaterializedUpdated = rowCount - 1; firstMaterializedUpdated = Math.max(0, lastMaterializedUpdated - Math.ceil(intersectionRect.height / rowHeight)); } // Update our state if and only if something has changed. if (firstMaterializedUpdated !== firstMaterialized || lastMaterializedUpdated !== lastMaterialized || rowHeight !== this.state.rowHeight || scrollTop !== this.state.scrollTop || scrollTopRect !== this.state.scrollTopRect) { // // @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. // this.setState({ firstMaterialized: firstMaterializedUpdated, lastMaterialized: lastMaterializedUpdated, rowHeight, scrollTop, scrollTopRect }); } }; this.onMouseDownBody = (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; }; const rowCount = props.itemProvider.length; this.state = { eventDispatch: props.eventDispatch || new EventDispatch(), firstMaterialized: 0, itemProvider: props.itemProvider, lastMaterialized: 0, maxHeight: this.props.maxHeight || 1000000, focusRows: {}, renderedRows: {}, rowCount, rowHeight: props.rowHeight || 0, rowProportion: props.rowHeight && props.maxHeight ? Math.min(1, props.maxHeight / (props.rowHeight * rowCount)) : 1, rows: {}, scrollTop: 0, scrollTopRect: 0 }; } 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 = Math.max(firstMaterialized, Math.min(state.lastMaterialized + (state.lastMaterialized === state.rowCount - 1 ? props.pageSize : 0), rowCount - 1)); } // Ensure out pages and providers are appropriately computed. const updatedState = { firstMaterialized, itemProvider: props.itemProvider, lastMaterialized, rowCount, rowProportion: Math.min(1, state.maxHeight / (state.rowHeight * rowCount)) }; // If there are changes to the props that affect the cached data, we need it clear it. if (props.itemProvider !== state.itemProvider) { updatedState.renderedRows = {}; updatedState.rows = {}; } return updatedState; } getListRole() { return this.props.role ? this.props.role : this.props.selection ? "listbox" : "list"; } getItemRole() { switch (this.getListRole()) { case "tree": case "group": return "treeitem"; case "list": return "listitem"; case "listbox": return "option"; case "radiogroup": return "radio"; default: return null; } } render() { const { className, focuszoneProps, id, width } = this.props; const { firstMaterialized, lastMaterialized, maxHeight, rowCount, rowHeight } = this.state; const role = this.getListRole(); const rows = []; const firstFocusRow = Math.max(0, this.focusIndex.value - 3); const lastFocusRow = Math.min(rowCount, this.focusIndex.value + 3); rows.push(this.renderIntersectionBounds(true)); // Add focus rows around rendered rows. if (this.focusIndex.value !== -1 && firstFocusRow < firstMaterialized) { for (let rowIndex = firstFocusRow; rowIndex <= Math.min(lastFocusRow, firstMaterialized - 1); rowIndex++) { rows.push(this.renderRow(rowIndex, false)); } } for (let rowIndex = firstMaterialized; rowIndex <= lastMaterialized; rowIndex++) { rows.push(this.renderRow(rowIndex, true)); } if (this.focusIndex.value !== -1 && lastFocusRow > lastMaterialized && lastMaterialized > 0) { for (let rowIndex = Math.max(firstFocusRow, lastMaterialized + 1); rowIndex <= lastFocusRow; rowIndex++) { rows.push(this.renderRow(rowIndex, false)); } } rows.push(this.renderIntersectionBounds(false)); const height = Math.min(maxHeight, rowHeight * this.state.rowCount); let list = (React.createElement("div", { "aria-label": this.props.ariaLabel, className: css(className, "bolt-fixed-height-list relative"), id: getSafeId(id), onBlur: this.onBlur, onClick: this.onClick, onDoubleClick: this.onDoubleClick, onDragEnd: this.onDispatch, onDragEnter: this.onDispatch, onDragExit: this.onDispatch, onDragOver: this.onDispatch, onDragStart: this.onDispatch, onDrop: this.onDispatch, onKeyUp: this.onDispatch, onMouseDown: this.onDispatch, onTouchStart: this.onDispatch, ref: this.listElement, role: role, style: { width, height: height } }, React.createElement("div", { className: "relative", onFocus: this.onFocusBody, onKeyDown: this.onKeyDown, onMouseDown: this.onMouseDownBody, ref: this.bodyElement, style: { width, height: height } }, rows))); list = (React.createElement(FocusZone, Object.assign({ direction: FocusZoneDirection.Vertical, skipHiddenCheck: true }, focuszoneProps), list)); return (React.createElement(Observer, { 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: {}, focusRows: {}, rows: {} }; // If their 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 = Math.max(updatedState.firstMaterialized, Math.min(this.state.lastMaterialized + (change.index >= this.state.firstMaterialized && change.index <= this.state.lastMaterialized + 1 ? countChange : 0), updatedState.rowCount - 1)); } } this.setState(updatedState); return false; }, observableValue: this.props.itemProvider } }, () => list)); } componentDidMount() { this.onIntersect([]); this.context.register(this.onIntersect); } componentDidUpdate(prevProps, prevState) { const { scrollToIndex, onScrollComplete } = this; if (this.state.rowCount !== prevState.rowCount) { this.onIntersect([]); } 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 = rowFromElement(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); } getFocusIndex() { return this.focusIndex.value; } getStats() { return { firstMaterialized: this.state.firstMaterialized, lastMaterialized: this.state.lastMaterialized }; } scrollIntoView(rowIndex, options, onScrollComplete) { const { pageSize } = this.props; const { firstMaterialized, lastMaterialized, rowCount } = 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 = rowFromElement(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; // 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. this.setState({ firstMaterialized: Math.max(0, rowIndex - Math.floor((lastMaterialized - firstMaterialized) / 2)), lastMaterialized: Math.min(rowCount - 1, Math.ceil(rowIndex + (lastMaterialized - firstMaterialized) / 2)) }); } } } focusRow(rowIndex, direction) { this.scrollIntoView(rowIndex, { block: "nearest" }, (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.value) { this.focusRow(newIndex, -direction); } } else { // Set focus to the row that scroll to rowElement.focus(); } } } }); } processSelectionEvent(event, listRow) { const { selection } = this.props; if (!selection || selection.selectable(listRow.index)) { let initialState = false; let targetState = true; if (selection) { const { index } = listRow; // If a selection is available use it to track the initial state. initialState = selection.selected(index); // Determine the type of change being made to the selection based on key states. if (this.pivotIndex >= 0 && event.shiftKey && selection.multiSelect) { selection.select(Math.min(this.pivotIndex, index), Math.abs(this.pivotIndex - index) + 1, event.ctrlKey || event.metaKey); } else { if ((event.ctrlKey || event.metaKey || selection.alwaysMerge) && selection.multiSelect) { selection.toggle(index, true); targetState = false; } else { selection.select(index, 1, false); } } // Save the last selectionIndex that we selected, this will allow // us to perform range based selection. if (!event.shiftKey) { this.pivotIndex = index; } } if (initialState !== targetState) { this.rowSelected(event, listRow); } } } renderLoadingRow(rowIndex, details) { return (React.createElement("div", { className: "bolt-list-row-loading" }, React.createElement("div", { className: "shimmer shimmer-line", style: { width: Math.random() * 80 + 20 + "%" } }, "\u00A0"))); } renderIntersectionBounds(top) { const { firstMaterialized, lastMaterialized, rowHeight, rowProportion } = this.state; const key = top ? "topobserv" : "bottomobserv"; let rowTop = 0; // If we run out of room move from the bottom up. This can happen with proportionally allocated rows if (firstMaterialized * rowHeight * rowProportion + (lastMaterialized - firstMaterialized) * rowHeight > this.state.maxHeight) { if (top) { rowTop = this.state.maxHeight; rowTop -= (lastMaterialized - firstMaterialized) * rowHeight * rowProportion + rowHeight; rowTop--; } else { rowTop = this.state.maxHeight - 1; } } else { if (top) { rowTop = firstMaterialized * rowHeight * rowProportion - 1; } else { rowTop = firstMaterialized * rowHeight * rowProportion + (1 + lastMaterialized - firstMaterialized) * rowHeight + 1; } } return (React.createElement("div", { className: "bolt-list-row-spacer invisible absolute", key: key, ref: spacerElement => { const existingElement = this.intersectionElements[key]; if (spacerElement) { if (existingElement !== spacerElement) { if (existingElement) { this.context.unobserve(spacerElement); } this.context.observe(spacerElement); this.intersectionElements[key] = spacerElement; } } else if (existingElement) { this.context.unobserve(existingElement); delete this.intersectionElements[key]; } }, role: "presentation", style: { top: `${rowTop}px`, height: "1px" } })); } renderRow(rowIndex, isVisible) { const { itemProvider } = this.props; const { focusRows, renderedRows, firstMaterialized, lastMaterialized, rowHeight, rowProportion, rows } = this.state; const role = this.getItemRole(); let renderedRow = isVisible ? renderedRows[rowIndex] : focusRows[rowIndex]; // We can't use the cache for proportioned rows since the top is different based on what the firstMaterialized value is if (!renderedRow || rowProportion !== 1) { 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; } }; } const onFocus = (event) => { this.onFocusItem(rowIndex, event); }; // 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, focusIndex: this.focusIndex }, (props) => { var _a, _b; const { renderRow, renderLoadingRow } = this.props; const { rowHeight, rowCount } = this.state; const rowItem = ObservableLike.getValue(item); const itemDetails = { ariaBusy: !props.item, ariaRowOffset: 1, data: rowItem, eventDispatch: this.state.eventDispatch, itemProvider: this.props.itemProvider, listProps: this.props, onFocusItem: this.onFocusItem, singleClickActivation: this.props.onActivate && this.props.singleClickActivation }; let renderedRow; if (props.item) { renderedRow = renderRow(rowIndex, props.item, itemDetails); } else if (renderLoadingRow) { renderedRow = renderLoadingRow(rowIndex, itemDetails); } else { renderedRow = this.renderLoadingRow(rowIndex, itemDetails); } let rowTop = 0; let rowHeightSpace = 0; if (rowIndex >= firstMaterialized && rowIndex <= lastMaterialized) { rowHeightSpace = rowHeight; } // If we run out of room move from the bottom up. This can happen with proportionally allocated rows if (firstMaterialized * rowHeight * rowProportion + (lastMaterialized - firstMaterialized) * rowHeight > this.state.maxHeight) { rowTop = this.state.maxHeight; rowTop -= (rowCount - lastMaterialized) * rowHeight * rowProportion; rowTop -= (lastMaterialized - rowIndex) * rowHeight; } else { if (rowHeightSpace === 0) { rowTop = rowIndex * rowHeight * rowProportion; } else { rowTop = firstMaterialized * rowHeight * rowProportion; rowTop += (rowIndex - firstMaterialized) * rowHeight; } } const rowData = itemDetails === null || itemDetails === void 0 ? void 0 : itemDetails.data; const hasChildItems = (_a = rowData === null || rowData === void 0 ? void 0 : rowData.underlyingItem) === null || _a === void 0 ? void 0 : _a.childItems; const isExpanded = (_b = rowData === null || rowData === void 0 ? void 0 : rowData.underlyingItem) === null || _b === void 0 ? void 0 : _b.expanded; return (React.createElement(FocusWithin, { onFocus: onFocus }, (focusStatus) => { return (React.createElement(FocusZoneContext.Consumer, null, rowContext => { return (React.createElement(FocusZone, { direction: FocusZoneDirection.Horizontal }, React.createElement("div", { className: css("bolt-fixed-height-list-row scroll-hidden absolute", this.focusIndex.value === rowIndex && "focused"), style: { height: `${rowHeightSpace}px`, top: `${rowTop}px` }, "data-focuszone": rowContext.focuszoneId, "data-row-index": rowIndex, tabIndex: rowIndex == 0 || hasChildItems ? 0 : -1, onBlur: focusStatus.onBlur, onFocus: focusStatus.onFocus, role: role, "aria-expanded": hasChildItems && isExpanded === undefined ? false : isExpanded, "aria-labelledby": `rowContent-${rowIndex}` }, renderedRow))); })); })); })); // Save the row in our cache. if (isVisible) { this.state.renderedRows[rowIndex] = renderedRow; } else { this.state.focusRows[rowIndex] = renderedRow; } } return renderedRow; } 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); } } } FixedHeightList.contextType = IntersectionContext; FixedHeightList.defaultProps = { defaultTabbableRow: 0, focuszoneProps: { direction: FocusZoneDirection.Vertical }, maxHeight: 1000000 }; function getAttributeAsNumber(element, attributeName) { const attributeValue = element.getAttribute(attributeName); if (attributeValue) { return parseInt(attributeValue, 10); } return -1; } export function rowFromElement(element) { let attributeValue; const cellIndex = -1; let rowIndex = -1; const cellElement = null; while (element) { attributeValue = getAttributeAsNumber(element, "data-row-index"); if (attributeValue !== -1) { rowIndex = attributeValue; break; } // We have hit the root of the details list, dont look above this. if (element.classList.contains("bolt-fixed-height-list")) { element = null; break; } element = element.parentElement; } return { cellElement, cellIndex, rowElement: element, rowIndex }; } export function rowFromEvent(event) { return rowFromElement(event.target); }