@fluentui/react
Version:
Reusable React components for building web experiences.
871 lines • 89.7 kB
JavaScript
import { __assign, __decorate, __extends, __rest, __spreadArrays } from "tslib";
import * as React from 'react';
import { Autofill } from '../../Autofill';
import { initializeComponentRef, css, customizable, divProperties, findElementRecursive, findIndex, focusAsync, getId, getNativeProps, isIOS, isMac, KeyCodes, shallowCompare, mergeAriaAttributeValues, warnMutuallyExclusive, Async, EventGroup, getPropsWithDefaults, } from '../../Utilities';
import { Callout, DirectionalHint } from '../../Callout';
import { Checkbox } from '../../Checkbox';
import { getCaretDownButtonStyles, getOptionStyles, getStyles } from './ComboBox.styles';
import { getClassNames, getComboBoxOptionClassNames } from './ComboBox.classNames';
import { Label } from '../../Label';
import { SelectableOptionMenuItemType, getAllSelectedOptions } from '../../SelectableOption';
import { CommandButton, IconButton } from '../../Button';
import { useMergedRefs } from '@fluentui/react-hooks';
var SearchDirection;
(function (SearchDirection) {
SearchDirection[SearchDirection["backward"] = -1] = "backward";
SearchDirection[SearchDirection["none"] = 0] = "none";
SearchDirection[SearchDirection["forward"] = 1] = "forward";
})(SearchDirection || (SearchDirection = {}));
var HoverStatus;
(function (HoverStatus) {
/** Used when the user was hovering and has since moused out of the menu items */
HoverStatus[HoverStatus["clearAll"] = -2] = "clearAll";
/** Default "normal" state, when no hover has happened or a hover is in progress */
HoverStatus[HoverStatus["default"] = -1] = "default";
})(HoverStatus || (HoverStatus = {}));
var ScrollIdleDelay = 250; /* ms */
var TouchIdleDelay = 500; /* ms */
/**
* This is used to clear any pending autocomplete text (used when autocomplete is true and
* allowFreeform is false)
*/
var ReadOnlyPendingAutoCompleteTimeout = 1000; /* ms */
/**
* Internal component that is used to wrap all ComboBox options.
* This is used to customize when we want to re-render components,
* so we don't re-render every option every time render is executed.
*/
var ComboBoxOptionWrapper = React.memo(function (_a) {
var render = _a.render;
return render();
}, function (_a, _b) {
var oldRender = _a.render, oldProps = __rest(_a, ["render"]);
var newRender = _b.render, newProps = __rest(_b, ["render"]);
// The render function will always be different, so we ignore that prop
return shallowCompare(oldProps, newProps);
});
var COMPONENT_NAME = 'ComboBox';
var DEFAULT_PROPS = {
options: [],
allowFreeform: false,
autoComplete: 'on',
buttonIconProps: { iconName: 'ChevronDown' },
};
function useOptionsState(_a) {
var options = _a.options, defaultSelectedKey = _a.defaultSelectedKey, selectedKey = _a.selectedKey;
/** The currently selected indices */
var _b = React.useState(function () {
return getSelectedIndices(options, buildDefaultSelectedKeys(defaultSelectedKey, selectedKey));
}), selectedIndices = _b[0], setSelectedIndices = _b[1];
/** The options currently available for the callout */
var _c = React.useState(options), currentOptions = _c[0], setCurrentOptions = _c[1];
/** This value is used for the autocomplete hint value */
var _d = React.useState(), suggestedDisplayValue = _d[0], setSuggestedDisplayValue = _d[1];
React.useEffect(function () {
if (selectedKey !== undefined) {
var selectedKeys = buildSelectedKeys(selectedKey);
var indices = getSelectedIndices(options, selectedKeys);
setSelectedIndices(indices);
}
setCurrentOptions(options);
}, [options, selectedKey]);
React.useEffect(function () {
if (selectedKey === null) {
setSuggestedDisplayValue(undefined);
}
}, [selectedKey]);
return [
selectedIndices,
setSelectedIndices,
currentOptions,
setCurrentOptions,
suggestedDisplayValue,
setSuggestedDisplayValue,
];
}
export var ComboBox = React.forwardRef(function (propsWithoutDefaults, forwardedRef) {
var _a = getPropsWithDefaults(DEFAULT_PROPS, propsWithoutDefaults), ref = _a.ref, props = __rest(_a, ["ref"]);
var rootRef = React.useRef(null);
var mergedRootRef = useMergedRefs(rootRef, forwardedRef);
var _b = useOptionsState(props), selectedIndices = _b[0], setSelectedIndices = _b[1], currentOptions = _b[2], setCurrentOptions = _b[3], suggestedDisplayValue = _b[4], setSuggestedDisplayValue = _b[5];
return (React.createElement(ComboBoxInternal, __assign({}, props, { hoisted: {
mergedRootRef: mergedRootRef,
rootRef: rootRef,
selectedIndices: selectedIndices,
setSelectedIndices: setSelectedIndices,
currentOptions: currentOptions,
setCurrentOptions: setCurrentOptions,
suggestedDisplayValue: suggestedDisplayValue,
setSuggestedDisplayValue: setSuggestedDisplayValue,
} })));
});
ComboBox.displayName = COMPONENT_NAME;
var ComboBoxInternal = /** @class */ (function (_super) {
__extends(ComboBoxInternal, _super);
function ComboBoxInternal(props) {
var _this = _super.call(this, props) || this;
/** The input aspect of the combo box */
_this._autofill = React.createRef();
/** The wrapping div of the input and button */
_this._comboBoxWrapper = React.createRef();
/** The callout element */
_this._comboBoxMenu = React.createRef();
/** The menu item element that is currently selected */
_this._selectedElement = React.createRef();
/**
* {@inheritdoc}
*/
_this.focus = function (shouldOpenOnFocus, useFocusAsync) {
if (_this._autofill.current) {
if (useFocusAsync) {
focusAsync(_this._autofill.current);
}
else {
_this._autofill.current.focus();
}
if (shouldOpenOnFocus) {
_this.setState({
isOpen: true,
});
}
}
// Programmatically setting focus means that there is nothing else that needs to be done
// Focus is now contained
if (!_this._hasFocus()) {
_this.setState({ focusState: 'focused' });
}
};
/**
* Close menu callout if it is open
*/
_this.dismissMenu = function () {
var isOpen = _this.state.isOpen;
isOpen && _this.setState({ isOpen: false });
};
/**
* componentWillReceiveProps handler for the auto fill component
* Checks/updates the input value to set, if needed
* @param defaultVisibleValue - the defaultVisibleValue that got passed
* in to the auto fill's componentWillReceiveProps
* @returns - the updated value to set, if needed
*/
_this._onUpdateValueInAutofillWillReceiveProps = function () {
var comboBox = _this._autofill.current;
if (!comboBox) {
return null;
}
if (comboBox.value === null || comboBox.value === undefined) {
return null;
}
var visibleValue = normalizeToString(_this._currentVisibleValue);
if (comboBox.value !== visibleValue) {
return visibleValue;
}
return comboBox.value;
};
_this._renderComboBoxWrapper = function (multiselectAccessibleText, errorMessageId) {
var _a = _this.props, label = _a.label, disabled = _a.disabled, ariaLabel = _a.ariaLabel, ariaDescribedBy = _a.ariaDescribedBy, required = _a.required, errorMessage = _a.errorMessage, buttonIconProps = _a.buttonIconProps, _b = _a.isButtonAriaHidden, isButtonAriaHidden = _b === void 0 ? true : _b, title = _a.title, placeholderProp = _a.placeholder, tabIndex = _a.tabIndex, autofill = _a.autofill, iconButtonProps = _a.iconButtonProps, suggestedDisplayValue = _a.hoisted.suggestedDisplayValue;
var isOpen = _this.state.isOpen;
// If the combo box has focus, is multiselect, and has a display string, then use that placeholder
// so that the selected items don't appear to vanish. This is not ideal but it's the only reasonable way
// to correct the behavior where the input is cleared so the user can type. If a full refactor is done, then this
// should be removed and the multiselect combo box should behave like a picker.
var placeholder = _this._hasFocus() && _this.props.multiSelect && multiselectAccessibleText
? multiselectAccessibleText
: placeholderProp;
return (React.createElement("div", { "data-ktp-target": true, ref: _this._comboBoxWrapper, id: _this._id + 'wrapper', className: _this._classNames.root },
React.createElement(Autofill, __assign({ "data-ktp-execute-target": true, "data-is-interactable": !disabled, componentRef: _this._autofill, id: _this._id + '-input', className: _this._classNames.input, type: "text", onFocus: _this._onFocus, onBlur: _this._onBlur, onKeyDown: _this._onInputKeyDown, onKeyUp: _this._onInputKeyUp, onClick: _this._onAutofillClick, onTouchStart: _this._onTouchStart, onInputValueChange: _this._onInputChange, "aria-expanded": isOpen, "aria-autocomplete": _this._getAriaAutoCompleteValue(), role: "combobox", readOnly: disabled, "aria-labelledby": label && _this._id + '-label', "aria-label": ariaLabel && !label ? ariaLabel : undefined, "aria-describedby": errorMessage !== undefined ? mergeAriaAttributeValues(ariaDescribedBy, errorMessageId) : ariaDescribedBy, "aria-activedescendant": _this._getAriaActiveDescendantValue(), "aria-required": required, "aria-disabled": disabled, "aria-owns": isOpen ? _this._id + '-list' : undefined, spellCheck: false, defaultVisibleValue: _this._currentVisibleValue, suggestedDisplayValue: suggestedDisplayValue, updateValueInWillReceiveProps: _this._onUpdateValueInAutofillWillReceiveProps, shouldSelectFullInputValueInComponentDidUpdate: _this._onShouldSelectFullInputValueInAutofillComponentDidUpdate, title: title, preventValueSelection: !_this._hasFocus(), placeholder: placeholder, tabIndex: disabled ? -1 : tabIndex }, autofill)),
React.createElement(IconButton, __assign({ className: 'ms-ComboBox-CaretDown-button', styles: _this._getCaretButtonStyles(), role: "presentation", "aria-hidden": isButtonAriaHidden, "data-is-focusable": false, tabIndex: -1, onClick: _this._onComboBoxClick, onBlur: _this._onBlur, iconProps: buttonIconProps, disabled: disabled, checked: isOpen }, iconButtonProps))));
};
/**
* componentDidUpdate handler for the auto fill component
*
* @param defaultVisibleValue - the current defaultVisibleValue in the auto fill's componentDidUpdate
* @param suggestedDisplayValue - the current suggestedDisplayValue in the auto fill's componentDidUpdate
* @returns - should the full value of the input be selected?
* True if the defaultVisibleValue equals the suggestedDisplayValue, false otherwise
*/
_this._onShouldSelectFullInputValueInAutofillComponentDidUpdate = function () {
return _this._currentVisibleValue === _this.props.hoisted.suggestedDisplayValue;
};
/**
* Get the correct value to pass to the input
* to show to the user based off of the current props and state
* @returns the value to pass to the input
*/
_this._getVisibleValue = function () {
var _a = _this.props, text = _a.text, allowFreeform = _a.allowFreeform, autoComplete = _a.autoComplete, _b = _a.hoisted, suggestedDisplayValue = _b.suggestedDisplayValue, selectedIndices = _b.selectedIndices, currentOptions = _b.currentOptions;
var _c = _this.state, currentPendingValueValidIndex = _c.currentPendingValueValidIndex, currentPendingValue = _c.currentPendingValue, isOpen = _c.isOpen;
var currentPendingIndexValid = indexWithinBounds(currentOptions, currentPendingValueValidIndex);
// If the user passed is a value prop, use that
// unless we are open and have a valid current pending index
if (!(isOpen && currentPendingIndexValid) &&
text &&
(currentPendingValue === null || currentPendingValue === undefined)) {
return text;
}
if (_this.props.multiSelect) {
// Multi-select
if (_this._hasFocus()) {
var index = -1;
if (autoComplete === 'on' && currentPendingIndexValid) {
index = currentPendingValueValidIndex;
}
return _this._getPendingString(currentPendingValue, currentOptions, index);
}
else {
return _this._getMultiselectDisplayString(selectedIndices, currentOptions, suggestedDisplayValue);
}
}
else {
// Single-select
var index = _this._getFirstSelectedIndex();
if (allowFreeform) {
// If we are allowing freeform and autocomplete is also true
// and we've got a pending value that matches an option, remember
// the matched option's index
if (autoComplete === 'on' && currentPendingIndexValid) {
index = currentPendingValueValidIndex;
}
// Since we are allowing freeform, if there is currently a pending value, use that
// otherwise use the index determined above (falling back to '' if we did not get a valid index)
return _this._getPendingString(currentPendingValue, currentOptions, index);
}
else {
// If we are not allowing freeform and have a valid index that matches the pending value,
// we know we will need some version of the pending value
if (currentPendingIndexValid && autoComplete === 'on') {
// If autoComplete is on, return the raw pending value, otherwise remember
// the matched option's index
index = currentPendingValueValidIndex;
return normalizeToString(currentPendingValue);
}
else if (!_this.state.isOpen && currentPendingValue) {
return indexWithinBounds(currentOptions, index)
? currentPendingValue
: normalizeToString(suggestedDisplayValue);
}
else {
return indexWithinBounds(currentOptions, index)
? getPreviewText(currentOptions[index])
: normalizeToString(suggestedDisplayValue);
}
}
}
};
/**
* Handler for typing changes on the input
* @param updatedValue - the newly changed value
*/
_this._onInputChange = function (updatedValue) {
if (_this.props.disabled) {
_this._handleInputWhenDisabled(null /* event */);
return;
}
_this.props.allowFreeform
? _this._processInputChangeWithFreeform(updatedValue)
: _this._processInputChangeWithoutFreeform(updatedValue);
};
/**
* Focus (and select) the content of the input
* and set the focused state
*/
_this._onFocus = function () {
var _a, _b;
(_b = (_a = _this._autofill.current) === null || _a === void 0 ? void 0 : _a.inputElement) === null || _b === void 0 ? void 0 : _b.select();
if (!_this._hasFocus()) {
_this.setState({ focusState: 'focusing' });
}
};
/**
* Callback issued when the options should be resolved, if they have been updated or
* if they need to be passed in the first time. This only does work if an onResolveOptions
* callback was passed in
*/
_this._onResolveOptions = function () {
if (_this.props.onResolveOptions) {
// get the options
var newOptions = _this.props.onResolveOptions(__spreadArrays(_this.props.hoisted.currentOptions));
// Check to see if the returned value is an array, if it is update the state
// 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(newOptions)) {
_this.props.hoisted.setCurrentOptions(newOptions);
}
else if (newOptions && newOptions.then) {
// Ensure that the promise will only use the callback if it was the most recent one
// and update the state when the promise returns
var promise_1 = (_this._currentPromise = newOptions);
promise_1.then(function (newOptionsFromPromise) {
if (promise_1 === _this._currentPromise) {
_this.props.hoisted.setCurrentOptions(newOptionsFromPromise);
}
});
}
}
};
/**
* OnBlur handler. Set the focused state to false
* and submit any pending value
*/
// eslint-disable-next-line deprecation/deprecation
_this._onBlur = function (event) {
var _a, _b;
// Do nothing if the blur is coming from something
// inside the comboBox root or the comboBox menu since
// it we are not really blurring from the whole comboBox
var relatedTarget = event.relatedTarget;
if (event.relatedTarget === null) {
// In IE11, due to lack of support, event.relatedTarget is always
// null making every onBlur call to be "outside" of the ComboBox
// even when it's not. Using document.activeElement is another way
// for us to be able to get what the relatedTarget without relying
// on the event
relatedTarget = document.activeElement;
}
if (relatedTarget) {
var isBlurFromComboBoxTitle = (_a = _this.props.hoisted.rootRef.current) === null || _a === void 0 ? void 0 : _a.contains(relatedTarget);
var isBlurFromComboBoxMenu = (_b = _this._comboBoxMenu.current) === null || _b === void 0 ? void 0 : _b.contains(relatedTarget);
var isBlurFromComboBoxMenuAncestor = _this._comboBoxMenu.current &&
findElementRecursive(_this._comboBoxMenu.current, function (element) { return element === relatedTarget; });
if (isBlurFromComboBoxTitle || isBlurFromComboBoxMenu || isBlurFromComboBoxMenuAncestor) {
if (isBlurFromComboBoxMenuAncestor &&
_this._hasFocus() &&
(!_this.props.multiSelect || _this.props.allowFreeform)) {
_this._submitPendingValue(event);
}
event.preventDefault();
event.stopPropagation();
return;
}
}
if (_this._hasFocus()) {
_this.setState({ focusState: 'none' });
if (!_this.props.multiSelect || _this.props.allowFreeform) {
_this._submitPendingValue(event);
}
}
};
// Render Callout container and pass in list
_this._onRenderContainer = function (props, defaultRender) {
var onRenderList = props.onRenderList, calloutProps = props.calloutProps, dropdownWidth = props.dropdownWidth, dropdownMaxWidth = props.dropdownMaxWidth, _a = props.onRenderUpperContent, onRenderUpperContent = _a === void 0 ? _this._onRenderUpperContent : _a, _b = props.onRenderLowerContent, onRenderLowerContent = _b === void 0 ? _this._onRenderLowerContent : _b, useComboBoxAsMenuWidth = props.useComboBoxAsMenuWidth, persistMenu = props.persistMenu, _c = props.shouldRestoreFocus, shouldRestoreFocus = _c === void 0 ? true : _c;
var isOpen = _this.state.isOpen;
var id = _this._id;
var comboBoxMenuWidth = useComboBoxAsMenuWidth && _this._comboBoxWrapper.current
? _this._comboBoxWrapper.current.clientWidth + 2
: undefined;
return (React.createElement(Callout, __assign({ isBeakVisible: false, gapSpace: 0, doNotLayer: false, directionalHint: DirectionalHint.bottomLeftEdge, directionalHintFixed: false }, calloutProps, { onLayerMounted: _this._onLayerMounted, className: css(_this._classNames.callout, calloutProps === null || calloutProps === void 0 ? void 0 : calloutProps.className), target: _this._comboBoxWrapper.current, onDismiss: _this._onDismiss, onMouseDown: _this._onCalloutMouseDown, onScroll: _this._onScroll, setInitialFocus: false, calloutWidth: useComboBoxAsMenuWidth && _this._comboBoxWrapper.current
? comboBoxMenuWidth && comboBoxMenuWidth
: dropdownWidth, calloutMaxWidth: dropdownMaxWidth ? dropdownMaxWidth : comboBoxMenuWidth, hidden: persistMenu ? !isOpen : undefined, shouldRestoreFocus: shouldRestoreFocus }),
onRenderUpperContent(_this.props, _this._onRenderUpperContent),
React.createElement("div", { className: _this._classNames.optionsContainerWrapper, ref: _this._comboBoxMenu }, onRenderList === null || onRenderList === void 0 ? void 0 : onRenderList(__assign(__assign({}, props), { id: id }), _this._onRenderList)),
onRenderLowerContent(_this.props, _this._onRenderLowerContent)));
};
_this._onLayerMounted = function () {
_this._onCalloutLayerMounted();
if (_this.props.calloutProps && _this.props.calloutProps.onLayerMounted) {
_this.props.calloutProps.onLayerMounted();
}
};
_this._onRenderLabel = function (onRenderLabelProps) {
var _a = onRenderLabelProps.props, label = _a.label, disabled = _a.disabled, required = _a.required;
if (label) {
return (React.createElement(Label, { id: _this._id + '-label', disabled: disabled, required: required, className: _this._classNames.label },
label,
onRenderLabelProps.multiselectAccessibleText && (React.createElement("span", { className: _this._classNames.screenReaderText }, onRenderLabelProps.multiselectAccessibleText))));
}
return null;
};
// Render List of items
_this._onRenderList = function (props) {
var onRenderItem = props.onRenderItem, options = props.options;
var id = _this._id;
return (React.createElement("div", { id: id + '-list', className: _this._classNames.optionsContainer, "aria-labelledby": id + '-label', role: "listbox" }, options.map(function (item) { return onRenderItem === null || onRenderItem === void 0 ? void 0 : onRenderItem(item, _this._onRenderItem); })));
};
// Render items
_this._onRenderItem = function (item) {
switch (item.itemType) {
case SelectableOptionMenuItemType.Divider:
return _this._renderSeparator(item);
case SelectableOptionMenuItemType.Header:
return _this._renderHeader(item);
default:
return _this._renderOption(item);
}
};
// Default _onRenderLowerContent function returns nothing
_this._onRenderLowerContent = function () {
return null;
};
// Default _onRenderUpperContent function returns nothing
_this._onRenderUpperContent = function () {
return null;
};
_this._renderOption = function (item) {
var _a;
var _b = _this.props.onRenderOption, onRenderOption = _b === void 0 ? _this._onRenderOptionContent : _b;
var id = _this._id;
var isSelected = _this._isOptionSelected(item.index);
var isChecked = _this._isOptionChecked(item.index);
var optionStyles = _this._getCurrentOptionStyles(item);
var optionClassNames = getComboBoxOptionClassNames(_this._getCurrentOptionStyles(item));
var title = (_a = item.title) !== null && _a !== void 0 ? _a : getPreviewText(item);
var onRenderCheckboxLabel = function () { return onRenderOption(item, _this._onRenderOptionContent); };
var getOptionComponent = function () {
return !_this.props.multiSelect ? (React.createElement(CommandButton, { id: id + '-list' + item.index, key: item.key, "data-index": item.index, styles: optionStyles, checked: isSelected, className: 'ms-ComboBox-option', onClick: _this._onItemClick(item),
// eslint-disable-next-line react/jsx-no-bind
onMouseEnter: _this._onOptionMouseEnter.bind(_this, item.index),
// eslint-disable-next-line react/jsx-no-bind
onMouseMove: _this._onOptionMouseMove.bind(_this, item.index), onMouseLeave: _this._onOptionMouseLeave, role: "option", "aria-selected": isChecked ? 'true' : 'false', ariaLabel: item.ariaLabel, disabled: item.disabled, title: title }, React.createElement("span", { className: optionClassNames.optionTextWrapper, ref: isSelected ? _this._selectedElement : undefined }, onRenderOption(item, _this._onRenderOptionContent)))) : (React.createElement(Checkbox, { id: id + '-list' + item.index, ariaLabel: item.ariaLabel, key: item.key, styles: optionStyles, className: 'ms-ComboBox-option', onChange: _this._onItemClick(item), label: item.text, checked: isChecked, title: title, disabled: item.disabled,
// eslint-disable-next-line react/jsx-no-bind
onRenderLabel: onRenderCheckboxLabel, inputProps: __assign({
// aria-selected should only be applied to checked items, not hovered items
'aria-selected': isChecked ? 'true' : 'false', role: 'option' }, {
'data-index': item.index,
'data-is-focusable': true,
}) }));
};
return (React.createElement(ComboBoxOptionWrapper, { key: item.key, index: item.index, disabled: item.disabled, isSelected: isSelected, isChecked: isChecked, text: item.text,
// eslint-disable-next-line react/jsx-no-bind
render: getOptionComponent, data: item.data }));
};
/**
* Mouse clicks to headers, dividers and scrollbar should not make input lose focus
*/
_this._onCalloutMouseDown = function (ev) {
ev.preventDefault();
};
/**
* Scroll handler for the callout to make sure the mouse events
* for updating focus are not interacting during scroll
*/
_this._onScroll = function () {
if (!_this._isScrollIdle && _this._scrollIdleTimeoutId !== undefined) {
_this._async.clearTimeout(_this._scrollIdleTimeoutId);
_this._scrollIdleTimeoutId = undefined;
}
else {
_this._isScrollIdle = false;
}
_this._scrollIdleTimeoutId = _this._async.setTimeout(function () {
_this._isScrollIdle = true;
}, ScrollIdleDelay);
};
_this._onRenderOptionContent = function (item) {
var optionClassNames = getComboBoxOptionClassNames(_this._getCurrentOptionStyles(item));
return React.createElement("span", { className: optionClassNames.optionText }, item.text);
};
/**
* Handles dismissing (cancelling) the menu
*/
_this._onDismiss = function () {
var onMenuDismiss = _this.props.onMenuDismiss;
if (onMenuDismiss) {
onMenuDismiss();
}
// In persistMode we need to simulate callout layer mount
// since that only happens once. We do it on dismiss since
// it works either way.
if (_this.props.persistMenu) {
_this._onCalloutLayerMounted();
}
// close the menu
_this._setOpenStateAndFocusOnClose(false /* isOpen */, false /* focusInputAfterClose */);
// reset the selected index
// to the last value state
_this._resetSelectedIndex();
};
_this._onAfterClearPendingInfo = function () {
_this._processingClearPendingInfo = false;
};
/**
* Handle keydown on the input
* @param ev - The keyboard event that was fired
*/
_this._onInputKeyDown = function (ev) {
var _a = _this.props, disabled = _a.disabled, allowFreeform = _a.allowFreeform, autoComplete = _a.autoComplete, currentOptions = _a.hoisted.currentOptions;
var _b = _this.state, isOpen = _b.isOpen, currentPendingValueValidIndexOnHover = _b.currentPendingValueValidIndexOnHover;
// Take note if we are processing an alt (option) or meta (command) keydown.
// See comment in _onInputKeyUp for reasoning.
_this._lastKeyDownWasAltOrMeta = isAltOrMeta(ev);
if (disabled) {
_this._handleInputWhenDisabled(ev);
return;
}
var index = _this._getPendingSelectedIndex(false /* includeCurrentPendingValue */);
// eslint-disable-next-line deprecation/deprecation
switch (ev.which) {
case KeyCodes.enter:
if (_this._autofill.current && _this._autofill.current.inputElement) {
_this._autofill.current.inputElement.select();
}
_this._submitPendingValue(ev);
if (_this.props.multiSelect && isOpen) {
_this.setState({
currentPendingValueValidIndex: index,
});
}
else {
// On enter submit the pending value
if (isOpen ||
((!allowFreeform ||
_this.state.currentPendingValue === undefined ||
_this.state.currentPendingValue === null ||
_this.state.currentPendingValue.length <= 0) &&
_this.state.currentPendingValueValidIndex < 0)) {
// if we are open or
// if we are not allowing freeform or
// our we have no pending value
// and no valid pending index
// flip the open state
_this.setState({
isOpen: !isOpen,
});
}
}
break;
case KeyCodes.tab:
// On enter submit the pending value
if (!_this.props.multiSelect) {
_this._submitPendingValue(ev);
}
// If we are not allowing freeform
// or the combo box is open, flip the open state
if (isOpen) {
_this._setOpenStateAndFocusOnClose(!isOpen, false /* focusInputAfterClose */);
}
// Allow TAB to propagate
return;
case KeyCodes.escape:
// reset the selected index
_this._resetSelectedIndex();
// Close the menu if opened
if (isOpen) {
_this.setState({
isOpen: false,
});
}
else {
return;
}
break;
case KeyCodes.up:
// if we are in clearAll state (e.g. the user as hovering
// and has since mousedOut of the menu items),
// go to the last index
if (currentPendingValueValidIndexOnHover === HoverStatus.clearAll) {
index = _this.props.hoisted.currentOptions.length;
}
if (ev.altKey || ev.metaKey) {
// Close the menu if it is open and break so
// that the event get stopPropagation and prevent default.
// Otherwise, we need to let the event continue to propagate
if (isOpen) {
_this._setOpenStateAndFocusOnClose(!isOpen, true /* focusInputAfterClose */);
break;
}
return;
}
// Go to the previous option
_this._setPendingInfoFromIndexAndDirection(index, SearchDirection.backward);
break;
case KeyCodes.down:
// Expand the combo box on ALT + DownArrow
if (ev.altKey || ev.metaKey) {
_this._setOpenStateAndFocusOnClose(true /* isOpen */, true /* focusInputAfterClose */);
}
else {
// if we are in clearAll state (e.g. the user as hovering
// and has since mousedOut of the menu items),
// go to the first index
if (currentPendingValueValidIndexOnHover === HoverStatus.clearAll) {
index = -1;
}
// Got to the next option
_this._setPendingInfoFromIndexAndDirection(index, SearchDirection.forward);
}
break;
case KeyCodes.home:
case KeyCodes.end:
if (allowFreeform) {
return;
}
// Set the initial values to respond to HOME
// which goes to the first selectable option
index = -1;
var directionToSearch = SearchDirection.forward;
// If end, update the values to respond to END
// which goes to the last selectable option
// eslint-disable-next-line deprecation/deprecation
if (ev.which === KeyCodes.end) {
index = currentOptions.length;
directionToSearch = SearchDirection.backward;
}
_this._setPendingInfoFromIndexAndDirection(index, directionToSearch);
break;
/* eslint-disable no-fallthrough */
case KeyCodes.space:
// event handled in _onComboBoxKeyUp
if (!allowFreeform && autoComplete === 'off') {
break;
}
default:
/* eslint-enable no-fallthrough */
// are we processing a function key? if so bail out
// eslint-disable-next-line deprecation/deprecation
if (ev.which >= 112 /* F1 */ && ev.which <= 123 /* F12 */) {
return;
}
// If we get here and we got either and ALT key
// or meta key, let the event propagate
if (ev.keyCode === KeyCodes.alt || ev.key === 'Meta' /* && isOpen */) {
return;
}
// If we are not allowing freeform and
// allowing autoComplete, handle the input here
// since we have marked the input as readonly
if (!allowFreeform && autoComplete === 'on') {
_this._onInputChange(ev.key);
break;
}
// allow the key to propagate by default
return;
}
ev.stopPropagation();
ev.preventDefault();
};
/**
* Handle keyup on the input
* @param ev - the keyboard event that was fired
*/
_this._onInputKeyUp = function (ev) {
var _a = _this.props, disabled = _a.disabled, allowFreeform = _a.allowFreeform, autoComplete = _a.autoComplete;
var isOpen = _this.state.isOpen;
// We close the menu on key up only if ALL of the following are true:
// - Most recent key down was alt or meta (command)
// - The alt/meta key down was NOT followed by some other key (such as down/up arrow to
// expand/collapse the menu)
// - We're not on a Mac (or iOS)
// This is because on Windows, pressing alt moves focus to the application menu bar or similar,
// closing any open context menus. There is not a similar behavior on Macs.
var keyPressIsAltOrMetaAlone = _this._lastKeyDownWasAltOrMeta && isAltOrMeta(ev);
_this._lastKeyDownWasAltOrMeta = false;
var shouldHandleKey = keyPressIsAltOrMetaAlone && !(isMac() || isIOS());
if (disabled) {
_this._handleInputWhenDisabled(ev);
return;
}
// eslint-disable-next-line deprecation/deprecation
switch (ev.which) {
case KeyCodes.space:
// If we are not allowing freeform and are not autoComplete
// make space expand/collapse the combo box
// and allow the event to propagate
if (!allowFreeform && autoComplete === 'off') {
_this._setOpenStateAndFocusOnClose(!isOpen, !!isOpen);
}
return;
default:
if (shouldHandleKey && isOpen) {
_this._setOpenStateAndFocusOnClose(!isOpen, true /* focusInputAfterClose */);
}
else {
if (_this.state.focusState === 'focusing' && _this.props.openOnKeyboardFocus) {
_this.setState({ isOpen: true });
}
if (_this.state.focusState !== 'focused') {
_this.setState({ focusState: 'focused' });
}
}
return;
}
};
_this._onOptionMouseLeave = function () {
if (_this._shouldIgnoreMouseEvent()) {
return;
}
// Ignore the event in persistMenu mode if the callout has
// closed. This is to avoid clearing the visuals on item click.
if (_this.props.persistMenu && !_this.state.isOpen) {
return;
}
_this.setState({
currentPendingValueValidIndexOnHover: HoverStatus.clearAll,
});
};
/**
* Click handler for the button of the combo box and the input when not allowing freeform.
* This toggles the expand/collapse state of the combo box (if enabled).
*/
_this._onComboBoxClick = function () {
var disabled = _this.props.disabled;
var isOpen = _this.state.isOpen;
if (!disabled) {
_this._setOpenStateAndFocusOnClose(!isOpen, false /* focusInputAfterClose */);
_this.setState({ focusState: 'focused' });
}
};
/**
* Click handler for the autofill.
*/
_this._onAutofillClick = function () {
var _a = _this.props, disabled = _a.disabled, allowFreeform = _a.allowFreeform;
if (allowFreeform && !disabled) {
_this.focus(_this.state.isOpen || _this._processingTouch);
}
else {
_this._onComboBoxClick();
}
};
_this._onTouchStart = function () {
if (_this._comboBoxWrapper.current && !('onpointerdown' in _this._comboBoxWrapper)) {
_this._handleTouchAndPointerEvent();
}
};
_this._onPointerDown = function (ev) {
if (ev.pointerType === 'touch') {
_this._handleTouchAndPointerEvent();
ev.preventDefault();
ev.stopImmediatePropagation();
}
};
initializeComponentRef(_this);
_this._async = new Async(_this);
_this._events = new EventGroup(_this);
warnMutuallyExclusive(COMPONENT_NAME, props, {
defaultSelectedKey: 'selectedKey',
text: 'defaultSelectedKey',
selectedKey: 'value',
dropdownWidth: 'useComboBoxAsMenuWidth',
});
_this._id = props.id || getId('ComboBox');
_this._isScrollIdle = true;
_this._processingTouch = false;
_this._gotMouseMove = false;
_this._processingClearPendingInfo = false;
_this.state = {
isOpen: false,
focusState: 'none',
currentPendingValueValidIndex: -1,
currentPendingValue: undefined,
currentPendingValueValidIndexOnHover: HoverStatus.default,
};
return _this;
}
Object.defineProperty(ComboBoxInternal.prototype, "selectedOptions", {
/**
* All selected options
*/
get: function () {
var _a = this.props.hoisted, currentOptions = _a.currentOptions, selectedIndices = _a.selectedIndices;
return getAllSelectedOptions(currentOptions, selectedIndices);
},
enumerable: false,
configurable: true
});
ComboBoxInternal.prototype.componentDidMount = function () {
if (this._comboBoxWrapper.current && !this.props.disabled) {
// hook up resolving the options if needed on focus
this._events.on(this._comboBoxWrapper.current, 'focus', this._onResolveOptions, true);
if ('onpointerdown' in this._comboBoxWrapper.current) {
// For ComboBoxes, touching anywhere in the combo box should drop the dropdown, including the input element.
// This gives more hit target space for touch environments. We're setting the onpointerdown here, because React
// does not support Pointer events yet.
this._events.on(this._comboBoxWrapper.current, 'pointerdown', this._onPointerDown, true);
}
}
};
ComboBoxInternal.prototype.componentDidUpdate = function (prevProps, prevState) {
var _this = this;
var _a = this.props, allowFreeform = _a.allowFreeform, text = _a.text, onMenuOpen = _a.onMenuOpen, onMenuDismissed = _a.onMenuDismissed, selectedIndices = _a.hoisted.selectedIndices;
var _b = this.state, isOpen = _b.isOpen, currentPendingValueValidIndex = _b.currentPendingValueValidIndex;
// If we are newly open or are open and the pending valid index changed,
// make sure the currently selected/pending option is scrolled into view
if (isOpen && (!prevState.isOpen || prevState.currentPendingValueValidIndex !== currentPendingValueValidIndex)) {
// Need this timeout so that the selectedElement ref is correctly updated
this._async.setTimeout(function () { return _this._scrollIntoView(); }, 0);
}
// if an action is taken that put focus in the ComboBox
// and If we are open or we are just closed, shouldFocusAfterClose is set,
// but we are not the activeElement set focus on the input
if (this._hasFocus() &&
(isOpen ||
(prevState.isOpen &&
!isOpen &&
this._focusInputAfterClose &&
this._autofill.current &&
document.activeElement !== this._autofill.current.inputElement))) {
this.focus(undefined /*shouldOpenOnFocus*/, true /*useFocusAsync*/);
}
// If we should focusAfterClose AND
// just opened/closed the menu OR
// are focused AND
// updated the selectedIndex with the menu closed OR
// are not allowing freeform OR
// the value changed
// we need to set selection
if (this._focusInputAfterClose &&
((prevState.isOpen && !isOpen) ||
(this._hasFocus() &&
((!isOpen &&
!this.props.multiSelect &&
prevProps.hoisted.selectedIndices &&
selectedIndices &&
prevProps.hoisted.selectedIndices[0] !== selectedIndices[0]) ||
!allowFreeform ||
text !== prevProps.text)))) {
this._onFocus();
}
this._notifyPendingValueChanged(prevState);
if (isOpen && !prevState.isOpen && onMenuOpen) {
onMenuOpen();
}
if (!isOpen && prevState.isOpen && onMenuDismissed) {
onMenuDismissed();
}
};
ComboBoxInternal.prototype.componentWillUnmount = function () {
this._async.dispose();
this._events.dispose();
};
// Primary Render
ComboBoxInternal.prototype.render = function () {
var id = this._id;
var errorMessageId = id + '-error';
var _a = this.props, className = _a.className, disabled = _a.disabled, required = _a.required, errorMessage = _a.errorMessage, _b = _a.onRenderContainer, onRenderContainer = _b === void 0 ? this._onRenderContainer : _b, _c = _a.onRenderLabel, onRenderLabel = _c === void 0 ? this._onRenderLabel : _c, _d = _a.onRenderList, onRenderList = _d === void 0 ? this._onRenderList : _d, _e = _a.onRenderItem, onRenderItem = _e === void 0 ? this._onRenderItem : _e, _f = _a.onRenderOption, onRenderOption = _f === void 0 ? this._onRenderOptionContent : _f, allowFreeform = _a.allowFreeform, customStyles = _a.styles, theme = _a.theme, persistMenu = _a.persistMenu, multiSelect = _a.multiSelect, _g = _a.hoisted, suggestedDisplayValue = _g.suggestedDisplayValue, selectedIndices = _g.selectedIndices, currentOptions = _g.currentOptions;
var isOpen = this.state.isOpen;
this._currentVisibleValue = this._getVisibleValue();
// Single select is already accessible since the whole text is selected
// when focus enters the input. Since multiselect appears to clear the input
// it needs special accessible text
var multiselectAccessibleText = multiSelect
? this._getMultiselectDisplayString(selectedIndices, currentOptions, suggestedDisplayValue)
: undefined;
var divProps = getNativeProps(this.props, divProperties, [
'onChange',
'value',
]);
var hasErrorMessage = errorMessage && errorMessage.length > 0 ? true : false;
this._classNames = this.props.getClassNames
? this.props.getClassNames(theme, !!isOpen, !!disabled, !!required, !!this._hasFocus(), !!allowFreeform, !!hasErrorMessage, className)
: getClassNames(getStyles(theme, customStyles), className, !!isOpen, !!disabled, !!required, !!this._hasFocus(), !!allowFreeform, !!hasErrorMessage);
var comboBoxWrapper = this._renderComboBoxWrapper(multiselectAccessibleText, errorMessageId);
return (React.createElement("div", __assign({}, divProps, { ref: this.props.hoisted.mergedRootRef, className: this._classNames.container }),
onRenderLabel({ props: this.props, multiselectAccessibleText: multiselectAccessibleText }, this._onRenderLabel),
comboBoxWrapper,
(persistMenu || isOpen) &&
onRenderContainer(__assign(__assign({}, this.props), { onRenderList: onRenderList,
onRenderItem: onRenderItem,
onRenderOption: onRenderOption, options: currentOptions.map(function (item, index) { return (__assign(__assign({}, item), { index: index })); }), onDismiss: this._onDismiss }), this._onRenderContainer),
React.createElement("div", __assign({ role: "region", "aria-live": "polite", "aria-atomic": "true", id: errorMessageId }, (hasErrorMessage ? { className: this._classNames.errorMessage } : { 'aria-hidden': true })), errorMessage !== undefined ? errorMessage : '')));
};
ComboBoxInternal.prototype._getPendingString = function (currentPendingValue, currentOptions, index) {
return currentPendingValue !== null && currentPendingValue !== undefined
? currentPendingValue
: indexWithinBounds(currentOptions, index)
? currentOptions[index].text
: '';
};
/**
* Returns a string that concatenates all of the selected values
* for multi