UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

427 lines (426 loc) 25.2 kB
import "../../CommonImports"; import "../../Core/core.css"; import "./IdentityPickerDropdown.css"; import * as React from "react"; import * as Resources from '../../Resources.IdentityPicker'; import { ObservableArray, ObservableLike, ObservableValue } from '../../Core/Observable'; import { TimerManagement } from '../../Core/TimerManagement'; import { makeCancelable } from '../../Core/Util/Promise'; import { format, localeIgnoreCaseComparer, startsWith } from '../../Core/Util/String'; import { FocusWithin } from '../../FocusWithin'; import { Icon, IconSize } from '../../Icon'; import { Measure } from '../../Measure'; import { Observer } from '../../Observer'; import { Persona, PersonaSize } from '../../Persona'; import { TextField } from '../../TextField'; import { css, KeyCode } from '../../Util'; import { Location } from '../../Utilities/Position'; import { IdentityPickerSuggestionItem } from "../IdentityPickerSuggestionsList/IdentityPickerSuggestionItem"; import { IdentityPickerSuggestionsList } from "../IdentityPickerSuggestionsList/IdentityPickerSuggestionsList"; import { isRemoveMRUFixEnabled, shouldShowIdentityCard } from "./IdentityPickerUtils"; import { IdentityType } from "./SharedIdentityPicker.Props"; let textFieldId = 1; export class CustomIdentityPickerDropdown extends React.Component { constructor(props) { super(props); this.focusWithin = React.createRef(); this.inputElement = React.createRef(); this.itemRefs = {}; this.openedIdentityCard = new ObservableValue(undefined); this.outerElement = React.createRef(); this.nonIdentitySuggestions = []; this.suggestionsLoading = new ObservableValue(false); this.isEditing = new ObservableValue(false); this.selectedIndex = new ObservableValue(-1); this.suggestions = new ObservableArray([]); this.textFieldId = `identity-picker-downdown-textfield-${textFieldId++}`; this.onPickerDismiss = () => { this.props.onSuggestionsVisibleChanged(false); }; this.renderSuggestionItem = (suggestion) => { return (React.createElement("div", { className: "flex-row flex-grow scroll-hidden", onKeyDown: this.onKeyDownSuggestionItem }, React.createElement(IdentityPickerSuggestionItem, Object.assign({}, suggestion, { onOpenPersonaCard: this.openPersonaCard, onRemoveFromMRU: isRemoveMRUFixEnabled() ? this.onRemoveFromMRU : undefined, ref: itemRef => (this.itemRefs[suggestion.item.entityId] = itemRef), renderSuggestion: this.props.renderCustomIdentitySuggestion && this.renderCustomSuggestionItem })))); }; this.renderCustomSuggestionItem = (suggestion) => { return this.props.renderCustomIdentitySuggestion && this.props.renderCustomIdentitySuggestion(suggestion.item); }; this.renderPersonaCoin = (className) => { return (React.createElement(Observer, { selectedIdentity: this.props.value, isEditing: this.isEditing }, (props) => { return props.selectedIdentity && !props.isEditing ? (React.createElement(Persona, { className: css("flex-row flex-center justify-center", className), identity: props.selectedIdentity, size: PersonaSize.size24 })) : (React.createElement(Icon, { className: css("flex-row flex-center justify-center", className), iconName: "Contact", size: IconSize.medium })); })); }; this.openPersonaCard = (identity) => { if (shouldShowIdentityCard(identity)) { this.openedIdentityCard.value = identity; } }; this.closePersonaCard = () => { this.openedIdentityCard.value = undefined; if (this.inputElement.current) { this.inputElement.current.focus(); const suggestionsVisible = ObservableLike.getValue(this.props.suggestionsVisible); if (!suggestionsVisible) { this.props.onSuggestionsVisibleChanged(true); } } }; this.onRemoveFromMRU = (identity) => { var _a, _b; if (!isRemoveMRUFixEnabled()) { return; } (_b = (_a = this.props.pickerProvider).removeIdentitiesFromMRU) === null || _b === void 0 ? void 0 : _b.call(_a, [identity]).then((success) => { if (success) { // Remove identity from suggestions const updatedSuggestions = this.suggestions.value.filter(s => s.entityId !== identity.entityId); const currentSelectedIndex = this.selectedIndex.value; // Clear cache to prevent stale data this.cachedResults = {}; // Update suggestions and adjust selected index this.setSuggestions(updatedSuggestions, Math.min(currentSelectedIndex, updatedSuggestions.length - 1)); // Track successful MRU removal document.dispatchEvent(new CustomEvent('vss-telemetry-proxy', { detail: { area: "vss-identity-picker", component: "CustomIdentityPickerDropdown", feature: "IdentityPicker.MRU", level: 3, method: "onRemoveFromMRU", message: "Successfully removed identity from MRU" }, bubbles: true })); } else { // If removal failed, keep the identity in the suggestions list and do not update UI // Track failed MRU removal document.dispatchEvent(new CustomEvent('vss-telemetry-proxy', { detail: { area: "vss-identity-picker", component: "CustomIdentityPickerDropdown", feature: "IdentityPicker.MRU", level: 3, method: "onRemoveFromMRU", message: "Failed to remove identity from MRU" }, bubbles: true })); } }); }; this.completeSuggestion = () => { let selectedIdentity; if (this.suggestions.value.length > 0 && ObservableLike.getValue(this.props.suggestionsVisible) && this.selectedIndex.value !== -1) { selectedIdentity = this.suggestions.value[this.selectedIndex.value]; } else { this.selectedIndex.value = -1; selectedIdentity = this.props.resolveUnrecognizedIdentity && this.props.resolveUnrecognizedIdentity(ObservableLike.getValue(this.props.textValue)); } this.selectedPersonaChanged(selectedIdentity); if (selectedIdentity) { this.props.onSuggestionsVisibleChanged(false); } return Boolean(selectedIdentity); }; this.onBlur = () => { this.props.onSuggestionsVisibleChanged(false); if (!this.openedIdentityCard.value) { ObservableLike.getValue(this.props.textValue) === "" && this.selectedPersonaChanged(); this.props.onBlur && this.props.onBlur(); } }; this.onClearClicked = (ev) => { this.clear(); ev.preventDefault(); }; this.onClearKeyDown = (ev) => { if (ev.which === KeyCode.enter) { this.clear(); ev.preventDefault(); } }; this.clear = () => { var _a; this.props.onClear && this.props.onClear(); this.props.onChange(undefined); this.lastPickedIdentity = undefined; this.suggestions.value = []; (_a = this.inputElement.current) === null || _a === void 0 ? void 0 : _a.focus(); }; this.onClick = () => { if ((ObservableLike.getValue(this.props.textValue) === "" && this.props.pickerProvider.onEmptyInputFocus) || (ObservableLike.getValue(this.props.value) && this.suggestions.length === 0)) { this.updateSuggestionsList(this.props.pickerProvider.onEmptyInputFocus()); this.props.onSuggestionsVisibleChanged(!ObservableLike.getValue(this.props.suggestionsVisible)); } else { this.props.onSuggestionsVisibleChanged(!ObservableLike.getValue(this.props.suggestionsVisible)); } this.inputElement.current.select(); }; this.onFocus = (e) => { const { onFocus } = this.props; if (ObservableLike.getValue(this.props.textValue) === "" && this.props.pickerProvider.onEmptyInputFocus && !ObservableLike.getValue(this.props.suggestionsVisible)) { this.updateSuggestionsList(this.props.pickerProvider.onEmptyInputFocus()); } if (onFocus) { onFocus(e); } }; this.onKeyDown = (ev) => { if (ev.isDefaultPrevented()) { return; } const keyCode = ev.which; const suggestionsVisible = ObservableLike.getValue(this.props.suggestionsVisible); const input = this.inputElement.current && this.inputElement.current.inputElement.current; switch (keyCode) { case KeyCode.escape: if (this.openedIdentityCard.value) { !(this.suggestions.value && this.suggestions.value[this.selectedIndex.value]) && this.selectedPersonaChanged(); } if (suggestionsVisible) { this.props.onSuggestionsVisibleChanged(false); this.openedIdentityCard.value = undefined; ev.stopPropagation(); } break; case KeyCode.tab: case KeyCode.enter: if (!ev.shiftKey && suggestionsVisible) { if (this.completeSuggestion()) { ev.preventDefault(); ev.stopPropagation(); } } else if (suggestionsVisible) { this.completeSuggestion(); } else if (keyCode === KeyCode.enter && input && !input.value) { // Enter on an empty input element should behave the same as the clear button this.selectedPersonaChanged(undefined); } break; case KeyCode.rightArrow: if (this.suggestions.value && this.suggestions.value[this.selectedIndex.value] && input && input.value.length === input.selectionEnd) { this.focusContactCardButton(this.suggestions.value[this.selectedIndex.value]); ev.preventDefault(); } break; case KeyCode.upArrow: if (suggestionsVisible && this.suggestions.value) { this.selectedIndex.value = Math.max(0, this.selectedIndex.value - 1); this.forceUpdate(); ev.preventDefault(); ev.stopPropagation(); } break; case KeyCode.downArrow: if (suggestionsVisible && this.suggestions.value) { this.selectedIndex.value = Math.min(this.suggestions.value.length - 1, this.selectedIndex.value + 1); this.forceUpdate(); ev.preventDefault(); ev.stopPropagation(); } else { this.props.onSuggestionsVisibleChanged(true); } break; } }; this.onKeyDownSuggestionItem = (event) => { if (!event.defaultPrevented) { if (event.which === KeyCode.leftArrow || event.which === KeyCode.escape || event.which === KeyCode.tab) { if (this.inputElement.current) { this.inputElement.current.focus(); event.preventDefault(); } } } }; this.focusContactCardButton = (tag) => { if (this.itemRefs[tag.entityId]) { this.itemRefs[tag.entityId].focus(); } }; this.onResolveSuggestions = (updatedValue) => { const suggestions = this.props.pickerProvider.onFilterIdentities(updatedValue.toLocaleLowerCase(), []); if (suggestions !== null) { this.updateSuggestionsList(suggestions, updatedValue); } }; this.onSearchChange = (event, value) => { if (value.length == 0) { const { onClear } = this.props; onClear && onClear(); } this.selectedIndex.value = -1; this.isEditing.value = true; this.props.onInputChange(value); this.updateValue(value); }; this.onSuggestionClick = (suggestion) => { this.selectedIndex.value = suggestion.index; this.selectedPersonaChanged(suggestion.item); this.props.onSuggestionsVisibleChanged(false); }; this.onTextFieldChanged = (newWidth, newHeight) => { this.setState({ width: Math.max(newWidth, 296) }); }; this.selectedPersonaChanged = (persona) => { if (this.lastPickedIdentity !== persona) { !!persona && !!this.props.pickerProvider.addIdentitiesToMRU && this.props.pickerProvider.addIdentitiesToMRU([persona]); } if (this.props.onChange(persona) !== false) { this.props.onInputChange(persona ? persona.displayName : ""); this.lastPickedIdentity = persona; } else { this.props.onInputChange(""); this.props.onChange(undefined); this.lastPickedIdentity = undefined; } this.isEditing.value = false; }; this.updateValue = (updatedValue) => { this.props.onSuggestionsVisibleChanged(!!updatedValue); if (this.cachedResults[updatedValue]) { this.updateSuggestionsList(this.cachedResults[updatedValue], updatedValue); } else { this.onResolveSuggestions(updatedValue); } }; this.cachedResults = {}; this.timerManagement = new TimerManagement(); this.lastPickedIdentity = ObservableLike.getValue(props.value); this.state = { width: 296 }; } render() { const { ariaLabel, ariaLabelledBy, autoFocus, disabled, editPlaceholder = Resources.IdentityPickerPlaceholderFocusText, placeholder = Resources.IdentityPickerPlaceholderText, required } = this.props; return (React.createElement(Observer, { suggestionsVisible: this.props.suggestionsVisible }, (topProps) => { return (React.createElement(FocusWithin, { onBlur: this.onBlur, onFocus: this.onFocus, ref: this.focusWithin }, (focusStatus) => { return (React.createElement(React.Fragment, null, React.createElement(Observer, { selectedIdentity: this.props.value, selectedIndex: this.selectedIndex, suggestionsLoading: this.suggestionsLoading, textValue: this.props.textValue }, (props) => { var _a; let ariaActiveDescendantId; if (topProps.suggestionsVisible) { if (props.selectedIndex === -1 || props.suggestionsLoading) { ariaActiveDescendantId = "sug-list-transition"; } else if (!this.suggestions || !this.suggestions.length) { ariaActiveDescendantId = "sug-list-no-results"; } else { ariaActiveDescendantId = `sug-row-${props.selectedIndex}`; } } const ariaControlsId = topProps.suggestionsVisible ? "tag-picker-callout" : undefined; const placeholderText = !focusStatus.hasFocus && !props.selectedIdentity ? placeholder : editPlaceholder; return (React.createElement(Measure, { onMeasure: this.onTextFieldChanged }, React.createElement("div", { className: "bolt-identitypickerdropdown flex-row flex-grow", ref: this.outerElement, onKeyDown: this.onKeyDown }, React.createElement(TextField, { ariaExpanded: topProps.suggestionsVisible, ariaActiveDescendant: ariaActiveDescendantId, ariaAutoComplete: "list", ariaControls: ariaControlsId, autoFocus: autoFocus, ariaHasPopup: "listbox", ariaLabel: ariaLabel ? ariaLabel : props.textValue === "" ? placeholderText : props.textValue, ariaLabelledBy: ariaLabelledBy, className: css(this.props.className, "bolt-identitypickerdropdown-textField flex-row flex-center", focusStatus.hasFocus && "bolt-identitypickerdropdown-open"), containerClassName: "bolt-identitypickerdropdown-container flex-column flex-grow", inputId: (_a = this.props.inputId) !== null && _a !== void 0 ? _a : this.textFieldId, onBlur: focusStatus.onBlur, onChange: this.onSearchChange, onClick: this.onClick, onFocus: focusStatus.onFocus, prefixIconProps: { render: this.renderPersonaCoin }, placeholder: placeholderText, ref: this.inputElement, required: required, role: "combobox", suffixIconProps: props.textValue && !disabled ? { ariaHidden: "false", iconName: "Clear", className: "bolt-identity-picker-clearButton fontSize", role: "button", ariaLabel: format(Resources.Remove, props.textValue), onClick: this.onClearClicked, onKeyDown: this.onClearKeyDown, tabIndex: 0 } : undefined, value: props.textValue, disabled: disabled })))); }), React.createElement(Observer, { openedIdentityCard: this.openedIdentityCard, selectedIndex: this.selectedIndex }, (props) => { return (React.createElement(IdentityPickerSuggestionsList, { calloutProps: { anchorElement: this.outerElement.current, anchorOrigin: { horizontal: Location.start, vertical: Location.end }, calloutOrigin: { horizontal: Location.start, vertical: Location.start }, contentShadow: true, id: "tag-picker-callout", onDismiss: this.onPickerDismiss, role: "presentation" }, suggestionsVisible: topProps.suggestionsVisible || !!props.openedIdentityCard, isLoading: this.suggestionsLoading, onBlur: focusStatus.onBlur, onFocus: focusStatus.onFocus, onSuggestionClicked: this.onSuggestionClick, onClosePersonaCard: this.closePersonaCard, onDismiss: this.onPickerDismiss, onOpenPersonaCard: this.openPersonaCard, openedIdentityCard: props.openedIdentityCard, pickerProvider: this.props.pickerProvider, renderSuggestion: this.renderSuggestionItem, suggestions: this.suggestions, suggestionTarget: this.outerElement.current, selectedIndex: props.selectedIndex, width: this.state.width, resultsMaximumNumber: this.props.suggestionItemsMaximumCount, suggestionsContainerAriaLabel: this.props.suggestionsContainerAriaLabel })); }))); })); })); } componentDidMount() { this.updateValue = this.timerManagement.debounce(this.updateValue, 250); if (this.props.autoFocus) { const textValue = ObservableLike.getValue(this.props.textValue); if (this.props.pickerProvider.onEmptyInputFocus) { this.updateSuggestionsList(this.props.pickerProvider.onEmptyInputFocus()); } else { this.updateSuggestionsList(this.props.pickerProvider.onEmptyInputFocus()); } this.inputElement.current && this.inputElement.current.select(); this.props.onSuggestionsVisibleChanged(true); } this.setState({ width: this.outerElement.current.clientWidth }); } componentWillUnmount() { this.currentPromise && this.currentPromise.cancel(); } updateSuggestionsList(suggestions, initialSearchValue) { const suggestionsArray = suggestions; const suggestionsPromiseLike = suggestions; // Check to see if the returned value is an array, if it is then just pass it into the next function. // If the returned value is not an array then check to see if it's a promise or PromiseLike. If it is then resolve it asynchronously. if (Array.isArray(suggestionsArray)) { this.updateSuggestions(suggestionsArray, initialSearchValue); } else if (suggestionsPromiseLike && suggestionsPromiseLike.then) { this.suggestionsLoading.value = true; let filteredNonIdentitySuggestions = []; if (this.props.pickerProvider.getAdditionalEntries) { this.nonIdentitySuggestions = this.props.pickerProvider.getAdditionalEntries().map(value => { return { displayName: value, entityType: IdentityType.Custom, entityId: value, originDirectory: "", originId: "" }; }); filteredNonIdentitySuggestions = this.nonIdentitySuggestions && initialSearchValue ? this.nonIdentitySuggestions.filter(s => startsWith(s.displayName, initialSearchValue)) : this.nonIdentitySuggestions; } // Ensure that the promise will only use the callback if it was the most recent one. const promise = (this.currentPromise = makeCancelable(suggestionsPromiseLike)); promise.promise.then((newSuggestions) => { if (promise === this.currentPromise) { const resultSuggestions = filteredNonIdentitySuggestions.length ? [...newSuggestions, ...filteredNonIdentitySuggestions].sort((a, b) => localeIgnoreCaseComparer(a.displayName, b.displayName)) : newSuggestions; this.updateSuggestions(resultSuggestions, initialSearchValue); if (!!initialSearchValue && initialSearchValue !== "" && this.suggestions.value && this.suggestions.value.length > 0) { this.cachedResults[initialSearchValue] = resultSuggestions; } this.suggestionsLoading.value = false; } }); } } setSuggestions(suggestions, selectedIndex) { this.suggestions.value = suggestions; // Selected index set after list is updated for screen readers. if (this.updateIndexTimer) { window.cancelAnimationFrame(this.updateIndexTimer); } this.updateIndexTimer = window.requestAnimationFrame(() => { this.selectedIndex.value = selectedIndex; }); } updateSuggestions(suggestions, initialSearchValue) { // Only update the suggestions if the initial search value is the same as the current input if (initialSearchValue === undefined || (this.inputElement.current && ObservableLike.getValue(this.props.textValue) === initialSearchValue)) { this.setSuggestions(suggestions, ObservableLike.getValue(this.props.textValue) === "" ? -1 : 0); } } }