UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

485 lines (484 loc) 26.3 kB
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 };