UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

230 lines (229 loc) 12.8 kB
import "../../CommonImports"; import "../../Core/core.css"; import "./Autocomplete.css"; import * as React from "react"; import { ObservableValue, ObservableLike } from '../../Core/Observable'; import { Callout } from '../../Callout'; import { Label } from "../Label/Label"; import { KeyCode, getSafeId } from '../../Util'; import { Suggestions } from "./Suggestions"; import { Location } from '../../Utilities/Position'; import { FocusZoneContext } from '../../FocusZone'; export class Autocomplete extends React.Component { constructor(props) { super(props); this.currentSelectedColorIndex = new ObservableValue(0); this.inputRef = React.createRef(); this.onBlur = (event) => { this.setState({ displayPlaceholderText: false }); }; this.onCheckForExactSuggestionMatches = (suggestions) => { return suggestions.findIndex((testSuggestion) => testSuggestion.content === ObservableLike.getValue(this.props.value)) != -1; }; this.onFocus = (event) => { this.props.onFocus && this.props.onFocus(event); this.setState({ displayPlaceholderText: true }); }; this.onInputChange = (event) => { this.props.onInputValueChange(event.currentTarget.value); }; this.onKeyDown = (event) => { this.props.onKeyDown && this.props.onKeyDown(event); if (event.isDefaultPrevented()) { return; } const currentInputValue = ObservableLike.getValue(this.props.value); const isCurrentValueInParentGroup = this.props.onCheckForDuplicateInParent(currentInputValue); const currentSuggestion = this.state.currentSuggestions[this.state.currentSuggestionIndex]; // Handle selection in the callout, and tab-complete if (this.state.displayCallout) { if (event.which === KeyCode.tab && currentSuggestion) { this.props.onInputValueChange(currentSuggestion.content); event.preventDefault(); } else if (event.which === KeyCode.downArrow) { // Determine if the New Label row is currently selectable and adjust upper bound accordingly // Then wraparound const upperBound = isCurrentValueInParentGroup ? this.state.currentSuggestions.length - 1 : this.state.currentSuggestions.length; const targetIndex = this.state.currentSuggestionIndex + 1; this.setState({ currentSuggestionIndex: targetIndex > upperBound ? 0 : targetIndex }); event.preventDefault(); } else if (event.which === KeyCode.upArrow) { // Determine if the New Label row is currently selectable and adjust wrap-around value accordingly // Then wraparound const finalIndex = isCurrentValueInParentGroup ? this.state.currentSuggestions.length - 1 : this.state.currentSuggestions.length; const targetIndex = this.state.currentSuggestionIndex - 1; this.setState({ currentSuggestionIndex: targetIndex < 0 ? finalIndex : targetIndex }); event.preventDefault(); } else if (isCurrentValueInParentGroup) { // Short circuit interaction with New Label row if we're rendering the "already in the group" row return; } else if (event.which === KeyCode.rightArrow && this.isNewRowSelected()) { this.currentSelectedColorIndex.value = Math.min(this.currentSelectedColorIndex.value + 1, Autocomplete.DEFAULT_COLORS.length - 1); event.preventDefault(); } else if (event.which === KeyCode.leftArrow && this.isNewRowSelected()) { this.currentSelectedColorIndex.value = Math.max(this.currentSelectedColorIndex.value - 1, 0); event.preventDefault(); } } // Handle submittal const trimmedInputValue = currentInputValue.trim(); const canBeSubmitted = trimmedInputValue !== "" && this.props.onSubmit && !isCurrentValueInParentGroup; if (event.which === KeyCode.enter && canBeSubmitted) { if (currentSuggestion) { this.submit(currentSuggestion); } else { this.submit({ content: trimmedInputValue, color: !this.props.disableColorPicker && Autocomplete.DEFAULT_COLORS[this.currentSelectedColorIndex.value] }); } } else if (event.which === KeyCode.comma) { if (canBeSubmitted) { this.submit({ content: trimmedInputValue, color: !this.props.disableColorPicker && Autocomplete.DEFAULT_COLORS[this.currentSelectedColorIndex.value] }); } event.preventDefault(); } }; this.onPipClick = (event, color, index) => { this.props.onSubmit && this.props.onSubmit({ content: ObservableLike.getValue(this.props.value), color: color }); }; this.onNewLabelClick = (event) => { this.props.onSubmit && this.props.onSubmit({ content: ObservableLike.getValue(this.props.value) }); }; this.onSuggestionClick = (event, labelModel) => { this.submit(labelModel); }; this.onValueChange = (newValue) => { // ALWAYS reset the loading delay clearTimeout(this.loadingDelayTimeoutId); if (newValue.trim() === "") { // Clear suggestions and hide callout if we erase the whole thing this.setState({ displayCallout: false, currentSuggestions: [] }); } else { // Handle Loading timeout logic w/ 200ms delay if the callout isn't up // Otherwise change state immediately, unless promise resolves and clears the timeout const setStateFunction = () => this.setState({ isLoading: true, displayCallout: true }); if (this.state.displayCallout) { setStateFunction(); } else { this.loadingDelayTimeoutId = setTimeout(() => { setStateFunction(); }, 200); } this.props.suggestionProvider(newValue).then((suggestions) => { // Ensure these suggestions aren't outdated if (newValue !== ObservableLike.getValue(this.props.value)) { return; } // Stop displaying loading timeout clearTimeout(this.loadingDelayTimeoutId); this.setState({ currentSuggestions: suggestions, isLoading: false, displayCallout: true, displayTypeAhead: true }); }); } // Never show typeahead when the value changes; wait for suggestions to load // Clear suggested item; they need to tab back into the thing this.setState({ displayTypeAhead: false, currentSuggestionIndex: 0, currentSuggestions: [] }); }; this.state = { currentSuggestionIndex: 0, currentSuggestions: [], displayCallout: false, displayPlaceholderText: false, displayTypeAhead: false, isLoading: false }; } render() { const { ariaDescribedBy, className, customColors, disableColorPicker = false, onCheckForDuplicateInParent, onDuplicateInParentText, placeholder, value } = this.props; const { currentSuggestions, currentSuggestionIndex, displayCallout, isLoading, displayTypeAhead, displayPlaceholderText } = this.state; const inputValue = ObservableLike.getValue(value); const suggestionValue = currentSuggestions[currentSuggestionIndex] ? currentSuggestions[currentSuggestionIndex].content : ""; const typeAhead = this.getTypeAheadValue(inputValue, suggestionValue, displayTypeAhead); const renderColors = customColors || Autocomplete.DEFAULT_COLORS; const selectedSuggestion = this.state.currentSuggestions[this.state.currentSuggestionIndex]; return (React.createElement(FocusZoneContext.Consumer, null, (zoneContext) => (React.createElement("div", { "aria-expanded": displayCallout, "aria-haspopup": "listbox", "aria-owns": getSafeId("autocomplete-listbox"), className: "bolt-label-autocomplete", role: "combobox" }, React.createElement("input", { "aria-activedescendant": displayCallout && selectedSuggestion && selectedSuggestion.content, "aria-autocomplete": "both", "aria-controls": getSafeId("autocomplete-listbox"), "aria-describedby": getSafeId(ariaDescribedBy), className: className, "data-focuszone": zoneContext.focuszoneId, onBlur: this.onBlur, onChange: this.onInputChange, onFocus: this.onFocus, onKeyDown: this.onKeyDown, placeholder: displayPlaceholderText ? placeholder : undefined, ref: this.inputRef, tabIndex: 0, type: "text", value: inputValue }), React.createElement("input", { type: "text", className: "suggestion", value: typeAhead, disabled: true }), displayCallout && (React.createElement(Callout, { anchorElement: this.inputRef.current, anchorOffset: { horizontal: 0, vertical: 10 }, anchorOrigin: { horizontal: Location.start, vertical: Location.end }, calloutOrigin: { horizontal: Location.start, vertical: Location.start }, className: "bolt-label-suggestions-callout", contentShadow: true }, React.createElement(Suggestions, { currentSelectedColorIndex: this.currentSelectedColorIndex, currentSelectedIndex: currentSuggestionIndex, disableColorPicker: disableColorPicker, inputAlreadyInGroupText: onDuplicateInParentText, isCurrentInputAlreadyInGroup: onCheckForDuplicateInParent(inputValue), isLoading: isLoading, onCheckForExactMatch: this.onCheckForExactSuggestionMatches, onColorPipClick: this.onPipClick, onNewLabelClick: this.onNewLabelClick, onSuggestionClick: this.onSuggestionClick, suggestedItems: currentSuggestions, swatchPickerColors: renderColors }))))))); } // Dealing with observable lifecycles componentDidMount() { ObservableLike.subscribe(this.props.value, this.onValueChange); } componentWillUnmount() { // Don't call setState if we're unmounted clearTimeout(this.loadingDelayTimeoutId); ObservableLike.unsubscribe(this.props.value, this.onValueChange); } UNSAFE_componentWillReceiveProps(nextProps) { ObservableLike.unsubscribe(this.props.value, this.onValueChange); ObservableLike.subscribe(nextProps.value, this.onValueChange); } focus() { this.inputRef.current.focus(); } getTypeAheadValue(inputValue, suggestionValue, displayTypeAhead) { if (!displayTypeAhead) { return ""; } return inputValue.concat(suggestionValue.substr(inputValue.length)); } isNewRowSelected() { return this.state.currentSuggestionIndex === this.state.currentSuggestions.length && ObservableLike.getValue(this.props.value) != ""; } submit(labelModel) { this.props.onSubmit && this.props.onSubmit(labelModel); clearTimeout(this.loadingDelayTimeoutId); this.currentSelectedColorIndex.value = 0; this.setState({ currentSuggestions: [] }); } } Autocomplete.DEFAULT_COLORS = [ Label.DEFAULT_COLOR, { red: 255, green: 255, blue: 0 }, { red: 235, green: 257, blue: 128 }, { red: 229, green: 150, blue: 182 }, { red: 191, green: 165, blue: 221 }, { red: 168, green: 191, blue: 243 }, { red: 153, green: 207, blue: 198 } // Green ];