UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

481 lines (480 loc) 28.5 kB
import "../../CommonImports"; import "../../Core/core.css"; import "./Filter.css"; import * as React from "react"; import { ObservableValue } from '../../Core/Observable'; import { TimerManagement } from '../../Core/TimerManagement'; import { announce } from '../../Core/Util/Accessibility'; import { ScreenSize } from '../../Core/Util/Screen'; import { format } from '../../Core/Util/String'; import { Button } from '../../Button'; import { ContentLocation } from '../../Callout'; import { Dropdown, DropdownCalloutComponent, DropdownExpandableButton, filterItems } from '../../Dropdown'; import { FocusZoneContext } from '../../FocusZone'; import { Icon } from '../../Icon'; import { renderListCell } from '../../List'; import { getListBoxItemsValue, ListBox, ListBoxItemType, wrapListBoxItems } from '../../ListBox'; import { Observer, SelectionObserver, UncheckedObserver } from '../../Observer'; import { Pill, PillSize } from '../../Pill'; import * as Resources from '../../Resources.Filter'; import { TextField } from '../../TextField'; import { css, KeyCode } from '../../Util'; import { updateFilterToSelection } from '../../Utilities/DropdownFilter'; import { DropdownMultiSelection } from '../../Utilities/DropdownSelection'; import { FILTER_CHANGE_EVENT } from '../../Utilities/Filter'; import { Location } from '../../Utilities/Position'; import { ScreenSizeObserver } from '../../Utilities/ScreenSize'; import { compareSelectionRanges, indexWithinRanges } from '../../Utilities/Selection'; import * as Utils_Accessibility from '../../Core/Util/Accessibility'; const FilterCalloutWidth = 320; const FilterItemPadding = 48; export class Filter extends React.Component { constructor(props) { super(props); this.dropdown = React.createRef(); this.dropdownCallout = React.createRef(); this.filterText = new ObservableValue(""); this.timerManagement = new TimerManagement(); this.collapse = () => { if (this.dropdown.current) { this.dropdown.current.collapse(); } }; this.expand = () => { if (this.dropdown.current) { this.dropdown.current.expand(); } }; this.onDoneClick = () => { const { filterStore } = this.props; if (filterStore.usesApplyMode()) { filterStore.applyChanges(); } this.collapse(); }; this.onApplyClick = () => { const { filterStore } = this.props; if (filterStore.usesApplyMode()) { filterStore.applyChanges(); } this.clearActiveFilter(); }; this.onExpandClick = () => { Utils_Accessibility.announce(this.props.title || Resources.FilterTitle); }; this.renderBeforeContent = () => { return (React.createElement(Observer, { activeFilter: this.activeFilter, filterText: this.filterText, bestHitItem: this.props.bestHitItem, userFilteredItems: this.props.userFilteredItems }, (props) => props.activeFilter ? props.activeFilter.renderBeforeContent ? props.activeFilter.renderBeforeContent(this.clearActiveFilter) : null : props.filterText ? this.renderFilteredView() : this.renderFilterItems())); }; this.renderFilteredView = () => { const items = []; if (this.props.bestHitItem && this.props.bestHitItem.value) { items.push({ id: "best-hit-header", text: Resources.BestHit, type: ListBoxItemType.Header, className: "bolt-filtered-header" }); items.push(this.props.bestHitItem.value); } if (this.props.userFilteredItems) { items.push(...getListBoxItemsValue(this.props.userFilteredItems)); } else { this.props.filterItems.forEach(filterItem => { let filteredItems = filterItems(getListBoxItemsValue(filterItem.items), this.filterText.value || "").filteredItems; // Remove all headers and dividers and selected items from results const selectedItems = this.getSelectedFilterItems(filterItem); filteredItems = filteredItems.filter(item => { return (item.type !== ListBoxItemType.Header && item.type !== ListBoxItemType.Divider && selectedItems.indexOf(item) === -1 && (!this.props.bestHitItem || this.props.bestHitItem.value !== item)); }); if (filteredItems.length) { items.push({ id: filterItem.id, text: filterItem.name, type: ListBoxItemType.Header, className: "bolt-filtered-header" }); items.push(...filteredItems.map(item => (Object.assign(Object.assign({}, item), { groupId: filterItem.id })))); } }); // Add keyword results last if (this.props.filterItems.some(filterItem => filterItem.id === "keyword-item")) { items.push(...getKeywordSearchResults(this.filterText.value)); } } const numberOfItems = items.filter(item => item.type !== ListBoxItemType.Header).length; if (numberOfItems > 0) { this.announceWithDebouncing(format(Resources.AnnounceFilterResultCount, numberOfItems)); } else { this.announceWithDebouncing(Resources.NoFilterResults); } return (React.createElement(ListBox, { items: items, onSelect: this.onFilteredItemSelect, onActivate: this.onFilteredItemSelect, excludeTabStop: true, focuszoneProps: null })); }; this.renderSelectedItems = (selection, items) => { return this.props.showFilterOnText !== false && this.filtered() ? Resources.FilterOn : Resources.Filter; }; this.renderFilterItems = () => { const { filterItems, filterStore } = this.props; return (React.createElement(FocusZoneContext.Consumer, null, zoneContext => filterItems.map((filterItem, index) => { const selectedItems = this.getSelectedFilterItems(filterItem); const selectedCount = selectedItems.length; const itemState = filterStore.getFilterItemState(filterItem.filterItemKey); const defaultItemState = filterStore.getDefaultState()[filterItem.filterItemKey]; const isDefault = filterStore.filterItemStatesAreEqual(filterItem.filterItemKey, itemState, defaultItemState); return (React.createElement("div", { className: "flex-row flex-center bolt-filter-item", key: filterItem.id, id: `bolt-filter-item-${filterItem.id}`, "data-focuszone": zoneContext.focuszoneId, tabIndex: -1, onClick: () => this.onFilterItemSelected(filterItem), onKeyDown: event => { if (!event.defaultPrevented && (event.which === KeyCode.enter || event.which === KeyCode.space || event.which === KeyCode.rightArrow)) { this.onFilterItemSelected(filterItem, event.currentTarget); event.preventDefault(); } } }, React.createElement("div", { className: css("flex-row flex-center flex-grow bolt-filter-label", itemState && itemState.value && itemState.value.length !== 0 && "bolt-filter-label-selected"), style: { width: this.props.width - FilterItemPadding } }, React.createElement("span", { className: css(isDefault && "primary-text", !isDefault && "font-weight-semibold") }, filterItem.name), selectedCount > 1 && (React.createElement(Pill, { className: "bolt-filter-selection-pill", excludeFocusZone: true, size: PillSize.compact }, selectedCount)), React.createElement("div", { className: "flex-grow flex-row bolt-filter-selected-item-container" }, filterItem.renderSelectedItems ? filterItem.renderSelectedItems(selectedItems) : renderSelectedFilterItems(selectedItems))), React.createElement(Icon, { iconName: "ChevronRight" }))); }))); }; this.onFilteredItemSelect = (event, item) => { if (item.groupId) { const { filterStore } = this.props; const group = this.props.filterItems.find(f => f.id === item.groupId); if (group) { const key = group.filterItemKey; const itemState = filterStore.getFilterItemState(key); const newValue = item.data !== undefined ? item.data : item.id; if (key === "keyword") { filterStore.setFilterItemState(key, { value: item.id }); } else if (itemState && itemState.value && Array.isArray(itemState.value) && this.selection.multiSelect) { filterStore.setFilterItemState(item.groupId, { value: [...itemState.value, newValue] }); } else { filterStore.setFilterItemState(key, { value: [newValue] }); } } } this.filterText.value = ""; if (this.dropdownCallout.current) { this.dropdownCallout.current.focus(); } }; this.onFilterChanged = (changedState) => { const filterState = this.props.filterStore.getState(); const newSelection = new DropdownMultiSelection(); const items = getListBoxItemsValue(this.wrappedItems || this.props.items); for (const key in filterState) { const itemState = filterState[key]; if (itemState && itemState.value) { if (key === "keyword") { const index = items.findIndex(item => item.id === itemState.value); if (index > -1) { newSelection.select(index, 1, true); } } else { for (let i = 0; i < itemState.value.length; i++) { const index = items.findIndex(item => item.id === itemState.value[i] || item.data === itemState.value[i]); if (index > -1) { newSelection.select(index, 1, true); } } } } } const selectionDifference = compareSelectionRanges(this.selection.value, newSelection.value); if (selectionDifference.length) { this.selection.value = newSelection.value; } }; this.onSelectionChanged = (values) => { const items = getListBoxItemsValue(this.wrappedItems || this.props.items); if (this.props.filterStore && this.activeFilter.value) { const activeFilterSelection = new DropdownMultiSelection(); let startingIndex = 0; for (let i = 0; this.props.filterItems[i].id !== this.activeFilter.value.id; i++) { startingIndex += this.props.filterItems[i].items.length; } values.forEach(value => { for (let i = value.beginIndex; i <= value.endIndex; i++) { if (i >= startingIndex && i < startingIndex + this.activeFilter.value.items.length) { activeFilterSelection.select(i, 1, true); } } }); if (this.activeFilter.value.filterItemKey === "keyword" && activeFilterSelection.value.length === 0) { // Don't clear keyword filter when selection gets empty. // This happens when user edits current text and we don't want to reset his editing text to "" return true; } updateFilterToSelection(activeFilterSelection.value, items, this.props.filterStore, this.activeFilter.value.filterItemKey); } return true; }; this.onResetClick = () => { if (this.dropdownCallout.current) { this.dropdownCallout.current.focus(); } this.props.filterStore.reset(); }; this.onResetFilterItemClick = (key) => { if (this.dropdownCallout.current) { this.dropdownCallout.current.focus(); } this.props.filterStore.resetFilterItemState(key); }; this.onFilterItemSelected = (filterItem, triggerElement) => { if (this.dropdownCallout.current) { this.dropdownCallout.current.focus(); } if (!this.props.activeFilter) { this.activeFilter.value = filterItem; announce(format(Resources.FilterSelected, filterItem.name)); this.activeFilterReturnElementId = triggerElement === null || triggerElement === void 0 ? void 0 : triggerElement.id; } if (this.props.onActiveFilterChanged) { this.props.onActiveFilterChanged(filterItem); } }; this.getOnFilterTextChanged = (props) => { return (e, newValue) => { this.filterText.value = newValue; if (this.activeFilter.value && props.onFilterTextChanged) { props.onFilterTextChanged(e, newValue); this.dropdownOnFilterTextChanged = props.onFilterTextChanged; } if (this.props.onFilterTextChanged) { this.props.onFilterTextChanged(e, newValue); } }; }; this.getFilterStartingIndex = (filter) => { if (filter) { const filterIndex = this.props.filterItems.indexOf(filter); let itemCount = 0; for (let i = 0; i < filterIndex; i++) { itemCount += this.props.filterItems[i].items.length; } return itemCount; } return -1; }; this.getSelectedFilterItems = (filter) => { const selectedItems = []; const items = getListBoxItemsValue(this.wrappedItems || this.props.items); const startingIndex = this.getFilterStartingIndex(filter); for (let i = startingIndex; i < startingIndex + filter.items.length; i++) { if (indexWithinRanges(i, this.selection.value)) { selectedItems.push(items[i]); } } return selectedItems; }; this.clearFilterSelection = () => { if (this.activeFilter.value) { this.props.filterStore.setFilterItemState(this.activeFilter.value.filterItemKey, { value: null }); this.activeFilter.value = null; } }; this.clearActiveFilter = (focusOnDropdown) => { // Focus on base filter dropdown, when filter is closed to avoid losing of keyboard focus for tabbing if (focusOnDropdown) { this.focus(); } else if (this.dropdownCallout.current) { this.dropdownCallout.current.focus(); // a11y: Focus back the filter item we selected. // We need to give time to re-render so filter items are visible again. requestAnimationFrame(() => { if (this.activeFilterReturnElementId) { const returnElement = document.getElementById(this.activeFilterReturnElementId); returnElement === null || returnElement === void 0 ? void 0 : returnElement.focus(); this.activeFilterReturnElementId = undefined; } }); } if (!this.props.activeFilter) { this.activeFilter.value = null; } if (this.props.onActiveFilterChanged) { this.props.onActiveFilterChanged(null); } this.filterText.value = ""; if (this.dropdownOnFilterTextChanged) { this.dropdownOnFilterTextChanged(null, ""); } }; this.filtered = () => { const filterState = this.props.filterStore.getAppliedState(); for (const key in filterState) { if (filterState[key].value && (!Array.isArray(filterState[key].value) || filterState[key].value.length > 0)) { return true; } } return false; }; this.announceWithDebouncing = (message) => { Utils_Accessibility.announce(message, false, 300); }; this.state = {}; this.selection = props.selection || new DropdownMultiSelection(); this.wrappedItems = wrapListBoxItems(props.items); this.activeFilter = props.activeFilter || new ObservableValue(null); } focus() { if (this.dropdown.current) { this.dropdown.current.focus(); } } componentDidMount() { this.props.filterStore && this.props.filterStore.subscribe(this.onFilterChanged, FILTER_CHANGE_EVENT); this.onFilterChanged(this.props.filterStore.getState()); this.announceWithDebouncing = this.timerManagement.debounce(this.announceWithDebouncing, 300); } componentWillUnmount() { this.props.filterStore && this.props.filterStore.unsubscribe(this.onFilterChanged, FILTER_CHANGE_EVENT); } render() { const { filterStore, showActiveFilterResetButton, showFilterOnText } = this.props; const filterOn = showFilterOnText !== false && this.filtered(); return (React.createElement(UncheckedObserver, { activeFilter: this.activeFilter, filter: filterStore }, React.createElement(SelectionObserver, { selection: this.selection, onSelectionChanged: this.onSelectionChanged }, () => { const activeFilter = this.activeFilter.value; const actions = []; let activeFilterSelectionCount = 0; const resetAction = { className: "bolt-filter-reset-button", text: !activeFilter ? Resources.ResetAll : Resources.Reset, subtle: false, onClick: !activeFilter ? this.onResetClick : () => this.onResetFilterItemClick(activeFilter.filterItemKey), id: "filter-reset-button" }; if (activeFilter) { const filterItemState = filterStore.getFilterItemState(activeFilter.filterItemKey); activeFilterSelectionCount = this.getSelectedFilterItems(activeFilter).length; if (showActiveFilterResetButton) { if (filterStore.hasChangesToReset()) { actions.push(resetAction); } } else { actions.push({ text: Resources.Clear, disabled: !(filterItemState && filterItemState.value), subtle: false, onClick: this.clearFilterSelection, id: "filter-clear-button" }); } if (filterStore.usesApplyMode()) { actions.push({ className: css(!showActiveFilterResetButton && "bolt-filter-apply-button"), disabled: !filterStore.hasChangesToApply(), text: Resources.Apply, primary: true, subtle: false, onClick: this.onApplyClick, id: "filter-apply-button" }); } } else { if (filterStore.hasChangesToReset()) { actions.push(resetAction); } if (filterStore.usesApplyMode()) { actions.push({ disabled: !filterStore.hasChangesToApply(), text: Resources.Apply, primary: true, subtle: false, onClick: this.onDoneClick, id: "filter-done-button" }); } } return (React.createElement(ScreenSizeObserver, null, (screenSizeProps) => { const fullscreen = screenSizeProps.screenSize === ScreenSize.xsmall; return (React.createElement(Dropdown, { actions: actions, calloutContentClassName: css("bolt-filter-callout", activeFilter && "bolt-active-filter", fullscreen && "absolute-fill"), className: css(this.props.className, "bolt-filter", filterOn && "bolt-filter-on"), dismissOnSelect: false, enforceSingleSelect: activeFilter === null || activeFilter === void 0 ? void 0 : activeFilter.enforceSingleSelect, filterByText: this.props.filterByText, onExpand: this.onExpandClick, onCollapse: () => this.clearActiveFilter(true), placeholder: filterOn ? Resources.FilterOn : Resources.Filter, ref: this.dropdown, items: this.props.items, userFilteredItems: activeFilter ? (this.props.userFilteredItems ? this.props.userFilteredItems : activeFilter.items) : [], renderExpandable: props => (React.createElement(DropdownExpandableButton, Object.assign({}, props, { iconProps: { iconName: "Filter" }, hideDropdownIcon: true, renderSelectedItems: this.renderSelectedItems }))), renderCallout: props => (React.createElement(DropdownCalloutComponent, Object.assign({}, props, { ariaLabel: this.props.title || Resources.FilterTitle, anchorElement: fullscreen ? undefined : props.anchorElement, anchorOrigin: { horizontal: Location.start, vertical: Location.end }, blurDismiss: !fullscreen, containerClassName: "bolt-filter-listbox-container", contentLocation: fullscreen ? ContentLocation.Center : undefined, dropdownOrigin: { horizontal: Location.start, vertical: Location.start }, enforceSingleSelect: activeFilter === null || activeFilter === void 0 ? void 0 : activeFilter.enforceSingleSelect, filterText: this.filterText, ignoreMouseDown: true, key: activeFilter === null || activeFilter === void 0 ? void 0 : activeFilter.id, onFilterTextChanged: this.getOnFilterTextChanged(props), onFilterKeyDown: event => { if (event && !event.defaultPrevented && event.which === KeyCode.enter && this.props.filterItems.some(filterItem => filterItem.id === "keyword-item") && this.filterText.value.length > 0) { filterStore.setFilterItemState("keyword", { value: this.filterText.value }); this.filterText.value = ""; } }, showCloseButton: true, title: activeFilter ? (React.createElement("div", { className: "flex-row flex-center bolt-filter-title-container" }, !this.props.hideBackButton && (React.createElement(Button, { ariaLabel: Resources.Back, subtle: true, className: "bolt-dropdown-header-button bolt-filter-back-button", iconProps: { iconName: "Back" }, onClick: () => this.clearActiveFilter(), tabIndex: -1 })), activeFilter.title || activeFilter.name, activeFilterSelectionCount > 1 && (React.createElement(Pill, { className: "bolt-filter-selection-pill", size: PillSize.compact }, activeFilterSelectionCount)))) : (this.props.title || Resources.FilterTitle), renderBeforeContent: this.renderBeforeContent, ref: this.dropdownCallout }))), selection: this.selection, showFilterBox: activeFilter ? !(activeFilter.showFilterBox === false) : true, width: fullscreen ? -1 : this.props.width })); })); }))); } } Filter.defaultProps = { width: FilterCalloutWidth }; export function getKeywordFilterItem(filter, throttle, items = []) { const filterItemKey = "keyword"; const timerManagement = new TimerManagement(); const updateFilterState = (newValue) => { filter.setFilterItemState(filterItemKey, { value: newValue }); if (!filter.usesApplyMode()) { filter.applyChanges(); } }; const throttledUpdateFilterState = throttle ? timerManagement.debounce(updateFilterState, throttle, { leading: false, trailing: true }) : updateFilterState; return { items, renderBeforeContent: (onEditingComplete) => { const value = new ObservableValue(""); const filterState = filter.getFilterItemState(filterItemKey); value.value = filterState && filterState.value ? filterState.value : ""; return (React.createElement(Observer, { filterExpression: { observableValue: filter, filter: () => { const filterState = filter.getFilterItemState(filterItemKey); value.value = filterState && filterState.value ? filterState.value : ""; } } }, () => { return (React.createElement(TextField, { ariaLabel: Resources.Keyword, placeholder: Resources.SearchKeyword, autoFocus: true, className: "bolt-filter-keyword-item", value: value, onChange: (e, newValue) => { value.value = newValue; throttledUpdateFilterState(newValue); }, onKeyDown: event => { if (event.which === KeyCode.enter) { onEditingComplete(); event.preventDefault(); } } })); })); }, renderSelectedItems: () => { const filterState = filter.getFilterItemState(filterItemKey); return filterState && filterState.value ? React.createElement("span", null, `"${filterState.value}"`) : null; }, enforceSingleSelect: true, id: "keyword-item", filterItemKey: filterItemKey, name: Resources.Keyword, showFilterBox: false }; } export function getKeywordSearchResults(filterText) { const items = []; items.push({ id: "keyword-header", text: Resources.Keyword, type: ListBoxItemType.Header, className: "bolt-filtered-header" }); items.push({ id: filterText, text: format(Resources.KeywordSearchResult, filterText), groupId: "keyword-item" }); return items; } export function renderSelectedFilterItems(selectedItems) { const hasIcons = selectedItems.some(selectedItem => !!selectedItem.iconProps); return (React.createElement(React.Fragment, null, selectedItems.map((selectedItem, index) => { return (React.createElement("div", { className: css("bolt-filter-selected-item flex-row", !hasIcons && "bolt-filter-selected-text-item"), key: selectedItem.id }, renderListCell(selectedItem), !hasIcons && index !== selectedItems.length - 1 && React.createElement("span", null, ", "))); }))); }