UNPKG

office-ui-fabric-react

Version:

Reusable React components for building experiences for Microsoft 365.

885 lines (884 loc) • 92.4 kB
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) {