UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

358 lines (357 loc) 21.2 kB
import "../../CommonImports"; import "../../Core/core.css"; import "./ListBox.css"; import * as React from "react"; import { ObservableLike } from '../../Core/Observable'; import * as Utils_Accessibility from '../../Core/Util/Accessibility'; import { format } from '../../Core/Util/String'; import { Icon } from '../../Icon'; import { renderListCell } from '../../List'; import { ItemsObserver, Observer } from '../../Observer'; import * as Resources from '../../Resources.Dropdown'; import { Spinner, SpinnerSize } from '../../Spinner'; import { ColumnSelect, SimpleTableCell, Table, TableRow } from '../../Table'; import { Tree, TreeRow } from '../../TreeEx'; import { css } from '../../Util'; import { DropdownSelection } from '../../Utilities/DropdownSelection'; import { ArrayItemProvider, getItemsValue } from '../../Utilities/Provider'; import { TreeItemProvider } from '../../Utilities/TreeItemProvider'; import { ListBoxItemType } from "./ListBox.Props"; export const DefaultListBoxWidth = -100; export class ListBox extends React.Component { constructor(props) { super(props); this.tabbableIndex = -1; this.positions = []; this.count = 0; this.table = React.createRef(); this.tree = React.createRef(); this.getItemWidth = () => { const { width } = this.props; return this.multiSelect && width && width > 0 ? width - 40 /* TODO: Remove this, 40 is only correct with default font-size */ : width; }; this.loadingChanged = () => { if (ObservableLike.getValue(this.props.loading)) { Utils_Accessibility.announce(Resources.AnnounceLoadingItems); } else { Utils_Accessibility.announce(Resources.AnnounceFinishedLoadingItems); const itemCount = this.props.items.length; Utils_Accessibility.announce(format(itemCount > 0 ? format(Resources.AnnounceItemCount, itemCount) : Resources.NoFilterResults), true); } return true; }; this.searchingChanged = () => { if (ObservableLike.getValue(this.props.searching)) { Utils_Accessibility.announce(Resources.Searching); } else if (ObservableLike.getValue(this.props.searching) === false) { const resultCount = this.props.items.length; Utils_Accessibility.announce(resultCount > 0 ? format(Resources.AnnounceFilterResultCount, resultCount) : Resources.NoFilterResults, true); } return true; }; this.onItemsChanged = () => { const items = getListBoxItemsValue(this.wrappedItems || this.props.items); this.tabbableIndex = -1; this.positions = []; this.count = 0; for (const item of items) { const itemValue = ObservableLike.getValue(item); if (itemValue && !listBoxItemSelectable(itemValue)) { this.positions.push(-1); } else { if (this.tabbableIndex === -1 && this.selection.selectable(this.positions.length)) { this.tabbableIndex = this.positions.length; } this.positions.push(++this.count); } } return true; }; this.onActivate = (event, tableRow) => { if (this.props.onActivate) { this.props.onActivate(event, tableRow.data); } }; this.onSelect = (event, tableRow) => { if (this.props.onSelect) { this.props.onSelect(event, tableRow.data); } }; this.onTreeActivate = (event, treeRow) => { const items = this.getListBoxItems(); if (this.props.onActivate && items) { const treeItem = treeRow.data.underlyingItem; const item = items.find(item => item.id === treeItem.id); item && this.props.onActivate(event, item); } }; this.onTreeSelect = (event, treeRow) => { const items = this.getListBoxItems(); if (this.props.onSelect && items) { const treeItem = treeRow.data.underlyingItem; const item = items.find(item => item.id === treeItem.id); item && this.props.onSelect(event, item); } }; this.renderListBoxRow = (index, item, details) => { const { excludeFocusZone, excludeTabStop } = this.props; const items = getListBoxItemsValue(this.wrappedItems || this.props.items); const focusable = !excludeFocusZone && this.selection.selectable(index); const rowDetails = Object.assign(Object.assign({ tooltipProps: item.tooltipProps || { text: item.text, overflowOnly: true, overflowDetected: overflowDetected } }, details), { ariaLabel: item.ariaLabel, ariaDescribedBy: item.type !== ListBoxItemType.Divider && item.type !== ListBoxItemType.Header && item.groupId ? `__bolt-${item.groupId}` : undefined, ariaPosInSet: this.positions[index] >= 0 ? this.positions[index] : null, ariaSetSize: this.positions[index] >= 0 ? this.count : null, excludeTabStop: excludeTabStop || this.tabbableIndex !== index, excludeFocusZone: !focusable, id: item.id, singleClickActivation: false, role: item.type === ListBoxItemType.Header || item.type === ListBoxItemType.Divider ? "presentation" : "option" }); return (React.createElement(Observer, { key: item.id || index, item: item }, () => (React.createElement(TableRow, { key: item.id || index, index: index, details: rowDetails, className: css("bolt-list-box-row", item.type === ListBoxItemType.Header && "bolt-list-box-header-row", item.type === ListBoxItemType.Divider && "bolt-list-box-divider-row", item.type === ListBoxItemType.Loading && "bolt-list-box-loading-row", this.multiSelect && "bolt-list-box-multi-select-row", item.type !== ListBoxItemType.Header && item.type !== ListBoxItemType.Divider && "cursor-pointer", item.disabled && "bolt-list-box-item-disabled") }, this.columns.map((tableColumn, columnIndex) => { if (this.multiSelect && columnIndex === 0) { if (item.type === ListBoxItemType.Divider || item.type === ListBoxItemType.Loading) { return null; } else if (item.type === ListBoxItemType.Header) { return React.createElement("span", { className: "bolt-table-cell bolt-list-cell bolt-header-cell", "data-column-index": "0", role: "presentation" }); } } tableColumn = Object.assign(Object.assign({}, tableColumn), { className: "dropdown-list" }); return tableColumn.renderCell(index, columnIndex, tableColumn, item); }))))); }; this.renderListBoxTreeRow = (index, item, details) => { const { excludeFocusZone, excludeTabStop } = this.props; const focusable = !excludeFocusZone && this.selection.selectable(index); const data = item.underlyingItem.data; const rowDetails = Object.assign(Object.assign({ tooltipProps: { text: item.underlyingItem.text, overflowOnly: true, overflowDetected } }, details), { ariaPosInSet: this.positions[index] >= 0 ? this.positions[index] : null, ariaSetSize: this.positions[index] >= 0 ? this.count : null, excludeTabStop: excludeTabStop || this.tabbableIndex !== index, excludeFocusZone: !focusable, id: item.underlyingItem.id, singleClickActivation: false, role: data.type === ListBoxItemType.Header || data.type === ListBoxItemType.Divider ? "row" : "treeitem" }); return (React.createElement(Observer, { key: `observer-${item.underlyingItem.id || index}`, item: item }, () => (React.createElement(TreeRow, { key: `row-${item.underlyingItem.id || index}`, index: index, details: rowDetails, className: css("bolt-list-box-row", data.type === ListBoxItemType.Header && "bolt-list-box-header-row", data.type === ListBoxItemType.Divider && "bolt-list-box-divider-row", data.type === ListBoxItemType.Loading && "bolt-list-box-loading-row", this.multiSelect && "bolt-list-box-multi-select-row", data.type !== ListBoxItemType.Header && data.type !== ListBoxItemType.Divider && "cursor-pointer", data.disabled && "bolt-list-box-item-disabled") }, this.columns.map((treeColumn, columnIndex) => { return treeColumn.renderCell(index, columnIndex, treeColumn, item); }))))); }; this.renderListBoxCell = (rowIndex, columnIndex, tableColumn, tableItem) => { return renderListBoxCell(rowIndex, columnIndex, tableColumn, tableItem, this.multiSelect); }; const { selection, renderItem, items } = this.props; this.selection = selection || new DropdownSelection(); this.multiSelect = this.props.enforceSingleSelect || this.props.showTree ? false : this.selection.multiSelect; // trees get custom columns if (!this.props.columns) { this.columns = []; if (this.multiSelect && this.props.showChecksColumn !== false) { this.columns.push(new ColumnSelect({ excludeFocusZone: true })); } else if (!this.multiSelect && this.props.showChecksColumn === true) { this.columns.push({ id: "column-check", width: 24, renderCell: (rowIndex, columnIndex) => (React.createElement("div", { className: "dropdown-list checkmark-icon", "data-column-index": columnIndex, key: "column-check", role: "presentation" }, React.createElement(Icon, { ariaHidden: "removed", className: css(!this.selection.selected(rowIndex) && "invisible"), iconName: "CheckMark" }))), readonly: true }); } this.columns.push({ id: "text", width: this.getItemWidth(), renderCell: renderItem || this.renderListBoxCell, className: css("bolt-list-box-text", this.multiSelect ? "bolt-list-box-text-multi-select" : "bolt-list-box-text-single-select"), readonly: true }); } else { this.columns = this.props.columns; } // string items are wrapped once here. Only use a string array in the simple case where the items are not changing. this.wrappedItems = wrapListBoxItems(items); this.onItemsChanged(); } componentDidUpdate() { if (this.props.didUpdate) { this.props.didUpdate(); } } render() { const { ariaLabel, className, containerClassName, enforceSingleSelect, focuszoneProps, getUnselectableRanges, items, loading, searching, searchResultsLoadingText, showItemsWhileSearching, width } = this.props; const itemsObservable = { observableValue: items, filter: this.onItemsChanged }; const listBoxItems = this.getListBoxItems(); const itemProvider = listBoxItems ? this.props.showTree ? new TreeItemProvider(convertListBoxItemsToTreeItems(listBoxItems)) : new ArrayItemProvider(listBoxItems) : items; if (!this.props.columns) { this.columns[this.columns.length - 1].width = this.getItemWidth(); } return (React.createElement(ItemsObserver, { getUnselectableRanges: getUnselectableRanges, items: items, selection: this.selection }, React.createElement(Observer, { items: itemsObservable, loading: { observableValue: loading || false, filter: this.loadingChanged }, searching: { observableValue: searching || false, filter: this.searchingChanged } }, (props) => { if (props.searching && !showItemsWhileSearching) { return (React.createElement("div", { className: "bolt-list-box-loading", style: { width } }, React.createElement(Spinner, { size: SpinnerSize.medium, label: searchResultsLoadingText || Resources.Searching }))); } if (this.props.showTree) { const treeProvider = itemProvider; return (React.createElement(Tree, { ariaLabel: ariaLabel || undefined, className: css(className, "bolt-list-box", "bolt-list-box-tree"), columns: this.columns, containerClassName: containerClassName, focuszoneProps: focuszoneProps, itemProvider: treeProvider, onActivate: this.onTreeActivate, onSelect: this.onTreeSelect, onToggle: (event, treeItem) => { if (event.target.className.includes("bolt-tree-expand-button")) { treeProvider.toggle(treeItem.underlyingItem); if (this.props.onToggle) { this.props.onToggle(event, treeItem.underlyingItem.data); } } }, ref: this.tree, renderRow: this.renderListBoxTreeRow, role: "listbox", scrollable: true, singleClickActivation: false, selection: this.selection, showHeader: false, showLines: false })); } else { return (React.createElement(Table, { ariaLabel: ariaLabel || undefined, className: css(className, "bolt-list-box"), columns: this.columns, containerClassName: containerClassName, enforceSingleSelect: enforceSingleSelect, focuszoneProps: focuszoneProps, itemProvider: itemProvider, onActivate: this.onActivate, onSelect: this.onSelect, renderRow: this.renderListBoxRow, ref: this.table, role: "listbox", scrollable: true, singleClickActivation: false, selection: this.selection, showHeader: false, showLines: false, spacerWidth: 0 })); } }))); } scrollIntoView(rowIndex, options) { if (this.table.current) { return this.table.current.scrollIntoView(rowIndex, options); } else if (this.tree.current) { return this.tree.current.scrollIntoView(rowIndex, options); } } /** * Try to pull list box items out of props and variables. * Returns undefined in case where IItemProvider was passed in. */ getListBoxItems() { var _a, _b; return ((_b = (_a = this.wrappedItems) !== null && _a !== void 0 ? _a : (this.props.items && (Array.isArray(this.props.items) ? this.props.items : this.props.items.value))) !== null && _b !== void 0 ? _b : undefined); } } ListBox.defaultProps = { getUnselectableRanges: getUnselectableRanges, width: DefaultListBoxWidth }; export function renderListBoxCell(rowIndex, columnIndex, tableColumn, tableItem, multiSelect) { if (tableItem.render) { return tableItem.render(rowIndex, columnIndex, tableColumn, tableItem); } if (tableItem.type === ListBoxItemType.Divider) { return (React.createElement(SimpleTableCell, { className: css(tableColumn.className, tableItem.className, multiSelect && "bolt-list-box-divider-multi-select"), columnIndex: columnIndex, colspan: multiSelect ? 2 : 1, key: tableItem.id, role: "presentation", tableColumn: tableColumn }, React.createElement("div", { className: "bolt-list-box-divider flex-grow" }))); } else if (tableItem.type === ListBoxItemType.Loading) { return React.createElement(LoadingCell, { columnIndex: columnIndex, key: tableItem.id, tableColumn: tableColumn, tableItem: tableItem }); } return (React.createElement(SimpleTableCell, { className: css(tableColumn.className, tableItem.className, tableItem.type === ListBoxItemType.Header && "bolt-list-box-header"), columnIndex: columnIndex, key: tableItem.id, role: "presentation", tableColumn: tableColumn }, React.createElement("div", { id: tableItem.type === ListBoxItemType.Header ? `header-${tableItem.groupId}` : undefined, "aria-label": undefined, className: "bolt-list-box-cell-container" }, tableItem && renderListCell(tableItem, false)))); } function overflowDetected(anchorElement) { const overflowElement = anchorElement.querySelector(".text-ellipsis"); if (overflowElement) { return overflowElement.scrollWidth > Math.ceil(overflowElement.offsetWidth); } return false; } /** * Retrieve a list of unselectable ranges based on a itemSelectable function. * @param items the set of items * @param itemSelectable A function that returns false when an items is not selectable. * Defaults to checking that the item type is not header or divider. */ export function getUnselectableRanges(items, itemSelectable = listBoxItemSelectable) { const ranges = []; let beginIndex = -1; for (let index = 0; index < items.length; index++) { if (!itemSelectable(items[index]) && beginIndex < 0) { beginIndex = index; } else if (itemSelectable(items[index]) && beginIndex >= 0) { ranges.push({ beginIndex: beginIndex, endIndex: index - 1 }); beginIndex = -1; } } if (beginIndex >= 0) { ranges.push({ beginIndex: beginIndex, endIndex: items.length - 1 }); } return ranges; } /** * Return whether a ListBoxItem can be selected or not. * @param item the ListBoxItem to evaluate */ export function listBoxItemSelectable(item) { return item.type !== ListBoxItemType.Header && item.type !== ListBoxItemType.Divider && item.type !== ListBoxItemType.Loading && !item.disabled; } /** * When items is a string[], wrap each item in a ListBoxItem. Otherwise, do nothing. * @param items the items prop */ export function wrapListBoxItems(items) { if (Array.isArray(items) && items.length && typeof items[0] === "string") { return items.map((item) => { return { id: item, text: item }; }); } } /** * Walk through the ListBoxItems and construct a tree */ export function convertListBoxItemsToTreeItems(items) { const rootItems = []; const itemsMap = new Map(); for (const item of items) { // include children that may have been processed before this parent const precreatedTreeItem = itemsMap.get(item.id); const treeItem = { childItems: precreatedTreeItem === null || precreatedTreeItem === void 0 ? void 0 : precreatedTreeItem.childItems, expanded: item.expanded, data: item, id: item.id, text: item.text }; if (!item.parent) { rootItems.push(treeItem); } else { const parent = itemsMap.get(item.parent.id); if (parent) { if (parent.childItems) { parent.childItems.push(treeItem); } else { parent.childItems = [treeItem]; } } else { // add a placeholder that tracks children until parent actually gets processed itemsMap.set(item.parent.id, { childItems: [treeItem] }); } } itemsMap.set(treeItem.id, treeItem); } return rootItems; } /** * Helper to get the value of the items prop. If items is a string[], it should first be wrapped using wrapListBoxItems. * If it's an itemProvider, .value will be called on the provider. * @param items the items prop. If items was provided as a string[], it should first be wrapped using wrapListBoxItems. */ export function getListBoxItemsValue(items) { if (false) { if (Array.isArray(items) && items.length && typeof items[0] === "string") { console.warn("a string[] was passed for items and not wrapped first. Call wrapListBoxItems on items and pass in the results as items."); } } return getItemsValue(items); } export class LoadingCell extends React.Component { componentDidMount() { if (this.props.onMount) { this.props.onMount(); } } render() { const { columnIndex, tableColumn, tableItem } = this.props; return (React.createElement(SimpleTableCell, { className: css(tableColumn.className, tableItem.className), columnIndex: columnIndex, colspan: 2, contentClassName: "justify-center", key: columnIndex, tableColumn: tableColumn }, React.createElement("div", { className: "bolt-list-box-loading" }, React.createElement(Spinner, { size: SpinnerSize.medium, label: Resources.Loading })))); } } export function isListBoxItemVisible(item) { let parent = item.parent; while (parent) { if (!parent.expanded) { return false; } parent = parent.parent; } return true; }