@sap-ux/ui-components
Version:
SAP UI Components Library
670 lines • 28.3 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.UIComboBox = exports.UIComboBoxLoaderType = exports.UISelectableOptionMenuItemType = void 0;
const react_1 = __importDefault(require("react"));
const react_2 = require("@fluentui/react");
Object.defineProperty(exports, "UISelectableOptionMenuItemType", { enumerable: true, get: function () { return react_2.SelectableOptionMenuItemType; } });
const UIHighlightMenuOption_1 = require("../UIContextualMenu/UIHighlightMenuOption");
require("./UIComboBox.scss");
require("./Callout.scss");
const UILoader_1 = require("../UILoader");
const Icons_1 = require("../Icons");
const ValidationMessage_1 = require("../../helper/ValidationMessage");
const UILabel_1 = require("../UILabel");
const UIDropdown_1 = require("../UIDropdown");
const UICallout_1 = require("../UICallout");
const utilities_1 = require("../../utilities");
const types_1 = require("../types");
var UIComboBoxLoaderType;
(function (UIComboBoxLoaderType) {
/**
* Loader within dropdown list
*/
UIComboBoxLoaderType["List"] = "List";
/**
* Loader within input
*/
UIComboBoxLoaderType["Input"] = "Input";
})(UIComboBoxLoaderType || (exports.UIComboBoxLoaderType = UIComboBoxLoaderType = {}));
/**
* UIComboBox component.
*
* @exports
* @class {UIComboBox}
* @extends {React.Component<UIComboBoxProps, UIComboBoxState>}
*/
class UIComboBox extends react_1.default.Component {
/**
* Initializes component properties.
*
* @param {UIComboBoxProps} props
*/
constructor(props) {
super(props);
// Reference to fluent ui combobox
this.comboBox = react_1.default.createRef();
this.comboboxDomRef = react_1.default.createRef();
this.menuDomRef = react_1.default.createRef();
this.selectedElement = react_1.default.createRef();
this.query = '';
this.ignoreOpenKeys = ['Meta', 'Control', 'Shift', 'Tab', 'Alt', 'CapsLock'];
this.isListHidden = false;
this.calloutCollisionTransform = new UICallout_1.CalloutCollisionTransform(this.comboboxDomRef, this.menuDomRef);
/**
* Method called on combobox option resolvation.
* We should not allow to add any custom option.
*
* @returns {IComboBoxOption[]} Array of combobox items.
*/
this.onResolveOptions = () => {
return this.props.options;
};
/**
* Default renderer for combobox item when highlight mode is enabled.
* We should pass highlight query within props and avoid rendering if it is hidden.
*
* @param {IComboBoxOption} props Combobox item props.
* @param {Function} defaultRender Combobox item default renderer.
* @returns {JSX.Element | null} Element to render.
*/
this._onRenderItem = (props, defaultRender) => {
if (defaultRender && props) {
// Use data for custom onRender functions
props.data = this.query;
if (props.title === undefined) {
// Apply title by default if property not provided
// In older fluent-ui versions it was applied by default, but behavior changed in version '8.66.2'
props.title = props.text;
}
const selected = props.index !== undefined && this.getCurrentSelectedIndex() === props.index;
return !props.hidden ? (react_1.default.createElement("div", { key: props.key, ref: selected ? this.selectedElement : undefined, className: `${selected ? 'ts-ComboBox--selected' : ''}` }, defaultRender(props))) : null;
}
return null;
};
/**
* Method called on combobox item render.
* We should pass query to it and avoid rendering if it is hidden.
*
* @param {IComboBoxOption} props Combobox item props.
* @param {Function} defaultRender Combobox item default renderer.
* @returns {JSX.Element | null} Element to render.
*/
this.onRenderItem = (props, defaultRender) => {
if (this.props.onRenderItem) {
return this.props.onRenderItem(props, this._onRenderItem.bind(this, props, defaultRender));
}
return this._onRenderItem(props, defaultRender);
};
/**
* Method to get current selected index.
*
* @returns {number | undefined} Selected item index.
*/
this.getCurrentSelectedIndex = () => {
const baseCombobox = this.comboBox.current;
if (!baseCombobox) {
return undefined;
}
if (baseCombobox.state.currentPendingValueValidIndex !== -1) {
return baseCombobox.state.currentPendingValueValidIndex;
}
return baseCombobox.props.hoisted.selectedIndices ? baseCombobox.props.hoisted.selectedIndices[0] : undefined;
};
/**
* Return a value for the placeholder.
*
* @returns {string}
*/
this.getPlaceholder = () => {
if (this.props.placeholder) {
return this.props.placeholder;
}
else {
// That string should be translated
return 'Search or select from dropdown';
}
};
/**
* Default renderer for combobox item's option/label when highlight mode is enabled.
* We should use different componenet which support highlighting - 'ComboboxSearchOption'.
*
* @param {IComboBoxOption} props Combobox item props.
* @param {Function} defaultRender Combobox item default renderer.
* @returns {JSX.Element | null} Element to render.
*/
this._onRenderOption = (props, defaultRender) => {
if (props && props.itemType !== react_2.SelectableOptionMenuItemType.Header) {
return react_1.default.createElement(UIHighlightMenuOption_1.UIHighlightMenuOption, { text: props.text, query: this.query });
}
return defaultRender ? defaultRender(props) : null;
};
/**
* Method called on combobox item's option/label render.
* We should use different componenet which support highlighting - 'ComboboxSearchOption'.
*
* @param {IComboBoxOption} props Combobox item props.
* @param {Function} defaultRender Combobox item default renderer.
* @returns {JSX.Element | null} Element to render.
*/
this.onRenderOption = (props, defaultRender) => {
if (this.props.onRenderOption) {
return this.props.onRenderOption(props, this._onRenderOption.bind(this, props, defaultRender));
}
return this._onRenderOption(props, defaultRender);
};
this.handleChange = (event, option) => {
if (option && this.props.onHandleChange) {
this.props.onHandleChange(option.key);
}
};
this.onRenderListLoading = () => {
const styles = {
label: {
fontSize: '11px',
fontWeight: 'normal'
}
};
return (react_1.default.createElement("div", { className: "option-loading" },
react_1.default.createElement(UILoader_1.UILoader, { label: "Loading", className: "uiLoaderXSmall", labelPosition: "right", styles: styles })));
};
this.onInput = this.onInput.bind(this);
this.reserQuery = this.reserQuery.bind(this);
this.onResolveOptions = this.onResolveOptions.bind(this);
this.onRenderItem = this.onRenderItem.bind(this);
this.onRenderOption = this.onRenderOption.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onClick = this.onClick.bind(this);
this.handleRefreshButton = this.handleRefreshButton.bind(this);
this.onPendingValueChanged = this.onPendingValueChanged.bind(this);
this.onMultiSelectChange = this.onMultiSelectChange.bind(this);
this.onScrollToItem = this.onScrollToItem.bind(this);
this.setFocus = this.setFocus.bind(this);
this.onRenderIcon = this.onRenderIcon.bind(this);
(0, react_2.initializeComponentRef)(this);
this.state = {};
}
/**
*
* @param {UIComboBoxProps} nextProps
* @returns {boolean}
*/
shouldComponentUpdate(nextProps) {
if (nextProps.options !== this.props.options && this.query) {
// Filter options
this.updateHiddenOptions(nextProps.options);
}
return true;
}
/**
* Updates hidden options.
*
* @param {IComboBoxOption[]} opts
*/
updateHiddenOptions(opts) {
this.isListHidden = true;
let currentGroup;
let isGroupVisible = false;
const updateGroupVisibility = () => {
if (currentGroup) {
currentGroup.hidden = !isGroupVisible;
}
};
for (const option of opts) {
if (option.itemType === react_2.SelectableOptionMenuItemType.Header) {
// Update visibility of previously processed group
updateGroupVisibility();
// Reset current group and visibility flag
currentGroup = option;
isGroupVisible = false;
}
else {
// Handle selectable item
const isVisible = this.isOptionVisibleByQuery(option, this.query);
option.hidden = !isVisible;
if (this.isListHidden && !option.hidden) {
this.isListHidden = false;
}
// Groups should be visible if at least one item is visible within group
isGroupVisible = isVisible || isGroupVisible;
}
}
updateGroupVisibility();
}
/**
* Determines whether an option should be hidden based on the current search query.
* Applies a custom filter if `customSearchFilter` is provided, otherwise uses the default
* search logic to match the `text` property (and `key` if `searchByKeyEnabled` is enabled).
*
* @param option - The option to evaluate for visibility.
* @param query - The current search query string.
* @returns `true` if the option should be hidden, `false` if it should be visible.
*/
isOptionVisibleByQuery(option, query) {
let isVisible;
if (this.props.customSearchFilter) {
// Apply external custom search
isVisible = this.props.customSearchFilter(query, option);
}
if (isVisible === undefined) {
// Apply internal search
isVisible = option.text.toLowerCase().includes(query);
// Consider 'key' of option if property 'searchByKeyEnabled' is enabled
if (this.props.searchByKeyEnabled && !isVisible) {
isVisible = option.key.toString().toLowerCase().includes(query);
}
}
return isVisible;
}
/**
* Method prevents cursor from jumping to the end of input.
*
* @param {React.FormEvent<IComboBox>} event Combobox event object
*/
setCaretPosition(event) {
if ((0, utilities_1.isHTMLInputElement)(event.target)) {
const input = event.target;
const selectionEnd = input.selectionEnd;
if (selectionEnd !== input.value.length) {
window.requestAnimationFrame(() => {
input.selectionStart = selectionEnd;
input.selectionEnd = selectionEnd;
});
}
}
}
/**
* Method filters options and hides unmatched options.
*
* @param {React.FormEvent<IComboBox>} event Combobox event object
*/
onInput(event) {
this.isListHidden = false;
if ((0, utilities_1.isHTMLInputElement)(event.target)) {
this.setCaretPosition(event);
const input = event.target;
this.query = input.value.trimStart().toLowerCase();
// Filter options
const baseCombobox = this.comboBox.current;
if (baseCombobox) {
this.updateHiddenOptions(baseCombobox.props.hoisted.currentOptions);
}
}
}
/**
* Method opens menu when user clicks on Combobox (input or button).
*
* @param event
*/
onClick(event) {
this.setCaretPosition(event);
const baseCombobox = this.comboBox.current;
const isOpen = baseCombobox?.state.isOpen;
const isDisabled = this.props.disabled;
if (this.props.openMenuOnClick && baseCombobox && !isOpen && !isDisabled) {
baseCombobox.focus(true);
}
}
/**
* Method handles keydown event and does following.
* 1. Fix for bug(in fluentui sources) regarding keyboard navigation when last item is not visible.
* 2. Opens list when user enters any key.
*
* @param {React.FormEvent<HTMLInputElement>} event Keydown event
*/
onKeyDown(event) {
let handled = false;
const baseCombobox = this.comboBox.current;
const isOpen = baseCombobox?.state.isOpen;
if (event.which === react_2.KeyCodes.down || event.which === react_2.KeyCodes.up) {
handled = this._setCyclingNavigation(event.which === react_2.KeyCodes.down);
}
if (handled) {
// Do not handle keydown of combobox
event.preventDefault();
event.stopPropagation();
}
else if (!this.ignoreOpenKeys.includes(event.key) && baseCombobox && !isOpen) {
// Open dropdown list on first key press instead of showing it right after focus
baseCombobox.focus(true);
}
}
/**
* Method resets search query.
*/
reserQuery() {
this.query = '';
for (const option of this.props.options) {
delete option.hidden;
}
this.isListHidden = false;
this.setState({
isListHidden: this.isListHidden
});
}
/**
* Method which determines what is next visible item - it is used to fix bug in fluentui sources..
*
* @param {number} index Current index.
* @param {boolean} forward Dirrection to look up.
* @returns {ComboboxItemInfo | null} Combobox item object.
*/
getNextVisibleItem(index, forward) {
const limit = this.props.options.length;
while (index >= 0 && index < limit) {
if (this.props.options[index] && !this.props.options[index].hidden) {
return {
option: this.props.options[index],
index
};
}
index = index + (forward ? 1 : -1);
}
return null;
}
/**
* Method is used as fix for bug(in fluentui sources) regarding keyboard navigation when last item is not visible.
*
* @param {boolean} forward Dirrection to look up.
* @returns {boolean} Returs true if method changed navigation.
*/
_setCyclingNavigation(forward) {
const baseCombobox = this.comboBox.current;
if (baseCombobox && baseCombobox.state.currentPendingValueValidIndex >= 0) {
// Check if there exists any visible item in that direction
let index = baseCombobox.state.currentPendingValueValidIndex;
const limit = this.props.options.length - 1;
index = index + (forward ? 1 : -1);
let option = this.getNextVisibleItem(index, forward);
if (option) {
// Item exists - no need for custom logic as we are fixing issue in base control
return false;
}
// Item was not found - make circular navigation
option = this.getNextVisibleItem(forward ? 0 : limit, forward);
if (option) {
// We fixed issue in base combobox control -
// when last item is not visible and we navigate it
baseCombobox.setState({
currentPendingValueValidIndex: option.index,
currentPendingValue: option.option.text
});
return true;
}
}
return false;
}
/**
* Method for property 'useComboBoxAsMenuMinWidth' - method resolves current dropdown width and updates state with latest value.
* 'minWidth' from state is used to set callout size in render.
*/
calculateMenuMinWidth() {
const comboBoxWrapper = this.comboBox.current?._comboBoxWrapper.current;
if (comboBoxWrapper &&
this.props.useComboBoxAsMenuMinWidth &&
comboBoxWrapper.clientWidth !== this.state.minWidth) {
this.setState({
minWidth: comboBoxWrapper.clientWidth
});
}
}
/**
* Method called only when property 'highlight' is true.
* Method called after each value live change - we need recheck if there is any visible item after search is done.
* 1. If there no any visible item - we hide menu callout.
* 2. If there is any visible item - we show menu callout.
*
* @param option Selected option.
* @param index Selected option's index.
* @param value Text value entered in input.
*/
onPendingValueChanged(option, index, value) {
if (this.state.isListHidden !== this.isListHidden) {
this.setState({
isListHidden: this.isListHidden
});
}
if (this.props?.onPendingValueChanged) {
this.props?.onPendingValueChanged(option, index, value);
}
}
/**
* Public method to close menu callout if it is open.
*/
dismissMenu() {
if (this.comboBox.current) {
this.comboBox.current.dismissMenu();
}
}
/**
* Public method to set the focus on the combo box.
*/
setFocus() {
if (this.comboBox.current) {
this.comboBox.current.focus();
}
}
/**
* Method called only when property 'onRefresh' has been defined.
* It is called when click on the refresh buttonIcon.
*/
handleRefreshButton() {
const baseCombobox = this.comboBox.current;
if (this.props.useComboBoxAsMenuMinWidth) {
this.calculateMenuMinWidth();
}
if (this.props.onRefresh) {
this.props.onRefresh();
baseCombobox?.focus(true);
}
}
/**
* Handle multiselect change by avoiding 'blur' event.
* Problem is that blur event uses suggested value and toggles selection for it.
*
* @param {React.FormEvent<IComboBox>} event on change
* @param {IComboBoxOption} option changed option
* @param {number} index option index
* @param {string} value changed value
*/
onMultiSelectChange(event, option, index, value) {
// Ignore toggle if change triggered by blur
if (this.props.onChange && event.type !== 'blur') {
this.props.onChange(event, option, index, value);
}
}
/**
* Method is used to fix bug in fluent ui.
* Bug is for multiselect combobox - when keyboard navigation is used, then scrollbar position is not updated to make selected option visible in viewport.
*/
onScrollToItem() {
// Multi select only
const selectedElement = this.selectedElement.current;
if (!this.props.multiSelect || !selectedElement) {
return;
}
const offsetParent = selectedElement.offsetParent;
if (offsetParent) {
const size = offsetParent.clientHeight;
const scrollSize = offsetParent.scrollHeight;
if (scrollSize > size) {
const elementTop = selectedElement.offsetTop;
const elementBottom = elementTop + selectedElement.clientHeight;
const scrollTop = offsetParent.scrollTop;
const scrollBottom = scrollTop + size;
if (!(elementTop >= scrollTop && elementBottom <= scrollBottom)) {
// Outline node is not visible in viewport
const diff = elementBottom > scrollBottom ? elementBottom - scrollBottom : elementTop - scrollTop;
offsetParent.scrollTop = scrollTop + diff;
}
}
}
}
/**
* Method returns class names string depending on props and component state.
*
* @param {InputValidationMessageInfo} messageInfo Error/warning message if applied
* @returns {string} Class names of root combobox element.
*/
getClassNames(messageInfo) {
const { readOnly, disabled } = this.props;
const errorSuffix = messageInfo.message ? ValidationMessage_1.MESSAGE_TYPES_CLASSNAME_MAP.get(messageInfo.type) : undefined;
let classNames = `ts-ComboBox${messageInfo.message ? ' ts-ComboBox--' + errorSuffix : ''}`;
if (readOnly && !disabled) {
classNames += ' ts-ComboBox--readonly';
}
if (disabled) {
classNames += ' ts-ComboBox--disabled';
}
if ((0, UIDropdown_1.isDropdownEmpty)(this.props)) {
classNames += ' ts-ComboBox--empty';
}
return classNames;
}
/**
* Method returns properties for Autofill component(combobox's inner compnent for text input).
* Method handles 'highlight' and 'readOnly' properties.
*
* @returns {IAutofillProps} Properties for Autofill component.
*/
getAutofillProps() {
const { highlight, readOnly, disabled } = this.props;
const autofill = {};
// Handle search highligh
if (highlight) {
autofill.onKeyDownCapture = this.onKeyDown;
}
const tabIndex = 'tabIndex' in this.props ? this.props.tabIndex : undefined;
// Handle readOnly property
if (readOnly && !disabled) {
autofill.readOnly = readOnly;
autofill.tabIndex = tabIndex;
// Adjust aria attributes for readonly
autofill['aria-disabled'] = undefined;
autofill['aria-readonly'] = true;
}
else if (disabled) {
autofill.disabled = undefined;
autofill.readOnly = true;
autofill.tabIndex = tabIndex;
}
// This is a workaround for FluentUI not handling aria-invalid correctly for a ComboBox.
// For the time being we add logic here to set aria-invalid based on the presence of
// an errorMessage.
autofill['aria-invalid'] = this.props.errorMessage !== undefined && this.props.errorMessage !== '';
return autofill;
}
/**
* Method returns if loader should be displayed for passed type.
*
* @param type Loader's place
* @returns True if loader should be displayed for passed type.
*/
isLoaderApplied(type) {
const { isLoading } = this.props;
if (Array.isArray(isLoading)) {
return isLoading.includes(type);
}
// Boolean value matches List option
return !!isLoading && type === UIComboBoxLoaderType.List;
}
/**
* Method renders dropdown expand icon.
* Overwritten renderer to replace expand icon with loader when combobox has laoding property set.
*
* @param props Button properties
* @param defaultRender Default icon renderer
* @returns React element to render.
*/
onRenderIcon(props, defaultRender) {
if (this.isLoaderApplied(UIComboBoxLoaderType.Input)) {
const styles = {
label: {
fontSize: '11px',
fontWeight: 'normal'
}
};
return react_1.default.createElement(UILoader_1.UILoader, { className: "uiLoaderXSmall", labelPosition: "right", styles: styles });
}
return defaultRender?.(props) ?? null;
}
/**
* @returns {JSX.Element}
*/
render() {
const messageInfo = (0, ValidationMessage_1.getMessageInfo)(this.props);
let disabled = this.props.isForceEnabled ? false : !this.props.options.length;
if (this.props.readOnly) {
disabled = true;
}
return (react_1.default.createElement("div", { ref: this.props.wrapperRef, className: this.getClassNames(messageInfo) },
react_1.default.createElement(react_2.ComboBox, { componentRef: this.comboBox, ref: this.comboboxDomRef, disabled: disabled, iconButtonProps: {
iconProps: {
iconName: Icons_1.UiIcons.ArrowDown
},
onRenderIcon: this.onRenderIcon
}, styles: {
label: {
...UILabel_1.labelGlobalStyle,
...(this.props.disabled && {
opacity: '0.4'
}),
...(this.props.required && {
selectors: {
'::after': {
content: types_1.REQUIRED_LABEL_INDICATOR,
color: 'var(--vscode-inputValidation-errorBorder)',
paddingRight: 12
}
}
})
},
errorMessage: [messageInfo.style]
}, ...this.props, calloutProps: {
calloutMaxHeight: 200,
popupProps: {
ref: this.menuDomRef
},
className: 'ts-Callout ts-Callout-Dropdown',
styles: {
...(this.props.useComboBoxAsMenuMinWidth && {
calloutMain: {
minWidth: this.state.minWidth,
display: this.state.isListHidden ? 'none' : undefined
}
})
},
...this.props.calloutProps,
...(0, UIDropdown_1.getCalloutCollisionTransformationPropsForDropdown)(this, this.calloutCollisionTransform)
}, ...(this.props.highlight && {
onInput: this.onInput,
onMenuDismissed: this.reserQuery,
onResolveOptions: this.onResolveOptions,
onRenderItem: this.onRenderItem,
onRenderOption: this.onRenderOption,
placeholder: this.getPlaceholder(),
onPendingValueChanged: this.onPendingValueChanged
}), autofill: this.getAutofillProps(), ...(this.props.useComboBoxAsMenuMinWidth && {
// Use 'onMenuOpen', because there can be dynamic size of combobox
onMenuOpen: this.calculateMenuMinWidth.bind(this)
}), ...(this.props.openMenuOnClick && {
onClick: this.onClick.bind(this)
}), ...(this.props.onRefresh && {
onMenuOpen: this.handleRefreshButton,
onChange: this.handleChange
}), ...(this.isLoaderApplied(UIComboBoxLoaderType.List) && {
onRenderList: this.onRenderListLoading
}), ...(this.props.multiSelect && {
onScrollToItem: this.onScrollToItem,
...(this.props.onChange && {
onChange: this.onMultiSelectChange
})
}), errorMessage: messageInfo.message })));
}
}
exports.UIComboBox = UIComboBox;
// Default values for public component properties
UIComboBox.defaultProps = { openMenuOnClick: true };
//# sourceMappingURL=UIComboBox.js.map