UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

480 lines (479 loc) 27.1 kB
import { __assign, __extends, __spreadArray } from "tslib"; 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"; var CustomEditableDropdown = /** @class */ (function (_super) { __extends(CustomEditableDropdown, _super); function CustomEditableDropdown(props) { var _this = _super.call(this, props) || this; _this.dropdown = React.createRef(); _this.listBox = React.createRef(); _this.filteredIndexMap = new ObservableValue([]); _this.filterMatches = []; _this.collapse = function () { if (_this.dropdown.current) { _this.dropdown.current.collapse(); } }; _this.expand = function () { if (_this.dropdown.current) { _this.dropdown.current.expand(); } }; _this.renderItem = function (rowIndex, columnIndex, tableColumn, tableItem) { return _this.wrapWithFocusedIndexObserver(rowIndex, columnIndex, tableColumn, tableItem, function (rowIndex, columnIndex, tableColumn, tableItem) { var item = tableItem; var column = tableColumn; var filterMatches = _this.filterMatches[rowIndex]; if (!item.render && filterMatches && filterMatches.length) { item.render = function (rowIndex, columnIndex, tableColumn, tableItem) { return renderHighlightedText(rowIndex, columnIndex, tableColumn, tableItem, filterMatches); }; } return _this.props.renderItem(rowIndex, columnIndex, column, item); }); }; _this.wrapWithFocusedIndexObserver = function (rowIndex, columnIndex, tableColumn, tableItem, render) { return (React.createElement(Observer, { focusedIndex: { observableValue: _this.focusedIndex, filter: function () { var itemIndex = _this.filteredIndexMap.value[rowIndex]; return itemIndex === _this.focusedIndex.value || itemIndex === _this.previousFocusedIndex; } }, key: "focused-observer-".concat(rowIndex, "-").concat(columnIndex) }, function () { var _a; var item = _this.filteredIndexMap.value[rowIndex] === _this.focusedIndex.value && ((_a = tableItem) === null || _a === void 0 ? void 0 : _a.type) !== ListBoxItemType.Loading ? __assign(__assign({}, tableItem), { className: css(tableItem.className, "bolt-editable-dropdown-focused-item") }) : __assign({}, tableItem); return render(rowIndex, columnIndex, tableColumn, item); })); }; _this.onCollapse = function () { var _a, _b; var _c = _this.props, allowFreeform = _c.allowFreeform, autoAccept = _c.autoAccept, onCollapse = _c.onCollapse, onValueChange = _c.onValueChange, showTree = _c.showTree, text = _c.text; if (onCollapse) { onCollapse(); } if (!_this.selectedItemInList) { var items = _this.itemProvider.value; // If the text matches an item in the list, select that item. var index = items.findIndex(function (item, index) { return _this.selection.selectable(index) && item.text === ObservableLike.getValue(text || ""); }); if (index > -1) { _this.selectIndex(index); } } var 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) { var 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) { var 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.onItemsChange = function (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 = function () { if (_this.props.selectedText) { var selectedText_1 = ObservableLike.getValue(_this.props.selectedText); if (selectedText_1) { var selectedIndex = _this.itemProvider.value.findIndex(function (item) { return item.text === selectedText_1; }); if (selectedIndex > -1) { _this.selection.select(selectedIndex); } } } }; _this.onSelect = function (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 = function (props) { return (React.createElement(Observer, { focusedIndex: _this.focusedIndex, selectedText: _this.props.selectedText, text: _this.props.text }, function (observerProps) { var _a, _b; var _c = _this.props, allowTextSelection = _c.allowTextSelection, inputId = _c.inputId; var selectedText = observerProps.selectedText, text = observerProps.text; var focusedIndex = _this.getFocusedIndex(); var activeId; if (focusedIndex > -1 && _this.itemProvider.value[focusedIndex]) { activeId = _this.itemProvider.value[focusedIndex].id; } var value = allowTextSelection && selectedText && !_this.isExpanded ? selectedText : text; var expandableProps = __assign(__assign({}, props), { ariaActiveDescendant: convertSpecialSymbols(activeId), editable: true, showPrefix: text ? false : true, blurDismiss: true, inputId: inputId, onChange: _this.onTextChange, onKeyDown: _this.onKeyDown, value: value }); return (_b = (_a = _this.props).renderExpandable) === null || _b === void 0 ? void 0 : _b.call(_a, expandableProps); })); }; _this.renderCallout = function (props) { var calloutProps = __assign(__assign({}, props), { focusOnMount: false, excludeTabStop: true, excludeFocusZone: true, ignoreMouseDown: true, listBoxRef: _this.listBox }); return _this.props.renderCallout(calloutProps); }; _this.onExpand = function () { if (_this.props.onExpand) { _this.props.onExpand(); } if (_this.props.filterItems) { var filterResult = _this.props.filterItems("", _this.itemProvider.value); var selectedIndex = _this.updateFilteredIndexMap(filterResult.filteredIndexMap); _this.filteredItems.value = filterResult.filteredItems; _this.focusItem(selectedIndex); } else { _this.filteredItems.value = _this.itemProvider.value; var focusedIndex = _this.updateFilteredIndexMap(_this.filteredItems.value.map(function (item, index) { return index; })); _this.focusItem(focusedIndex); } _this.isExpanded = true; }; _this.onTextChange = function (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 { var selectedIndex = _this.itemProvider.value.findIndex(function (item) { return 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 = function () { var items = _this.itemProvider.value; var text = ObservableLike.getValue(_this.props.text || ""); var filterResult; var firstMatchIndex; if (_this.props.filterItems) { filterResult = _this.props.filterItems(text, items); } else if (text) { if (_this.props.showTree) { var _a = filterTreeItems(items, text, [], _this.props.filterItem, _this.props.filterMatchedItem), result = _a[0], firstIndex = _a[1]; 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(function (item) { var _a; return equals((_a = item.text) !== null && _a !== void 0 ? _a : "", text, true); }); if (firstMatchIndex < 0) { firstMatchIndex = items.findIndex(function (item) { var _a; return startsWith((_a = item.text) !== null && _a !== void 0 ? _a : "", text, true); }); } } } else { filterResult = { filteredItems: items, filteredIndexMap: items.map(function (item, index) { return index; }), filterMatches: [] }; } _this.filterMatches = filterResult.filterMatches; var 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) var indexToSelect = firstMatchIndex && firstMatchIndex > -1 ? firstMatchIndex : selectedIndex; _this.focusItem(indexToSelect); }; _this.onKeyDown = function (ev) { var keyCode = ev.which; var 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)) { var 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) { var focusedIndex = _this.getFocusedIndex(); var 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) { var focusedIndex = _this.getFocusedIndex(); var 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; default: _this.expand(); } }; _this.selection = props.selection || new DropdownSelection(); _this.itemProvider = new EditableDropdownItemProvider(props.items, _this.selection); _this.filteredItems = new ObservableArray(__spreadArray([], _this.itemProvider.value, true)); _this.focusedIndex = new ObservableValue(-1); _this.previousFocusedIndex = -1; _this.timerManagement = new TimerManagement(); _this.filteredIndexMap.value = _this.itemProvider.value.map(function (item, index) { return index; }); _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(function (col) { return __assign(__assign({}, col), { renderCell: function (rowIndex, columnIndex, treeColumn, treeItem) { return _this.wrapWithFocusedIndexObserver(rowIndex, columnIndex, treeColumn, treeItem, col.renderCell); } }); }); } return _this; } CustomEditableDropdown.prototype.render = function () { var _this = this; var _a = this.props, actions = _a.actions, allowTextSelection = _a.allowTextSelection, ariaLabel = _a.ariaLabel, ariaLabelledBy = _a.ariaLabelledBy, autoSelect = _a.autoSelect, calloutContentClassName = _a.calloutContentClassName, className = _a.className, disabled = _a.disabled, filterByText = _a.filterByText, getUnselectableRanges = _a.getUnselectableRanges, inputId = _a.inputId, noItemsText = _a.noItemsText, onToggle = _a.onToggle, selectedText = _a.selectedText, showTree = _a.showTree, text = _a.text, minCalloutWidth = _a.minCalloutWidth, required = _a.required, containerClassName = _a.containerClassName; return (React.createElement(Observer, { text: text, items: { observableValue: this.itemProvider, filter: this.onItemsChange }, selection: this.selection, selectedText: selectedText }, function (props) { var placeholder = _this.props.placeholder; if (!allowTextSelection) { if (props.selectedText) { placeholder = props.selectedText; } else if (props.selection.length) { var selectedIndex = props.selection[0].beginIndex; if (selectedIndex > -1) { placeholder = _this.itemProvider.value[selectedIndex].text; } } } return (React.createElement(Dropdown, { ariaLabelledBy: ariaLabelledBy, 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, 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 })); })); }; CustomEditableDropdown.prototype.componentDidMount = function () { if (this.props.filterThrottleWait) { this.filterItems = this.timerManagement.debounce(this.filterItems, this.props.filterThrottleWait); } }; CustomEditableDropdown.prototype.focus = function () { if (this.dropdown.current) { this.dropdown.current.focus(); } }; CustomEditableDropdown.prototype.selectIndex = function (index) { if (index > -1) { this.selection.select(index); this.selectedItemInList = true; } }; CustomEditableDropdown.prototype.focusItem = function (index) { if (index !== undefined && index > -1 && this.isFocusable(index)) { this.previousFocusedIndex = this.focusedIndex.value; this.focusedIndex.value = index; } }; CustomEditableDropdown.prototype.updateFilteredIndexMap = function (filteredIndexMap) { var _this = this; // Try to maintain the focused index relative to what's being filtered. var prevFilteredFocusedIndex = this.filteredIndexMap.value.indexOf(this.focusedIndex.value); this.filteredIndexMap.value = filteredIndexMap; var focusedIndex = filteredIndexMap[prevFilteredFocusedIndex]; 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(function (item) { return _this.selection.selectable(item); }) : -1; } return focusedIndex; }; CustomEditableDropdown.prototype.focusNextItem = function () { var nextIndex; var filteredFocusedIndex = this.filteredIndexMap.value.indexOf(this.focusedIndex.value); for (var i = filteredFocusedIndex + 1; i < this.filteredIndexMap.value.length; i++) { if (this.selection.selectable(this.filteredIndexMap.value[i])) { nextIndex = this.filteredIndexMap.value[i]; break; } } this.focusItem(nextIndex); }; CustomEditableDropdown.prototype.focusPreviousItem = function () { var prevIndex; var filteredFocusedIndex = this.filteredIndexMap.value.indexOf(this.focusedIndex.value); for (var i = filteredFocusedIndex - 1; i >= 0; i--) { if (this.selection.selectable(this.filteredIndexMap.value[i])) { prevIndex = this.filteredIndexMap.value[i]; break; } } this.focusItem(prevIndex); }; CustomEditableDropdown.prototype.getFocusedIndex = function () { 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 var currentFocusedIndex = this.focusedIndex.value; for (var i = 0; i <= Math.min(currentFocusedIndex, this.itemProvider.value.length - 1); i++) { if (!isListBoxItemVisible(this.itemProvider.value[i])) { currentFocusedIndex++; } } return currentFocusedIndex; }; CustomEditableDropdown.prototype.isFocusable = function (index) { if (!this.props.showTree) { return true; } var visibleItems = this.itemProvider.value.filter(function (item) { return isListBoxItemVisible(item); }); return index < visibleItems.length; }; CustomEditableDropdown.defaultProps = { allowClear: true, autoAccept: true, renderExpandable: DropdownExpandableTextField, renderCallout: DropdownCallout, renderItem: renderListBoxCell }; return CustomEditableDropdown; }(React.Component)); export { CustomEditableDropdown };