office-ui-fabric-react
Version:
Reusable React components for building experiences for Microsoft 365.
885 lines (884 loc) • 92.4 kB
JavaScript
import { __assign, __decorate, __extends, __spreadArrays } from "tslib";
import * as React from 'react';
import { Autofill } from '../Autofill/index';
import { initializeComponentRef, css, customizable, divProperties, findElementRecursive, findIndex, focusAsync, getId, getNativeProps, isIOS, isMac, KeyCodes, shallowCompare, mergeAriaAttributeValues, warnMutuallyExclusive, Async, EventGroup, } from '../../Utilities';
import { Callout } from '../../Callout';
import { Checkbox } from '../../Checkbox';
import { CommandButton, IconButton } from '../../Button';
import { DirectionalHint } from '../../common/DirectionalHint';
import { getCaretDownButtonStyles, getOptionStyles, getStyles } from './ComboBox.styles';
import { getClassNames, getComboBoxOptionClassNames } from './ComboBox.classNames';
import { KeytipData } from '../../KeytipData';
import { Label } from '../../Label';
import { SelectableOptionMenuItemType, getAllSelectedOptions } from '../../utilities/selectableOption/index';
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 class that is used to wrap all ComboBox options.
* This is used to customize when we want to rerender components,
* so we don't rerender every option every time render is executed.
*/
var ComboBoxOptionWrapper = /** @class */ (function (_super) {
__extends(ComboBoxOptionWrapper, _super);
function ComboBoxOptionWrapper() {
return _super !== null && _super.apply(this, arguments) || this;
}
ComboBoxOptionWrapper.prototype.render = function () {
return this.props.render();
};
ComboBoxOptionWrapper.prototype.shouldComponentUpdate = function (newProps) {
// The render function will always be different, so we ignore that prop
return !shallowCompare(__assign(__assign({}, this.props), { render: undefined }), __assign(__assign({}, newProps), { render: undefined }));
};
return ComboBoxOptionWrapper;
}(React.Component));
var COMPONENT_NAME = 'ComboBox';
var ComboBox = /** @class */ (function (_super) {
__extends(ComboBox, _super);
function ComboBox(props) {
var _this = _super.call(this, props) || this;
_this._root = React.createRef();
/** The input aspect of the comboBox */
_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.props.disabled === true) {
return;
}
if (_this._autofill.current) {
if (useFocusAsync) {
focusAsync(_this._autofill.current);
}
else {
_this._autofill.current.focus();
}
if (shouldOpenOnFocus) {
_this.setState({
isOpen: true,
});
}
}
// Programatically 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 iput 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 = _this._normalizeToString(_this._currentVisibleValue);
if (comboBox.value !== visibleValue) {
// If visibleValue is empty, ensure that the empty string is used
return visibleValue || '';
}
return comboBox.value;
};
_this._renderComboBoxWrapper = function (multiselectAccessibleText, errorMessageId, keytipAttributes) {
if (keytipAttributes === void 0) { keytipAttributes = {}; }
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;
var _c = _this.state, isOpen = _c.isOpen, suggestedDisplayValue = _c.suggestedDisplayValue;
// If the combobox 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 combobox should behave like a picker.
var placeholder = _this._hasFocus() && _this.props.multiSelect && multiselectAccessibleText
? multiselectAccessibleText
: placeholderProp;
return (React.createElement("div", { "data-ktp-target": keytipAttributes['data-ktp-target'], ref: _this._comboBoxWrapper, id: _this._id + 'wrapper', className: _this._classNames.root },
React.createElement(Autofill, __assign({ "data-ktp-execute-target": keytipAttributes['data-ktp-execute-target'], "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, keytipAttributes['aria-describedby'], errorMessageId)
: mergeAriaAttributeValues(ariaDescribedBy, keytipAttributes['aria-describedby']), "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: 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.state.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;
var _b = _this.state, selectedIndices = _b.selectedIndices, currentPendingValueValidIndex = _b.currentPendingValueValidIndex, currentOptions = _b.currentOptions, currentPendingValue = _b.currentPendingValue, suggestedDisplayValue = _b.suggestedDisplayValue, isOpen = _b.isOpen;
var currentPendingIndexValid = _this._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 _this._normalizeToString(currentPendingValue);
}
else if (!_this.state.isOpen && currentPendingValue) {
return _this._indexWithinBounds(currentOptions, index)
? currentPendingValue
: _this._normalizeToString(suggestedDisplayValue);
}
else {
return _this._indexWithinBounds(currentOptions, index)
? currentOptions[index].text
: _this._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;
}
if (_this.props.onInputValueChange) {
_this.props.onInputValueChange(updatedValue);
}
_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 () {
if (_this._autofill.current && _this._autofill.current.inputElement) {
_this._autofill.current.inputElement.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.state.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.setState({
currentOptions: 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.setState({
currentOptions: newOptionsFromPromise,
});
}
});
}
}
};
/**
* OnBlur handler. Set the focused state to false
* and submit any pending value
*/
// eslint-disable-next-line deprecation/deprecation
_this._onBlur = function (event) {
// Do nothing if the blur is coming from something
// inside the comboBox root or the comboBox menu since
// it we are not really bluring 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 &&
// when event coming from withing the comboBox title
((_this._root.current && _this._root.current.contains(relatedTarget)) ||
// when event coming from within the comboBox list menu
(_this._comboBoxMenu.current &&
(_this._comboBoxMenu.current.contains(relatedTarget) ||
// when event coming from the callout containing the comboBox list menu (ex: when scrollBar of the
// Callout is clicked) checks if the relatedTarget is a parent of _comboBoxMenu
findElementRecursive(_this._comboBoxMenu.current, function (element) { return element === relatedTarget; }))))) {
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) {
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 ? calloutProps.className : undefined), 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(__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 _a = props.onRenderItem, onRenderItem = _a === void 0 ? _this._onRenderItem : _a, label = props.label, ariaLabel = props.ariaLabel;
var queue = { items: [] };
var renderedList = [];
var id = _this._id;
var emptyQueue = function () {
var newGroup = queue.id
? [
React.createElement("div", { role: "group", key: queue.id, "aria-labelledby": queue.id }, queue.items),
]
: queue.items;
renderedList = __spreadArrays(renderedList, newGroup);
// Flush items and id
queue = { items: [] };
};
var placeRenderedOptionIntoQueue = function (item, index) {
/*
Case Header
empty queue if it's not already empty
ensure unique ID for header and set queue ID
push header into queue
Case Divider
push divider into queue if not first item
empty queue if not already empty
Default
push item into queue
*/
switch (item.itemType) {
case SelectableOptionMenuItemType.Header:
queue.items.length > 0 && emptyQueue();
id = id + item.key;
queue.items.push(onRenderItem(__assign(__assign({ id: id }, item), { index: index }), _this._onRenderItem));
queue.id = id;
break;
case SelectableOptionMenuItemType.Divider:
index > 0 && queue.items.push(onRenderItem(__assign(__assign({}, item), { index: index }), _this._onRenderItem));
queue.items.length > 0 && emptyQueue();
break;
default:
queue.items.push(onRenderItem(__assign(__assign({}, item), { index: index }), _this._onRenderItem));
}
};
// Place options into the queue. Queue will be emptied anytime a Header or Divider is encountered
props.options.forEach(function (item, index) {
placeRenderedOptionIntoQueue(item, index);
});
// Push remaining items into all renderedList
queue.items.length > 0 && emptyQueue();
return (React.createElement("div", { id: id + '-list', className: _this._classNames.optionsContainer, "aria-labelledby": label && id + '-label', "aria-label": ariaLabel && !label ? ariaLabel : undefined, role: "listbox" }, renderedList));
};
// 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 = _this.props.onRenderOption, onRenderOption = _a === void 0 ? _this._onRenderOptionContent : _a;
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 = _this._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;
var _b = _this.state, isOpen = _b.isOpen, currentOptions = _b.currentOptions, currentPendingValueValidIndexOnHover = _b.currentPendingValueValidIndexOnHover;
// Take note if we are processing an alt (option) or meta (command) keydown.
// See comment in _onInputKeyUp for reasoning.
_this._lastKeyDownWasAltOrMeta = _this._isAltOrMeta(ev);
if (disabled) {
_this._handleInputWhenDisabled(ev);
return;
}
var index = _this._getPendingSelectedIndex(false /* includeCurrentPendingValue */);
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 comboBox is open, flip the open state
if (isOpen) {
_this._setOpenStateAndFocusOnClose(!isOpen, false /* focusInputAfterClose */);
}
// Allow TAB to propigate
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.state.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 comboBox 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
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
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 && _this._isAltOrMeta(ev);
_this._lastKeyDownWasAltOrMeta = false;
var shouldHandleKey = keyPressIsAltOrMetaAlone && !(isMac() || isIOS());
if (disabled) {
_this._handleInputWhenDisabled(ev);
return;
}
switch (ev.which) {
case KeyCodes.space:
// If we are not allowing freeform and are not autoComplete
// make space expand/collapse the comboBox
// 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 comboBox
* and the input when not allowing freeform. This
* toggles the expand/collapse state of the comboBox (if enbled)
*/
_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',
ariaLabel: 'label',
});
_this._id = props.id || getId('ComboBox');
var selectedKeys = _this._buildDefaultSelectedKeys(props.defaultSelectedKey, props.selectedKey);
_this._isScrollIdle = true;
_this._processingTouch = false;
_this._gotMouseMove = false;
_this._processingClearPendingInfo = false;
var initialSelectedIndices = _this._getSelectedIndices(props.options, selectedKeys);
_this.state = {
isOpen: false,
selectedIndices: initialSelectedIndices,
focusState: 'none',
suggestedDisplayValue: undefined,
currentOptions: _this.props.options,
currentPendingValueValidIndex: -1,
currentPendingValue: undefined,
currentPendingValueValidIndexOnHover: HoverStatus.default,
};
return _this;
}
Object.defineProperty(ComboBox.prototype, "selectedOptions", {
/**
* All selected options
*/
get: function () {
var _a = this.state, currentOptions = _a.currentOptions, selectedIndices = _a.selectedIndices;
return getAllSelectedOptions(currentOptions, selectedIndices);
},
enumerable: true,
configurable: true
});
ComboBox.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);
}
}
};
ComboBox.prototype.UNSAFE_componentWillReceiveProps = function (newProps) {
// Update the selectedIndex and currentOptions state if
// the selectedKey, value, or options have changed
if (newProps.selectedKey !== this.props.selectedKey ||
newProps.text !== this.props.text ||
newProps.options !== this.props.options) {
var selectedKeys = this._buildSelectedKeys(newProps.selectedKey);
var indices = this._getSelectedIndices(newProps.options, selectedKeys);
this.setState({
selectedIndices: indices,
currentOptions: newProps.options,
});
if (newProps.selectedKey === null) {
this.setState({
suggestedDisplayValue: undefined,
});
}
}
};
ComboBox.prototype.componentDidUpdate = function (prevProps, prevState) {
var _this = this;
var _a = this.props, allowFreeform = _a.allowFreeform, text = _a.text, onMenuOpen = _a.onMenuOpen, onMenuDismissed = _a.onMenuDismissed;
var _b = this.state, isOpen = _b.isOpen, selectedIndices = _b.selectedIndices, 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 &&
prevState.selectedIndices &&
selectedIndices &&
prevState.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();
}
};
ComboBox.prototype.componentWillUnmount = function () {
this._async.dispose();
this._events.dispose();
};
// Primary Render
ComboBox.prototype.render = function () {
var _this = this;
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, keytipProps = _a.keytipProps, persistMenu = _a.persistMenu, multiSelect = _a.multiSelect;
var _g = this.state, isOpen = _g.isOpen, suggestedDisplayValue = _g.suggestedDisplayValue;
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(this.state.selectedIndices, this.state.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 = keytipProps ? (React.createElement(KeytipData, { keytipProps: keytipProps, disabled: disabled }, function (keytipAttributes) {