UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

499 lines (498 loc) 25.1 kB
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); }