UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

219 lines (218 loc) 12.9 kB
import "../../CommonImports"; import "../../Core/core.css"; import * as React from "react"; import * as Resources from '../../Resources.Label'; import { WrappingBehavior } from "./LabelGroup.Props"; import { LabelGroup } from "./LabelGroup"; import { css, getSafeId, KeyCode } from '../../Util'; import { ObservableValue, ObservableArray, ObservableLike } from '../../Core/Observable'; import { Autocomplete } from "../Autocomplete/Autocomplete"; import { Button } from '../../Button'; import { Icon } from '../../Icon'; import { Observer } from '../../Observer'; export class EditableLabelGroup extends React.Component { constructor(props) { super(props); this.autocompleteRef = React.createRef(); this.autocompleteValue = new ObservableValue(""); // Using a boolean instead of stopPropagation here so that people get the events up the DOM tree // if they have onMouseDown bound on wrappers etc. this.isMouseDownEventHandled = false; this.labelGroupRef = React.createRef(); this.renderTitleIconFlag = new ObservableValue(false); this.selectedLabelContents = new ObservableArray(); this.filterLabelModelAgainstContents = (testModel) => { return ObservableLike.getValue(this.props.labelProps).filter(y => y.content.toUpperCase() === testModel.content.toUpperCase()).length == 0; }; this.onAddButtonClicked = () => { this.setState({ isInEditMode: true }, () => this.autocompleteRef.current.focus()); }; this.onAutocompleteFocus = (event) => { this.selectedLabelContents.removeAll(); }; this.onAutocompleteValueChange = (newValue) => { this.autocompleteValue.value = newValue; }; this.onBlur = (event) => { /** * Delay the toggle off; _onFocus will clear the timeout */ this.focusOutTimeoutId = setTimeout(() => { this.selectedLabelContents.removeAll(); this.autocompleteValue.value = ""; this.setState({ isInEditMode: false }); this.props.onBlur && this.props.onBlur(); }, 0); /**/ }; this.onCheckForDuplicates = (newInput) => { return (ObservableLike.getValue(this.props.labelProps).filter((testModel) => testModel.content.trim().toUpperCase() === newInput.trim().toUpperCase()).length !== 0); }; // Only for handling keyboard focus this.onFocus = (event) => { clearTimeout(this.focusOutTimeoutId); if (!this.state.isInEditMode) { const callbackFunction = () => { this.renderTitleIconFlag.value = false; this.autocompleteRef.current.focus(); }; this.setState({ isInEditMode: true }, callbackFunction); } }; this.onGetSuggestions = (content) => { if (content === "" || !this.props.getSuggestedLabels) { return new Promise(resolve => resolve([])); } const labelModels = this.props.getSuggestedLabels(content); return Promise.resolve(labelModels).then((suggestions) => suggestions.filter(this.filterLabelModelAgainstContents)); }; this.onInnerMouseDOwn = (event) => { // Eat the event; allow focus to move and signal that the outer wrapper shouldn't handle it if (this.state.isInEditMode) { this.isMouseDownEventHandled = true; } }; this.onInputKeyDown = (event) => { const inputValue = this.autocompleteValue.value; if (event.which === KeyCode.backspace || event.which === KeyCode.delete) { if (this.props.labelProps.length > 0 && inputValue === "") { // Make sure we have an element to remove this.removeLabel(ObservableLike.getValue(this.props.labelProps)[this.props.labelProps.length - 1].content); event.preventDefault(); } } }; this.onInputSubmit = (labelModel) => { this.props.onLabelSubmit && this.props.onLabelSubmit(labelModel); this.autocompleteValue.value = ""; this.autocompleteRef.current.focus(); }; this.onLabelKeyDown = (event, model, index) => { this.props.onLabelKeyDown && this.props.onLabelKeyDown(event, model, index); if (event.isDefaultPrevented()) { return; } const selectedOrFocusedContents = new Set([...this.selectedLabelContents.value, model.content]); if (event.which === KeyCode.backspace || event.which === KeyCode.delete) { let nextLabelFocusIndex = -1; let minSelectedIndex = 99999; selectedOrFocusedContents.forEach((content) => { const index = ObservableLike.getValue(this.props.labelProps).findIndex((testModel) => testModel.content === content); minSelectedIndex = Math.min(minSelectedIndex, index); }); // If it's 0, we need to focus the first unselected item in the group if (minSelectedIndex === 0) { const selectedNextContent = new Set(selectedOrFocusedContents); nextLabelFocusIndex = ObservableLike.getValue(this.props.labelProps).findIndex((model) => !selectedNextContent.has(model.content)); } else { // Otherwise focus the one before the earliest deleted nextLabelFocusIndex = minSelectedIndex - 1; } // If we didn't select a new element, focus the autocomplete if (nextLabelFocusIndex === -1) { this.autocompleteRef.current.focus(); } else { // Otherwise set a timeout this.labelGroupRef.current.focusLabel(nextLabelFocusIndex); } selectedOrFocusedContents.forEach((content) => this.removeLabel(content)); this.selectedLabelContents.removeAll(); } else if (event.which === KeyCode.upArrow || event.which === KeyCode.downArrow || event.which === KeyCode.leftArrow || event.which === KeyCode.rightArrow) { this.selectedLabelContents.removeAll(); } }; this.onLabelMouseDown = (event, model, index) => { this.props.onLabelMouseDown && this.props.onLabelMouseDown(event, model, index); if (event.isDefaultPrevented() || !this.state.isInEditMode) { return; } const selectedContentSet = new Set(this.selectedLabelContents.value); // Handle multiselect if (event.ctrlKey) { if (selectedContentSet.has(model.content)) { this.selectedLabelContents.removeAll((value) => value === model.content); } else { this.selectedLabelContents.push(model.content); } } else { // Single Select this.selectedLabelContents.value = selectedContentSet.has(model.content) ? [] : [model.content]; } }; this.onOuterMouseDown = (event) => { // If the inner container handled it, reset and ignore the event if (this.isMouseDownEventHandled) { this.isMouseDownEventHandled = false; return; } // Only go into edit mode if we're not already there if (!this.state.isInEditMode) { // Don't deal with mouse-related focus, we'll manage it manually event.preventDefault(); // Focus move here is only good if we didn't eat the event in _innerMouseDown // meaning the click was truly on the OUTER containers const callbackFunction = () => { this.renderTitleIconFlag.value = false; this.autocompleteRef.current.focus(); }; this.setState({ isInEditMode: true }, callbackFunction); } }; this.onWrapperKeyDown = (event) => { if (event.which === KeyCode.escape) { this.selectedLabelContents.removeAll(); this.autocompleteValue.value = ""; this.setState({ isInEditMode: false }); this.props.onBlur && this.props.onBlur(); event.preventDefault(); } }; this.groupId = "editable-label-group-" + EditableLabelGroup.editableGroupCount; this.state = { isInEditMode: false }; } render() { const { addButtonText = Resources.AddLabelButtonText, className, customColors, disableColorPicker = false, disableMouseFocusOnLabels = false, duplicateLabelText, enableHoverStyles = true, labelProps, onLabelClick, shrinkToContents = false, title, useBlankZeroData = false, watermark = Resources.AddLabelWatermark } = this.props; const { isInEditMode } = this.state; const editClassName = isInEditMode ? "edit" : ""; const actualWrappingBehavior = isInEditMode ? WrappingBehavior.freeFlow : this.props.wrappingBehavior; let bodyContent; if (labelProps.length > 0 || isInEditMode) { bodyContent = (React.createElement("div", { className: css("bolt-labelgroup--editableWrapper", editClassName, !shrinkToContents && "default-padding"), onKeyDown: this.onWrapperKeyDown, onMouseDown: this.onInnerMouseDOwn }, React.createElement(LabelGroup, Object.assign({ ref: this.labelGroupRef }, this.props, { className: undefined, id: getSafeId(this.groupId), onLabelMouseDown: this.onLabelMouseDown, onLabelKeyDown: this.onLabelKeyDown, onLabelClick: onLabelClick, selectedLabelContents: this.selectedLabelContents, title: undefined, disableMouseFocusOnLabels: disableMouseFocusOnLabels, enableHoverStyles: enableHoverStyles, wrappingBehavior: actualWrappingBehavior }), isInEditMode && (React.createElement(Autocomplete, { ref: this.autocompleteRef, suggestionProvider: this.onGetSuggestions, value: this.autocompleteValue, placeholder: watermark, onKeyDown: this.onInputKeyDown, onSubmit: this.onInputSubmit, onFocus: this.onAutocompleteFocus, onInputValueChange: this.onAutocompleteValueChange, customColors: customColors, onCheckForDuplicateInParent: this.onCheckForDuplicates, onDuplicateInParentText: duplicateLabelText, disableColorPicker: disableColorPicker, ariaDescribedBy: getSafeId(this.groupId) }))))); } else if (!useBlankZeroData) { bodyContent = (React.createElement(Button, { className: "bolt-labelgroup-addButton", text: addButtonText, iconProps: { iconName: "Add" }, onClick: this.onAddButtonClicked })); } else { // If we have zero data and are using the blank one, render nothing at all return null; } return (React.createElement("div", { className: css(className, "bolt-labelgroup-editable flex-column", editClassName), onFocus: this.onFocus, onBlur: this.onBlur, onMouseDown: this.onOuterMouseDown, onMouseEnter: () => (this.renderTitleIconFlag.value = isInEditMode ? false : true), onMouseLeave: () => (this.renderTitleIconFlag.value = false) }, title && (React.createElement(Observer, { renderIconFlag: this.renderTitleIconFlag }, (observerProps) => (React.createElement("div", { className: "bolt-labelgroup-title-wrapper body-m flex-row" }, React.createElement("div", { className: "bolt-labelgroup-title-content" }, title), observerProps.renderIconFlag && React.createElement(Icon, { iconName: "Edit" }))))), bodyContent)); } componentWillUnmount() { // Niche race condition, but let's not setstate on an unmounted component clearTimeout(this.focusOutTimeoutId); } focus() { this.setState({ isInEditMode: true }, () => this.autocompleteRef.current && this.autocompleteRef.current.focus()); } removeLabel(content) { this.props.onLabelRemove && this.props.onLabelRemove(ObservableLike.getValue(this.props.labelProps).find((testModel) => testModel.content === content)); this.selectedLabelContents.removeAll(x => x === content); } } EditableLabelGroup.editableGroupCount = 0;