UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

316 lines (315 loc) 20.6 kB
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 };