azure-devops-ui
Version:
React components for building web UI in Azure DevOps
427 lines (426 loc) • 25.2 kB
JavaScript
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);
}
}
}