azure-devops-ui
Version:
React components for building web UI in Azure DevOps
230 lines (229 loc) • 12.8 kB
JavaScript
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
];