azure-devops-ui
Version:
React components for building web UI in Azure DevOps
485 lines (484 loc) • 26.3 kB
JavaScript
import "../../CommonImports";
import "../../Core/core.css";
import "./EditableDropdown.css";
import * as React from "react";
import { ObservableArray, ObservableLike, ObservableValue } from '../../Core/Observable';
import { TimerManagement } from '../../Core/TimerManagement';
import { equals, startsWith } from '../../Core/Util/String';
import { Dropdown, DropdownCallout, DropdownExpandableTextField, filterItems } from '../../Dropdown';
import { isListBoxItemVisible, ListBoxItemType, renderListBoxCell } from '../../ListBox';
import { Observer } from '../../Observer';
import * as Resources from '../../Resources.Dropdown';
import { convertSpecialSymbols, css, KeyCode } from '../../Util';
import { DropdownSelection } from '../../Utilities/DropdownSelection';
import { EditableDropdownItemProvider } from '../../Utilities/EditableDropdownItemProvider';
import { filterTreeItems, renderHighlightedText } from "../Dropdown/Dropdown";
export class CustomEditableDropdown extends React.Component {
constructor(props) {
super(props);
this.dropdown = React.createRef();
this.listBox = React.createRef();
this.filteredIndexMap = new ObservableValue([]);
this.filterMatches = [];
this.collapse = () => {
if (this.dropdown.current) {
this.dropdown.current.collapse();
}
};
this.expand = () => {
if (this.dropdown.current) {
this.dropdown.current.expand();
}
};
this.renderItem = (rowIndex, columnIndex, tableColumn, tableItem) => {
return this.wrapWithFocusedIndexObserver(rowIndex, columnIndex, tableColumn, tableItem, (rowIndex, columnIndex, tableColumn, tableItem) => {
const item = tableItem;
const column = tableColumn;
const filterMatches = this.filterMatches[rowIndex];
if (!item.render && filterMatches && filterMatches.length) {
item.render = (rowIndex, columnIndex, tableColumn, tableItem) => renderHighlightedText(rowIndex, columnIndex, tableColumn, tableItem, filterMatches);
}
return this.props.renderItem(rowIndex, columnIndex, column, item);
});
};
this.wrapWithFocusedIndexObserver = (rowIndex, columnIndex, tableColumn, tableItem, render) => {
return (React.createElement(Observer, { focusedIndex: {
observableValue: this.focusedIndex,
filter: () => {
const itemIndex = this.filteredIndexMap.value[rowIndex];
return itemIndex === this.focusedIndex.value || itemIndex === this.previousFocusedIndex;
}
}, key: `focused-observer-${rowIndex}-${columnIndex}` }, () => {
var _a;
const item = this.filteredIndexMap.value[rowIndex] === this.focusedIndex.value &&
((_a = tableItem) === null || _a === void 0 ? void 0 : _a.type) !== ListBoxItemType.Loading
? Object.assign(Object.assign({}, tableItem), { className: css(tableItem.className, "bolt-editable-dropdown-focused-item") }) : Object.assign({}, tableItem);
return render(rowIndex, columnIndex, tableColumn, item);
}));
};
this.onCollapse = () => {
var _a, _b;
const { allowFreeform, autoAccept, onCollapse, onValueChange, showTree, text } = this.props;
if (onCollapse) {
onCollapse();
}
if (!this.selectedItemInList) {
const items = this.itemProvider.value;
// If the text matches an item in the list, select that item.
const index = items.findIndex((item, index) => this.selection.selectable(index) && item.text === ObservableLike.getValue(text || ""));
if (index > -1) {
this.selectIndex(index);
}
}
const lastIndex = this.itemProvider.length - 1;
// If collapsing with the freeform item in the list, if the last value is selected or no value is selected, select the value in the textField.
if (allowFreeform &&
(!this.selection.value.length || this.selection.value[0].beginIndex === lastIndex || !this.selectedItemInList) &&
this.itemProvider.hasExtraItem) {
if (autoAccept || this.selectedItemInList || this.selectedFreeform) {
const selectedText = this.itemProvider.value[lastIndex].id;
if (onValueChange) {
onValueChange({ id: selectedText, text: selectedText });
}
}
this.selectedFreeform = false;
this.selection.clear();
}
else if (showTree && this.lastSelectedItem) {
// tree items may be collapsed, so selection index isn't guaranteed to be accurate
if (onValueChange) {
onValueChange(this.lastSelectedItem);
}
}
else if (this.selection.value.length) {
const selectedItem = this.itemProvider.value[this.selection.value[0].beginIndex];
if (onValueChange) {
onValueChange(selectedItem);
}
}
// Clear the filter text, showing the selected item.
(_b = (_a = this.props).onTextChange) === null || _b === void 0 ? void 0 : _b.call(_a, null, "");
this.isExpanded = false;
this.selectedItemInList = false;
this.lastSelectedItem = undefined;
if (showTree) {
this.focusedIndex.value = -1;
}
this.isFiltering = false;
};
this.onItemsChange = (value, action) => {
if (!this.isExpanded) {
return;
}
// Update the filtered set if items were added or removed.
if (action !== "change") {
this.filterItems();
}
this.selectSelectedTextItem();
};
this.selectSelectedTextItem = () => {
if (this.props.selectedText) {
const selectedText = ObservableLike.getValue(this.props.selectedText);
if (selectedText) {
const selectedIndex = this.itemProvider.value.findIndex(item => item.text === selectedText);
if (selectedIndex > -1) {
this.selection.select(selectedIndex);
}
}
}
};
this.onSelect = (event, item) => {
// Set to true when an explicit selection is made. If false, it means a user is making a freeform selection by blurring.
this.selectedItemInList = true;
this.lastSelectedItem = item;
};
this.renderExpandable = (props) => {
return (React.createElement(Observer, { focusedIndex: this.focusedIndex, selectedText: this.props.selectedText, text: this.props.text }, (observerProps) => {
var _a, _b;
const { allowTextSelection, inputId } = this.props;
const { selectedText, text } = observerProps;
const focusedIndex = this.getFocusedIndex();
let activeId;
if (focusedIndex > -1 && this.itemProvider.value[focusedIndex]) {
activeId = this.itemProvider.value[focusedIndex].id;
}
const value = allowTextSelection && selectedText && !this.isExpanded ? selectedText : text;
const expandableProps = Object.assign(Object.assign({}, props), { ariaActiveDescendant: convertSpecialSymbols(activeId), editable: true, showPrefix: text ? false : true, blurDismiss: true, inputId, onChange: this.onTextChange, onKeyDown: this.onKeyDown, value });
return (_b = (_a = this.props).renderExpandable) === null || _b === void 0 ? void 0 : _b.call(_a, expandableProps);
}));
};
this.renderCallout = (props) => {
const calloutProps = Object.assign(Object.assign({}, props), { focusOnMount: false, excludeTabStop: true, excludeFocusZone: true, ignoreMouseDown: true, listBoxRef: this.listBox });
return this.props.renderCallout(calloutProps);
};
this.onExpand = () => {
if (this.props.onExpand) {
this.props.onExpand();
}
if (this.props.filterItems) {
const filterResult = this.props.filterItems("", this.itemProvider.value);
const selectedIndex = this.updateFilteredIndexMap(filterResult.filteredIndexMap);
this.filteredItems.value = filterResult.filteredItems;
this.focusItem(selectedIndex);
}
else {
this.filteredItems.value = this.itemProvider.value;
const focusedIndex = this.updateFilteredIndexMap(this.filteredItems.value.map((item, index) => index));
this.focusItem(focusedIndex);
}
this.isExpanded = true;
};
this.onTextChange = (event, text) => {
var _a, _b, _c, _d, _e, _f, _g, _h;
(_b = (_a = this.props).onTextChange) === null || _b === void 0 ? void 0 : _b.call(_a, event, text);
if (this.props.allowFreeform) {
if (this.itemProvider.hasExtraItem) {
// Remove the freeform item from the filtered set first so we don't
// get a warning that the filtered set is greater than the item set.
this.filteredItems.pop();
}
this.itemProvider.setTextValue(text);
if (this.props.allowClear && text === "") {
this.selection.clear();
(_d = (_c = this.props).onValueChange) === null || _d === void 0 ? void 0 : _d.call(_c);
}
}
else if (text === "" && this.props.allowClear) {
this.selection.clear();
(_f = (_e = this.props).onValueChange) === null || _f === void 0 ? void 0 : _f.call(_e);
}
else {
const selectedIndex = this.itemProvider.value.findIndex(item => item.text === text);
if (selectedIndex > -1 && this.selection.selectable(selectedIndex)) {
this.selection.select(selectedIndex);
(_h = (_g = this.props).onValueChange) === null || _h === void 0 ? void 0 : _h.call(_g, this.itemProvider.value[selectedIndex]);
}
}
if (this.isExpanded) {
this.filterItems();
}
return false;
};
this.filterItems = () => {
const items = this.itemProvider.value;
const text = ObservableLike.getValue(this.props.text || "");
let filterResult;
let firstMatchIndex;
if (this.props.filterItems) {
filterResult = this.props.filterItems(text, items);
}
else if (text) {
if (this.props.showTree) {
const [result, firstIndex] = filterTreeItems(items, text, [], this.props.filterItem, this.props.filterMatchedItem);
filterResult = result;
firstMatchIndex = firstIndex;
}
else {
filterResult = filterItems(items, text, [], this.props.filterItem);
// focus the first full or partial match in the list of filter results
firstMatchIndex = items.findIndex(item => { var _a; return equals((_a = item.text) !== null && _a !== void 0 ? _a : "", text, true); });
if (firstMatchIndex < 0) {
firstMatchIndex = items.findIndex(item => { var _a; return startsWith((_a = item.text) !== null && _a !== void 0 ? _a : "", text, true); });
}
}
}
else {
filterResult = {
filteredItems: items,
filteredIndexMap: items.map((item, index) => index),
filterMatches: []
};
}
this.filterMatches = filterResult.filterMatches;
const selectedIndex = this.updateFilteredIndexMap(filterResult.filteredIndexMap);
this.filteredItems.value = filterResult.filteredItems;
// if a tree is being filtered, we want to focus the first actual match in the list (as opposed to ancestor of that match)
const indexToSelect = firstMatchIndex && firstMatchIndex > -1 ? firstMatchIndex : selectedIndex;
this.focusItem(indexToSelect);
this.isFiltering = true;
};
this.onKeyDown = (ev) => {
const keyCode = ev.which;
const initiallyExpanded = this.isExpanded;
switch (keyCode) {
case KeyCode.escape:
if (this.isExpanded) {
this.collapse();
ev.preventDefault();
}
break;
case KeyCode.enter:
if (!this.isExpanded) {
this.expand();
ev.preventDefault();
}
case KeyCode.tab:
if (initiallyExpanded && !ev.shiftKey && (this.filteredItems.length || this.props.allowFreeform)) {
const focusedIndex = this.getFocusedIndex();
if (focusedIndex >= 0) {
this.selectIndex(focusedIndex);
}
else {
this.selectedFreeform = true;
}
this.collapse();
ev.preventDefault();
}
break;
case KeyCode.upArrow:
if (this.isExpanded) {
this.focusPreviousItem();
if (this.listBox.current) {
this.listBox.current.scrollIntoView(this.filteredIndexMap.value.indexOf(this.focusedIndex.value), {
block: "nearest"
});
}
}
ev.preventDefault();
break;
case KeyCode.rightArrow:
if (this.isExpanded && this.props.showTree) {
const focusedIndex = this.getFocusedIndex();
const item = this.itemProvider.value[focusedIndex];
if (!item.expanded) {
this.props.onToggle && this.props.onToggle(ev, item);
/** Kinda hacky but this is the only way to get the items to properly update */
this.focusNextItem();
this.focusPreviousItem();
if (this.listBox.current) {
this.listBox.current.scrollIntoView(this.filteredIndexMap.value.indexOf(focusedIndex), {
block: "nearest"
});
}
}
}
break;
case KeyCode.downArrow:
if (this.isExpanded) {
this.focusNextItem();
if (this.listBox.current) {
this.listBox.current.scrollIntoView(this.filteredIndexMap.value.indexOf(this.focusedIndex.value), {
block: "nearest"
});
}
}
else if (!this.isExpanded) {
this.expand();
}
ev.preventDefault();
break;
case KeyCode.leftArrow:
if (this.isExpanded && this.props.showTree) {
const focusedIndex = this.getFocusedIndex();
const item = this.itemProvider.value[focusedIndex];
if (item.expanded) {
this.props.onToggle && this.props.onToggle(ev, item);
/** Kinda hacky but this is the only way to get the items to properly update */
this.focusPreviousItem();
this.focusNextItem();
if (this.listBox.current) {
this.listBox.current.scrollIntoView(this.filteredIndexMap.value.indexOf(focusedIndex), {
block: "nearest"
});
}
}
}
break;
case KeyCode.delete:
case KeyCode.backspace:
if (this.props.allowClear && !ObservableLike.getValue(this.props.text || "")) {
this.selection.clear();
if (this.props.onValueChange) {
this.props.onValueChange();
}
}
else {
this.expand();
}
break;
case KeyCode.ctrl:
case KeyCode.shift:
// The Ctrl key is used for most screen readers to stop speech.
// while Shift is used for keyboard navigation
// Ignore Shift and Ctrl key down.
break;
case undefined:
// Ignore undefined key code to avoid unnecessary expand/collapse
// It can be undefined for autofill operation in browser
break;
default:
this.expand();
}
};
this.selection = props.selection || new DropdownSelection();
this.itemProvider = new EditableDropdownItemProvider(props.items, this.selection);
this.filteredItems = new ObservableArray([...this.itemProvider.value]);
this.focusedIndex = new ObservableValue(-1);
this.previousFocusedIndex = -1;
this.timerManagement = new TimerManagement();
this.filteredIndexMap.value = this.itemProvider.value.map((item, index) => index);
this.isFiltering = false;
this.selectSelectedTextItem();
if (false && this.selection.multiSelect) {
console.warn("multiselect selection is being used, EditableDropdown does not support multiselect");
}
if (this.props.columns) {
// copy columns and wrap the render function of each column to get focus treatment
this.columns = this.props.columns.map(col => {
return Object.assign(Object.assign({}, col), { renderCell: (rowIndex, columnIndex, treeColumn, treeItem) => this.wrapWithFocusedIndexObserver(rowIndex, columnIndex, treeColumn, treeItem, col.renderCell) });
});
}
}
render() {
const { actions, allowTextSelection, ariaLabel, ariaLabelledBy, ariaDescribedBy, autoSelect, calloutContentClassName, className, disableAutocomplete, disabled, filterByText, getUnselectableRanges, inputId, noItemsText, onToggle, selectedText, showTree, text, minCalloutWidth, required, containerClassName } = this.props;
return (React.createElement(Observer, { text: text, items: { observableValue: this.itemProvider, filter: this.onItemsChange }, selection: this.selection, selectedText: selectedText }, (props) => {
let placeholder = this.props.placeholder;
if (!allowTextSelection) {
if (props.selectedText) {
placeholder = props.selectedText;
}
else if (props.selection.length) {
const selectedIndex = props.selection[0].beginIndex;
if (selectedIndex > -1) {
placeholder = this.itemProvider.value[selectedIndex].text;
}
}
}
return (React.createElement(Dropdown, { ariaLabelledBy: ariaLabelledBy, ariaDescribedBy: ariaDescribedBy, actions: actions, ariaLabel: ariaLabel, autoSelect: autoSelect !== null && autoSelect !== void 0 ? autoSelect : false, containerClassName: containerClassName, calloutContentClassName: calloutContentClassName, className: css("bolt-editable-dropdown", (props.selection.length > 0 || !!props.selectedText) && "bolt-editable-dropdown-with-selection", className), columns: this.columns, disableAutocomplete: disableAutocomplete, disabled: disabled, getUnselectableRanges: getUnselectableRanges, filterByText: filterByText, inputId: inputId, items: this.itemProvider, noItemsText: noItemsText || Resources.NoItemsFound, onCollapse: this.onCollapse, onExpand: this.onExpand, onSelect: this.onSelect, onToggle: onToggle, placeholder: placeholder, ref: this.dropdown, renderCallout: this.renderCallout, renderExpandable: this.renderExpandable, renderItem: this.renderItem, selection: this.selection, showFilterBox: false, showTree: showTree, userFilteredItems: this.filteredItems, userFilteredItemsIndexMap: this.filteredIndexMap, minCalloutWidth: minCalloutWidth, required: required }));
}));
}
componentDidMount() {
if (this.props.filterThrottleWait) {
this.filterItems = this.timerManagement.debounce(this.filterItems, this.props.filterThrottleWait);
}
}
focus() {
if (this.dropdown.current) {
this.dropdown.current.focus();
}
}
selectIndex(index) {
if (index > -1) {
this.selection.select(index);
this.selectedItemInList = true;
}
}
focusItem(index) {
if (index !== undefined && index > -1 && this.isFocusable(index)) {
this.previousFocusedIndex = this.focusedIndex.value;
this.focusedIndex.value = index;
}
}
updateFilteredIndexMap(filteredIndexMap) {
// Try to maintain the focused index relative to what's being filtered.
const prevFilteredFocusedIndex = this.filteredIndexMap.value.indexOf(this.focusedIndex.value);
this.filteredIndexMap.value = filteredIndexMap;
let focusedIndex = filteredIndexMap[prevFilteredFocusedIndex];
if (this.selection.value.length &&
// Double check if only one value is selected.
this.selection.value[0].beginIndex === this.selection.value[0].endIndex &&
this.selection.selectable(this.selection.value[0].beginIndex) &&
this.filteredIndexMap.value.indexOf(this.selection.value[0].beginIndex) !== -1) {
// Try to put the focus on the selected item.
focusedIndex = this.selection.value[0].beginIndex;
}
else if (!focusedIndex ||
focusedIndex < 0 ||
!this.selection.selectable(focusedIndex) ||
this.filteredIndexMap.value.indexOf(focusedIndex) === -1) {
// If unable to maintain the focused index, focus the first selectable item.
focusedIndex = !this.props.showTree ? this.filteredIndexMap.value.find(item => this.selection.selectable(item)) : -1;
}
return focusedIndex;
}
focusNextItem() {
let nextIndex;
const filteredFocusedIndex = this.filteredIndexMap.value.indexOf(this.focusedIndex.value);
for (let i = filteredFocusedIndex + 1; i < this.filteredIndexMap.value.length; i++) {
const item = this.filteredIndexMap.value[i];
if (this.selection.selectable(item) && item > this.focusedIndex.value) {
nextIndex = this.filteredIndexMap.value[i];
break;
}
}
this.focusItem(nextIndex);
}
focusPreviousItem() {
let prevIndex;
const filteredFocusedIndex = this.filteredIndexMap.value.indexOf(this.focusedIndex.value);
for (let i = filteredFocusedIndex - 1; i >= 0; i--) {
if (this.selection.selectable(this.filteredIndexMap.value[i])) {
prevIndex = this.filteredIndexMap.value[i];
break;
}
}
this.focusItem(prevIndex);
}
getFocusedIndex() {
if (!this.props.showTree) {
return this.focusedIndex.value;
}
// get the correct focusedIndex by increasing the current index by the number of collapsed rows that come before it
let currentFocusedIndex = this.focusedIndex.value;
for (let i = 0; i <= Math.min(currentFocusedIndex, this.itemProvider.value.length - 1); i++) {
if (!isListBoxItemVisible(this.itemProvider.value[i]) && !this.isFiltering) {
currentFocusedIndex++;
}
}
return currentFocusedIndex;
}
isFocusable(index) {
if (!this.props.showTree) {
return true;
}
const visibleItems = this.itemProvider.value.filter(item => isListBoxItemVisible(item));
return this.isFiltering ? isListBoxItemVisible(this.itemProvider.value[index]) : index < visibleItems.length;
}
}
CustomEditableDropdown.defaultProps = {
allowClear: true,
autoAccept: true,
renderExpandable: DropdownExpandableTextField,
renderCallout: DropdownCallout,
renderItem: renderListBoxCell
};