azure-devops-ui
Version:
React components for building web UI in Azure DevOps
499 lines (498 loc) • 25.1 kB
JavaScript
import "../../CommonImports";
import "../../Core/core.css";
import "./Dropdown.css";
import * as React from "react";
import { ObservableArray, ObservableLike, ObservableValue } from '../../Core/Observable';
import { TimerManagement } from '../../Core/TimerManagement';
import * as Utils_Accessibility from '../../Core/Util/Accessibility';
import { format } from '../../Core/Util/String';
import { FilteredListSelection, renderListCell } from '../../List';
import { getListBoxItemsValue, getUnselectableRanges, ListBoxItemType, wrapListBoxItems } from '../../ListBox';
import { ItemsObserver, Observer } from '../../Observer';
import * as Resources from '../../Resources.Dropdown';
import { SimpleTableCell } from '../../Table';
import { css } from '../../Util';
import { DropdownSelection } from '../../Utilities/DropdownSelection';
import { getItemsValue } from '../../Utilities/Provider';
import { DropdownCallout } from "./DropdownCallout";
import { DropdownExpandableTextField } from "./DropdownExpandableTextField";
export class Dropdown extends React.Component {
constructor(props) {
super(props);
this.expandable = React.createRef();
this.expandableContainer = React.createRef();
this.filterText = new ObservableValue("");
this.collapse = () => {
if (this.expandable.current) {
this.expandable.current.collapse();
}
};
this.expand = () => {
if (this.expandable.current) {
this.expandable.current.expand();
}
};
this.onDismiss = () => {
if (this.expandable.current) {
this.expandable.current.collapse();
}
};
this.onExpand = () => {
if (this.props.onExpand) {
this.props.onExpand();
}
this.updateFilteredItems();
this.state.expanded.value = true;
};
this.onCollapse = () => {
if (this.props.onCollapse) {
this.props.onCollapse();
}
this.state.expanded.value = false;
};
this.onActivate = (event, item) => {
if (!event.defaultPrevented && event.type === "keydown") {
const multiSelect = this.props.enforceSingleSelect ? false : this.state.filteredSelection.multiSelect;
if (multiSelect) {
this.state.filteredSelection.toggle(this.state.filteredItems.value.indexOf(item), this.state.filteredSelection.alwaysMerge, multiSelect);
}
else {
this.state.filteredSelection.select(this.state.filteredItems.value.indexOf(item), 1, this.state.filteredSelection.alwaysMerge, multiSelect);
}
this.onSelect(event, item);
}
};
this.onFilterTextChanged = (e, newValue) => {
this.filterText.value = newValue;
this.debouncedUpdateFilteredItems();
};
this.onSelect = (event, item) => {
const { dismissOnSelect, onSelect } = this.props;
const selection = this.parentSelection;
if (onSelect) {
onSelect(event, item);
}
if (dismissOnSelect !== undefined
? dismissOnSelect
: selection.value.length > 0 && !(this.props.enforceSingleSelect ? false : selection.multiSelect) && !selection.selectOnFocus) {
this.filterText.value = "";
this.onDismiss();
}
};
this.selectionChanged = (value, action) => {
this.state.filteredSelection.selectionChanged(value, action);
return true;
};
this.renderCallout = (dropdown, dropdownId, anchorElement, anchorOffset, anchorOrigin, anchorPoint, dropdownOrigin) => {
var _a;
const { actions, ariaLabel, calloutContentClassName, columns, containerClassName, filterPlaceholderText, filteredNoResultsText, getUnselectableRanges, items, loading, noItemsText, onFilterTextChanged, onToggle, portalProps, renderItem, renderBeforeContent, searching, showChecksColumn, showFilterBox, showItemsWhileSearching, showTree, startsWithSort, userFilteredItems } = this.props;
let width = this.props.width;
if (width === undefined && this.expandableContainer.current) {
const minWidth = (_a = this.props.minCalloutWidth) !== null && _a !== void 0 ? _a : 100;
width = Math.max(this.expandableContainer.current.clientWidth, minWidth);
}
const { filteredItems, filterText, filteredSelection } = this.state;
const calloutProps = {
actions,
anchorElement,
anchorOffset,
anchorOrigin,
anchorPoint,
ariaLabel,
calloutContentClassName,
columns,
containerClassName,
dropdownOrigin,
filteredItems,
filteredNoResultsText,
selection: filteredSelection,
filterPlaceholderText,
filterText,
getUnselectableRanges,
id: dropdownId,
items,
loading,
noItemsText,
onActivate: this.onActivate,
onFilterTextChanged: (e, value) => {
onFilterTextChanged && onFilterTextChanged(e, value);
this.onFilterTextChanged && this.onFilterTextChanged(e, value);
},
onDismiss: this.onDismiss,
onSelect: this.onSelect,
onToggle,
portalProps,
renderBeforeContent,
renderItem,
searching,
showChecksColumn,
showItemsWhileSearching,
showFilterBox,
showTree,
startsWithSort,
updateFilteredItems: this.updateFilteredItems,
userFilteredItems,
width
};
return this.props.renderCallout(calloutProps);
};
this.updateFilteredItems = () => {
updateFilteredItems(this.props, this.state);
return true;
};
this.debouncedUpdateFilteredItems = () => {
updateFilteredItems(this.props, this.state);
};
this.parentSelection = props.selection || new DropdownSelection();
// string items are wrapped once here. Only use a string array in the simple case where the items are not changing.
const wrappedItems = wrapListBoxItems(props.items);
const itemsValue = getListBoxItemsValue(wrappedItems || props.items);
this.timerManagement = new TimerManagement();
this.state = {
expanded: new ObservableValue(false),
filteredItems: new ObservableArray([...itemsValue]),
filteredSelection: new FilteredListSelection(this.parentSelection),
filterText: this.filterText,
props: props,
wrappedItems: wrappedItems
};
}
static getDerivedStateFromProps(props, state) {
if (props.userFilteredItems !== state.props.userFilteredItems || props.items !== state.props.items) {
updateFilteredItems(props, state);
}
return Object.assign(Object.assign({}, state), { props: props, wrappedItems: wrapListBoxItems(props.items) });
}
componentDidMount() {
if (this.props.filterThrottleWait) {
this.debouncedUpdateFilteredItems = this.timerManagement.debounce(this.debouncedUpdateFilteredItems, this.props.filterThrottleWait);
}
}
render() {
const { ariaLabel, ariaLabelledBy, ariaDescribedBy, autoSelect, className, disableAutocomplete, disabled, enforceSingleSelect, excludeTabStop, inputId, items, placeholder, renderExpandable, renderSelectedItems, role, showPrefix, required } = this.props;
const selectionObservable = { observableValue: this.parentSelection, filter: this.selectionChanged };
return (React.createElement(ItemsObserver, { getUnselectableRanges: this.props.getUnselectableRanges, items: items, selection: this.parentSelection },
React.createElement(Observer, { selection: selectionObservable }, () => {
return renderExpandable({
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
autoSelect,
className: css(className, "bolt-dropdown-expandable"),
containerRef: this.expandableContainer,
disabled,
disableAutocomplete,
enforceSingleSelect,
excludeTabStop,
inputId,
placeholder,
onCollapse: this.onCollapse,
onExpand: this.onExpand,
expandableRef: this.expandable,
renderCallout: this.renderCallout,
items: getListBoxItemsValue(this.state.wrappedItems || items),
role,
renderSelectedItems: renderSelectedItems,
selection: this.parentSelection,
showPrefix: showPrefix,
required: required
});
})));
}
focus() {
if (this.expandable.current) {
this.expandable.current.focus();
}
}
}
Dropdown.defaultProps = {
filterByText: true,
filterItem: filterItemByText,
getUnselectableRanges: getUnselectableRanges,
renderCallout: DropdownCallout,
renderExpandable: DropdownExpandableTextField,
renderSelectedItems: renderDropdownSelectedItemText
};
export function filterItemByText(filterText, item) {
if (item.text && item.type !== ListBoxItemType.Header && item.type !== ListBoxItemType.Divider && item.type !== ListBoxItemType.Loading) {
return item.text.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
}
return false;
}
export function filterItemByTextStartsWith(filterText, item) {
if (item.text && item.type !== ListBoxItemType.Header && item.type !== ListBoxItemType.Divider && item.type !== ListBoxItemType.Loading) {
return item.text.toLowerCase().startsWith(filterText.toLowerCase());
}
return false;
}
export function renderDropdownSelectedItemText(selection, items) {
const firstSelectedItem = items[selection.value[0].beginIndex];
let text = (firstSelectedItem && firstSelectedItem.text) || "";
if (selection.selectedCount > 1) {
text = `${text} (+${selection.selectedCount - 1})`;
}
return text;
}
// This is necessary as it is not a class method
// Since updateFilteredItems gets called repeatedly, we need to announce the results only once
let announcementInterval;
function updateFilteredItems(props, state) {
if (announcementInterval) {
clearTimeout(announcementInterval);
}
const { filteredSelection, filterText } = state;
let filteredIndexMap = [];
const items = getListBoxItemsValue(state.wrappedItems || props.items);
let filteredItems = items;
if (props.userFilteredItems) {
filteredItems = getItemsValue(props.userFilteredItems);
const userFilteredItemsIndexMap = props.userFilteredItemsIndexMap && props.userFilteredItemsIndexMap.value;
if (userFilteredItemsIndexMap) {
filteredIndexMap = userFilteredItemsIndexMap;
}
else {
for (let filteredIndex = 0; filteredIndex < props.userFilteredItems.length; filteredIndex++) {
const index = items.findIndex(listItem => listItem.id === filteredItems[filteredIndex].id);
if (false) {
if (index === -1) {
console.error("filteredItems contains an item not in items. " +
"Selection cannot be maintained unless filteredItems is a subset of items. " +
"Check item in filteredItems at index " +
filteredIndex);
}
}
filteredIndexMap.push(index);
}
}
}
if (props.filterByText && filterText.value) {
const filterItemsResults = filterItems(filteredItems, filterText.value, filteredIndexMap, props.filterItem, props.startsWithSort);
filteredItems = filterItemsResults.filteredItems;
filteredIndexMap = filterItemsResults.filteredIndexMap;
}
// Remove the first item if it's a divider
while (filteredItems.length && filteredItems[0].type === ListBoxItemType.Divider) {
filteredItems.shift();
filteredIndexMap.shift();
}
announcementInterval = setTimeout(() => {
if (!ObservableLike.getValue(props.searching) && !ObservableLike.getValue(props.loading) && state.expanded.value) {
if (filterText.value) {
let noResultsText = Resources.NoFilterResults;
if (props.filteredNoResultsText) {
noResultsText = ObservableLike.getValue(props.filteredNoResultsText);
}
Utils_Accessibility.announce(filteredItems.length > 0 ? format(Resources.AnnounceFilterResultCount, filteredItems.length) : noResultsText, true);
}
else if (filteredItems.length === 0 && props.noItemsText) {
Utils_Accessibility.announce(props.noItemsText, true);
}
else if (filteredItems.length > 0) {
Utils_Accessibility.announce(format(Resources.AnnounceItemCount, filteredItems.length));
}
}
}, 500);
filteredSelection.updateFilteredSelection(filteredIndexMap, props.enforceSingleSelect ? false : undefined);
state.filteredItems.value = filteredItems;
return true;
}
export function filterItems(items, filterTextValue, currentFilteredIndexMap = [], filterItem = filterItemByText, startsWithSort = false) {
let filteredItems = [];
const filteredIndexMap = [];
const filterMatches = [];
if (filterTextValue) {
let lastHeader;
let lastHeaderIndex = -1;
let lastDivider;
let lastDividerIndex = -1;
if (startsWithSort) {
let startsWithMatches = [];
let containsMatches = [];
let startsWithIndices = [];
let containsIndices = [];
// Get the filtered items by section
for (let i = 0, l = items.length; i < l; i++) {
const item = items[i];
const itemIndex = currentFilteredIndexMap.length ? currentFilteredIndexMap[i] : i;
// handle finding a header or divider
// Track these values and push to the previous section if it exists
if (item.type === ListBoxItemType.Header || item.type === ListBoxItemType.Divider) {
if (startsWithMatches.length || containsMatches.length) {
filteredItems.push(...startsWithMatches);
filteredItems.push(...containsMatches);
filteredIndexMap.push(...startsWithIndices);
filteredIndexMap.push(...containsIndices);
startsWithMatches = [];
containsMatches = [];
startsWithIndices = [];
containsIndices = [];
}
if (item.type === ListBoxItemType.Header) {
lastHeader = item;
lastHeaderIndex = itemIndex;
}
else if (item.type === ListBoxItemType.Divider) {
lastDivider = item;
lastDividerIndex = itemIndex;
}
}
else {
// See if it's a startsWith match and add to appropriate list if so
const filterResultsStartsWith = filterItemByTextStartsWith(filterTextValue, item);
if (filterResultsStartsWith) {
if (lastDivider && lastDivider.groupId === item.groupId) {
startsWithMatches.push(lastDivider);
startsWithIndices.push(lastDividerIndex);
lastDivider = undefined;
}
// Add the header first if it has an item from its group showing
if (lastHeader && lastHeader.groupId === item.groupId) {
startsWithMatches.push(lastHeader);
startsWithIndices.push(lastHeaderIndex);
lastHeader = undefined;
}
startsWithMatches.push(item);
startsWithIndices.push(itemIndex);
filterMatches.push(Array.isArray(filterResultsStartsWith) ? filterResultsStartsWith : []);
}
else {
const filterResults = filterItem(filterTextValue, item, items);
if (filterResults || item.type === ListBoxItemType.Loading) {
// Add any divider, then header for this group
if (lastDivider && lastDivider.groupId === item.groupId) {
startsWithMatches.push(lastDivider);
startsWithIndices.push(lastDividerIndex);
lastDivider = undefined;
}
// Add the header first if it has an item from its group showing
if (lastHeader && lastHeader.groupId === item.groupId) {
startsWithMatches.push(lastHeader);
startsWithIndices.push(lastHeaderIndex);
lastHeader = undefined;
}
containsMatches.push(item);
containsIndices.push(itemIndex);
filterMatches.push(Array.isArray(filterResults) ? filterResults : []);
}
}
}
}
// Push the remaining items
filteredItems.push(...startsWithMatches);
filteredItems.push(...containsMatches);
filteredIndexMap.push(...startsWithIndices);
filteredIndexMap.push(...containsIndices);
}
else {
for (let i = 0, l = items.length; i < l; i++) {
const item = items[i];
const itemIndex = currentFilteredIndexMap.length ? currentFilteredIndexMap[i] : i;
// Add Dividers and Headers only if they have an item from their group showing.
if (item.type === ListBoxItemType.Header) {
lastHeader = item;
lastHeaderIndex = itemIndex;
}
else if (item.type === ListBoxItemType.Divider) {
lastDivider = item;
lastDividerIndex = itemIndex;
}
else {
const filterResults = filterItem(filterTextValue, item, items);
if (filterResults || item.type === ListBoxItemType.Loading) {
// Add any divider, then header for this group
if (lastDivider && lastDivider.groupId === item.groupId) {
filteredItems.push(lastDivider);
filteredIndexMap.push(lastDividerIndex);
lastDivider = undefined;
}
if (lastHeader && lastHeader.groupId === item.groupId) {
filteredItems.push(lastHeader);
filteredIndexMap.push(lastHeaderIndex);
lastHeader = undefined;
}
filteredItems.push(item);
filteredIndexMap.push(itemIndex);
filterMatches.push(Array.isArray(filterResults) ? filterResults : []);
}
}
}
}
}
return { filteredItems, filteredIndexMap, filterMatches };
}
/**
* Filter the tree of items using user-entered text. Include all items with text matching
* the filter and all their predecessors and descendants in the tree.
* @returns items matching filter and all their predecessors and descendants in the tree, and the index of the first actual match (since we're returning predecessors)
*/
export function filterTreeItems(items, filterText, currentFilteredIndexMap = [], filterItem = filterItemByText, filterMatchedItem = filterMatchedItemByListboxType) {
const filterResults = filterItems(items, filterText, currentFilteredIndexMap, filterItem);
const filteredIndexes = filterResults.filteredIndexMap;
// find the index of the first actual match to allow calling code to focus it
const firstMatch = filterResults.filteredItems.find(filterMatchedItem);
// reconstruct the list of filtered items, adding in descendants of filtered items
const indexMap = {};
for (const index of filteredIndexes) {
const item = items[index];
let parent = item.parent;
while (parent) {
const parentIndex = items.indexOf(parent);
indexMap[parentIndex] = parent;
parent.expanded = true;
parent = parent.parent;
}
indexMap[index] = item;
}
const filteredIndexMap = [];
const filteredItems = [];
for (const indexStr of Object.keys(indexMap)) {
const index = Number(indexStr);
const value = indexMap[index];
filteredIndexMap.push(index);
filteredItems.push(value);
}
const firstMatchIndex = firstMatch ? items.indexOf(firstMatch) : -1;
return [{ filteredIndexMap, filteredItems, filterMatches: [] }, firstMatchIndex];
}
export function filterMatchedItemByListboxType(item) {
return !item.type || item.type === ListBoxItemType.Row;
}
export function renderHighlightedText(rowIndex, columnIndex, tableColumn, tableItem, filterResults) {
let item = tableItem;
if (filterResults && tableItem.text) {
item = Object.assign(Object.assign({}, tableItem), { textNode: getHighlightedText(tableItem.text, filterResults) });
}
return (React.createElement(SimpleTableCell, { className: css(tableColumn.className, tableItem.className, tableItem.type === ListBoxItemType.Header && "bolt-list-box-header"), columnIndex: columnIndex, key: columnIndex, tableColumn: tableColumn },
React.createElement("div", { id: tableItem.type === ListBoxItemType.Header ? `header-${tableItem.id}` : undefined, "aria-label": tableItem.type === ListBoxItemType.Header ? format(Resources.HeaderAriaLabel, tableItem.text) : undefined }, renderListCell(item))));
}
export function getHighlightedText(text, matchingIndices, className) {
const splitText = [];
let splitTextIndex = -1;
// Split text into bold and non-bold sections
for (let i = 0; i < text.length; i++) {
if (matchingIndices.indexOf(i) !== -1) {
if (splitText && splitText[splitText.length - 1] && splitText[splitText.length - 1].bold) {
splitText[splitTextIndex].text += text.charAt(i);
}
else {
splitText[++splitTextIndex] = { text: text.charAt(i), bold: true };
}
}
else {
if (splitText && splitText[splitText.length - 1] && !splitText[splitText.length - 1].bold) {
splitText[splitTextIndex].text += text.charAt(i);
}
else {
splitText[++splitTextIndex] = { text: text.charAt(i), bold: false };
}
}
}
const formattedText = [];
for (let i = 0; i < splitText.length; i++) {
const substring = splitText[i];
substring.bold
? formattedText.push(React.createElement("span", { className: "font-weight-heavy", key: `${text}-${i}` }, substring.text))
: formattedText.push(substring.text);
}
return React.createElement("span", { className: className }, formattedText);
}