azure-devops-ui
Version:
React components for building web UI in Azure DevOps
358 lines (357 loc) • 21.2 kB
JavaScript
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;
}