azure-devops-ui
Version:
React components for building web UI in Azure DevOps
316 lines (315 loc) • 20.6 kB
JavaScript
import "../../CommonImports";
import "../../Core/core.css";
import "./TagPicker.css";
import * as React from "react";
import { ObservableArray, ObservableLike, ObservableValue } from '../../Core/Observable';
import { TimerManagement } from '../../Core/TimerManagement';
import { Callout } from '../../Callout';
import { FocusWithin } from '../../FocusWithin';
import { FormItemContext } from '../../FormItem';
import { Icon, IconSize } from '../../Icon';
import { Measure } from '../../Measure';
import { Observer } from '../../Observer';
import { Pill } from '../../Pill';
import { SuggestionsList } from '../../SuggestionsList';
import { css, getSafeId, KeyCode } from '../../Util';
import { Location } from '../../Utilities/Position';
export class TagPicker extends React.Component {
constructor(props) {
var _a;
super(props);
this.inputElement = React.createRef();
this.outerElement = React.createRef();
this.textValue = new ObservableValue("");
this.suggestionsVisible = new ObservableValue(false);
this.selectedIndex = new ObservableValue(-1);
this.selectableTags = new ObservableArray([]);
// TODO: FF cleanup target (VisualStudio.Services.WebPlatform.TagPickerAccessibilityFixEnabled)
this.isTagPickerAccessibilityFixEnabled = false;
this.clearTagPicker = () => {
this.suggestionsVisible.value = false;
this.textValue.value = "";
this.selectedIndex.value = -1;
};
this.suggestionsLoaded = () => {
if (this.selectedIndex.value === -1 &&
!ObservableLike.getValue(this.props.suggestionsLoading) &&
this.inputElement.current &&
this.inputElement.current.value !== "") {
// Selected index set after list is updated for screen readers.
if (this.updateIndexTimer) {
window.cancelAnimationFrame(this.updateIndexTimer);
}
this.updateIndexTimer = window.requestAnimationFrame(() => {
this.selectedIndex.value = 0;
});
}
return true;
};
this.createGenericItem = (searchString, list) => {
if (searchString.trim().length === 0) {
return undefined;
}
const defaultItem = this.props.createDefaultItem && this.props.createDefaultItem(searchString);
if (defaultItem) {
if (list.some(selectedTag => this.props.areTagsEqual(defaultItem, selectedTag))) {
return undefined;
}
}
return defaultItem;
};
this.onBlur = () => {
if (this.props.shouldBlurClear && !this.props.shouldBlurClear()) {
return;
}
const previousValue = this.textValue.value;
this.textValue.value = "";
this.selectableTags.value = [];
this.props.onBlur && this.props.onBlur(previousValue);
this.onSuggestionsDismiss();
};
this.onFocus = () => {
this.props.onFocus && this.props.onFocus();
};
this.onOuterKeyDown = (ev) => {
const keyCode = ev.which;
switch (keyCode) {
case KeyCode.delete:
case KeyCode.backspace:
if (!ev.isDefaultPrevented() && this.selectableTags.value.length > 0) {
this.props.onTagsRemoved && this.props.onTagsRemoved(this.selectableTags.value);
this.selectableTags.value = [];
this.focusInput();
ev.preventDefault();
}
break;
}
};
this.onKeyDown = (ev) => {
const keyCode = ev.which;
const input = this.inputElement.current;
const suggestionsVisible = this.suggestionsVisible.value;
const suggestions = ObservableLike.getValue(this.props.suggestions);
switch (keyCode) {
case KeyCode.escape:
this.onSuggestionsDismiss();
ev.preventDefault();
break;
case KeyCode.tab:
case KeyCode.enter:
if (!ev.shiftKey) {
if (suggestionsVisible) {
this.completeSuggestion();
ev.preventDefault();
}
else if (this.props.createDefaultItem) {
const itemToAdd = this.createGenericItem(this.textValue.value, ObservableLike.getValue(this.props.selectedTags));
if (itemToAdd) {
this.addItem(itemToAdd);
ev.preventDefault();
}
}
}
break;
case KeyCode.upArrow:
if (suggestionsVisible) {
this.selectedIndex.value = Math.max(0, this.selectedIndex.value - 1);
ev.preventDefault();
}
break;
case KeyCode.downArrow:
if (suggestionsVisible) {
this.selectedIndex.value = Math.min(suggestions.length - 1, this.selectedIndex.value + 1);
ev.preventDefault();
}
else if (this.textValue.value === "") {
this.props.onEmptyInputFocus && this.props.onEmptyInputFocus();
this.suggestionsVisible.value = true;
ev.preventDefault();
}
else {
this.suggestionsVisible.value = true;
ev.preventDefault();
}
break;
case KeyCode.rightArrow:
if (suggestionsVisible &&
suggestions &&
suggestions[this.selectedIndex.value] &&
this.props.onSuggestionExpanded &&
input &&
input.value.length === input.selectionEnd) {
this.props.onSuggestionExpanded(suggestions[this.selectedIndex.value]);
ev.preventDefault();
}
break;
}
};
this.onInputClick = (event) => {
if (this.props.onEmptyInputFocus && this.textValue.value === "") {
this.props.onEmptyInputFocus();
}
this.suggestionsVisible.value = true;
event && event.preventDefault();
};
this.onInputChange = (e) => {
this.textValue.value = e.target.value;
this.selectedIndex.value = -1;
this.onResolveInput(e);
e.persist();
e.preventDefault();
};
this.onResolveInput = (e) => {
const splitText = this.props.deliminator && this.textValue.value.split(this.props.deliminator);
if (this.props.onDelimitedSearch && this.props.deliminator && splitText && splitText.length > 1) {
this.props.onDelimitedSearch(this.textValue.value.split(this.props.deliminator).filter(identity => identity !== ""));
}
else {
this.props.onSearchChanged(e.target.value);
this.suggestionsVisible.value = true;
}
};
this.onTagClicked = (event, suggestion) => {
if (!event || !event.isDefaultPrevented()) {
const indexOf = this.indexOfTag(suggestion, this.selectableTags.value);
if (!this.props.onTagsRemoved) {
return;
}
if (indexOf < 0) {
this.selectableTags.push(suggestion);
}
else {
this.selectableTags.splice(indexOf, 1);
}
event && event.preventDefault();
}
};
this.onTagRemoved = (suggestion) => {
const indexOf = this.indexOfTag(suggestion, this.selectableTags.value);
// If this is selected, remove the selection before removing the tag from the list
if (indexOf >= 0) {
this.selectableTags.splice(indexOf, 1);
}
this.props.onTagRemoved(suggestion);
this.inputElement.current && this.inputElement.current.focus();
};
this.onTagPickerSizeChanged = (newWidth, newHeight) => {
this.setState({ width: newWidth });
};
this.completeSuggestion = () => {
const suggestionToAdd = ObservableLike.getValue(this.props.suggestions)[this.selectedIndex.value];
!!suggestionToAdd && this.addItem(suggestionToAdd);
};
this.onSuggestionClick = (suggestion) => {
this.addItem(suggestion.item);
};
this.addItem = (item) => {
this.suggestionsVisible.value = false;
this.props.onTagAdded(item);
this.textValue.value = "";
this.selectedIndex.value = -1;
this.focusInput();
};
this.focusInput = () => {
if (this.inputElement.current) {
this.inputElement.current.focus();
this.inputElement.current.select();
}
};
this.onSuggestionsDismiss = () => {
this.suggestionsVisible.value = false;
};
this.indexOfTag = (tag, list) => {
return list.findIndex((item) => {
return this.props.areTagsEqual(item, tag);
});
};
this.onAddButtonClicked = () => {
requestAnimationFrame(() => {
if (this.outerElement.current) {
const tagPicker = this.outerElement.current;
tagPicker.scrollTop = tagPicker.scrollHeight;
}
});
};
this.state = { width: 296 };
this.timerManagement = new TimerManagement();
this.isTagPickerAccessibilityFixEnabled = typeof document !== "undefined" && ((_a = document.body) === null || _a === void 0 ? void 0 : _a.classList.contains('tag-picker-accessibility-fix-enabled'));
}
render() {
const { ariaLabel, ariaLabelledBy, className, convertItemToPill, noResultsFoundText, onTagsRemoved, placeholderText, prefixIconProps, renderSuggestionItem, selectedTags, suggestions, suggestionsLoading, suggestionsLoadingText, suggestionsContainerAriaLabel } = this.props;
return (React.createElement(FocusWithin, { onBlur: this.onBlur, onFocus: this.onFocus }, (focusStatus) => {
return (React.createElement(React.Fragment, null,
React.createElement(Observer, { suggestionsLoading: { observableValue: suggestionsLoading, filter: this.suggestionsLoaded }, suggestionsVisible: this.suggestionsVisible, selectedIndex: this.selectedIndex, selectableTags: this.selectableTags, selectedTags: selectedTags, suggestions: { observableValue: suggestions, filter: this.suggestionsLoaded }, textValue: this.textValue }, (props) => {
const genericItem = this.createGenericItem(this.textValue.value, props.suggestions.concat(props.selectedTags));
genericItem && props.suggestions.unshift(genericItem);
return (React.createElement(Measure, { onMeasure: this.onTagPickerSizeChanged },
React.createElement("div", { className: css("bolt-tag-picker", (focusStatus.hasFocus || props.suggestionsVisible) && "edit", className), ref: this.outerElement, onBlur: focusStatus.onBlur, onFocus: focusStatus.onFocus, onClick: this.focusInput },
React.createElement("div", { className: "bolt-tag-picker-group flex-center flex-row flex-grow flex-wrap", onKeyDown: onTagsRemoved && this.onOuterKeyDown },
props.selectedTags.length === 0 && prefixIconProps ? (React.createElement(Icon, Object.assign({}, prefixIconProps, { className: css(prefixIconProps.className, "bolt-tag-picker-prefix-icon") }))) : null,
!this.isTagPickerAccessibilityFixEnabled && props.selectedTags.map((suggestion, index) => {
const tagPill = convertItemToPill(suggestion, index);
const indexOf = this.indexOfTag(suggestion, props.selectableTags);
return (React.createElement(Pill, Object.assign({}, tagPill, { key: getSafeId("bolt-tag-picker-pill" + index), className: css(tagPill.className, "bolt-tag-picker-pill", onTagsRemoved && "bolt-tag-picker-pill-selectable", indexOf >= 0 && "active"), contentClassName: "text-ellipsis scroll-hidden", onClick: ev => {
this.onTagClicked(ev, suggestion);
tagPill.onClick && tagPill.onClick(ev);
}, onRemoveClick: event => {
this.onTagRemoved(suggestion);
event.preventDefault();
} }), tagPill.content));
}),
React.createElement(FormItemContext.Consumer, null, formItemContext => {
const placeholder = props.selectedTags.length === 0 || focusStatus.hasFocus ? placeholderText : undefined;
const showAddButton = !focusStatus.hasFocus && props.selectedTags.length > 0 && !props.suggestionsVisible;
let ariaActiveDescendantId;
if (props.suggestionsVisible) {
if (props.selectedIndex === -1 || props.suggestionsLoading) {
ariaActiveDescendantId = getSafeId("sug-list-transition");
}
else if (!props.suggestions || !props.suggestions.length) {
ariaActiveDescendantId = getSafeId("sug-list-no-results");
}
else {
ariaActiveDescendantId = getSafeId(`sug-row-${props.selectedIndex}`);
}
}
const ariaControlsId = props.suggestionsVisible
? getSafeId("suggestion-list")
: undefined;
return this.isTagPickerAccessibilityFixEnabled ? (React.createElement(React.Fragment, null,
React.createElement("div", { role: "list", "aria-labelledby": getSafeId(formItemContext.ariaLabelledById), className: "tag-picker-accessibility-fix-enabled flex-row flex-wrap" }, props.selectedTags.map((suggestion, index) => {
const tagPill = convertItemToPill(suggestion, index);
const indexOf = this.indexOfTag(suggestion, props.selectableTags);
return (React.createElement(Pill, Object.assign({}, tagPill, { isListItem: true, key: getSafeId("bolt-tag-picker-pill" + index), className: css(tagPill.className, "bolt-tag-picker-pill", onTagsRemoved && "bolt-tag-picker-pill-selectable", indexOf >= 0 && "active"), contentClassName: "text-ellipsis scroll-hidden", onClick: ev => {
this.onTagClicked(ev, suggestion);
tagPill.onClick && tagPill.onClick(ev);
}, onRemoveClick: event => {
this.onTagRemoved(suggestion);
event.preventDefault();
} }), tagPill.content));
})),
React.createElement("div", { className: "bolt-tag-picker-add-icon-div flex-row flex-grow" },
showAddButton && (React.createElement(Icon, { className: "bolt-tag-picker-add-icon cursor-pointer", iconName: "Add", size: IconSize.small, onClick: this.onAddButtonClicked })),
React.createElement("input", { "aria-activedescendant": ariaActiveDescendantId, "aria-autocomplete": "list", "aria-controls": ariaControlsId, "aria-expanded": props.suggestionsVisible, "aria-haspopup": "listbox", "aria-label": ariaLabel || placeholderText, "aria-labelledby": getSafeId(ariaLabelledBy || formItemContext.ariaLabelledById), className: css("bolt-tag-picker-input flex-row flex-grow scroll-hidden", showAddButton && !placeholder && "hide-input"), onBlur: focusStatus.onBlur, onChange: this.onInputChange, onKeyDown: this.onKeyDown, onClick: this.onInputClick, placeholder: placeholder, ref: this.inputElement, role: "combobox", type: "text", value: props.textValue })))) : (React.createElement("div", { className: "bolt-tag-picker-add-icon-div flex-row flex-grow" },
showAddButton && (React.createElement(Icon, { className: "bolt-tag-picker-add-icon cursor-pointer", iconName: "Add", size: IconSize.small, onClick: this.onAddButtonClicked })),
React.createElement("input", { "aria-activedescendant": ariaActiveDescendantId, "aria-autocomplete": "list", "aria-controls": ariaControlsId, "aria-expanded": props.suggestionsVisible, "aria-haspopup": "listbox", "aria-label": ariaLabel || placeholderText, "aria-labelledby": getSafeId(ariaLabelledBy || formItemContext.ariaLabelledById), className: css("bolt-tag-picker-input flex-row flex-grow scroll-hidden", showAddButton && !placeholder && "hide-input"), onBlur: focusStatus.onBlur, onChange: this.onInputChange, onKeyDown: this.onKeyDown, onClick: this.onInputClick, placeholder: placeholder, ref: this.inputElement, role: "combobox", type: "text", value: props.textValue })));
})))));
}),
React.createElement(Observer, { suggestionsVisible: this.suggestionsVisible, suggestionsLoading: suggestionsLoading, selectedIndex: this.selectedIndex, suggestions: suggestions, textValue: this.textValue }, (props) => {
return props.suggestionsVisible ? (React.createElement(Callout, { anchorElement: this.outerElement.current || undefined, anchorOrigin: { horizontal: Location.start, vertical: Location.end }, calloutOrigin: { horizontal: Location.start, vertical: Location.start }, contentClassName: "bolt-tag-picker-callout-content scroll-hidden", contentShadow: true, id: "tag-picker-callout", role: "presentation" },
React.createElement(SuggestionsList, { isLoading: props.suggestionsLoading, loadingText: suggestionsLoadingText, onBlur: focusStatus.onBlur, onFocus: focusStatus.onFocus, noResultsFoundText: props.textValue ? noResultsFoundText : undefined, onSuggestionClicked: this.onSuggestionClick, renderSuggestion: renderSuggestionItem, selectedIndex: props.selectedIndex, suggestions: props.suggestions, suggestionsContainerAriaLabel: suggestionsContainerAriaLabel, width: this.state.width }))) : null;
})));
}));
}
componentDidMount() {
this.onResolveInput = this.timerManagement.debounce(this.onResolveInput, this.props.onSearchChangedDebounceWait);
}
focus() {
if (this.inputElement.current) {
this.inputElement.current.focus();
}
}
}
TagPicker.defaultProps = {
onSearchChangedDebounceWait: 250
};