UNPKG

@openui5/sap.m

Version:

OpenUI5 UI Library sap.m

1,743 lines (1,483 loc) 66.7 kB
/*! * OpenUI5 * (c) Copyright 2026 SAP SE or an SAP affiliate company. * Licensed under the Apache License, Version 2.0 - see LICENSE.txt. */ // Provides control sap.m.Tokenizer. sap.ui.define([ './library', "sap/base/i18n/Localization", 'sap/m/Button', 'sap/m/List', 'sap/m/StandardListItem', 'sap/m/ResponsivePopover', "sap/ui/core/Core", "sap/ui/core/ControlBehavior", 'sap/ui/core/Control', 'sap/ui/core/Element', "sap/ui/core/Lib", 'sap/ui/core/delegate/ScrollEnablement', "sap/ui/base/ManagedObjectObserver", 'sap/ui/Device', 'sap/ui/core/InvisibleText', 'sap/ui/core/ResizeHandler', './TokenizerRenderer', "sap/ui/dom/containsOrEquals", "sap/ui/events/KeyCodes", "sap/base/Log", "sap/ui/core/Theming", "sap/ui/core/theming/Parameters", // jQuery Plugin "scrollLeftRTL" "sap/ui/dom/jquery/scrollLeftRTL" ], function( library, Localization, Button, List, StandardListItem, ResponsivePopover, Core, ControlBehavior, Control, Element, Library, ScrollEnablement, ManagedObjectObserver, Device, InvisibleText, ResizeHandler, TokenizerRenderer, containsOrEquals, KeyCodes, Log, Theming, Parameters ) { "use strict"; var CSS_CLASS_NO_CONTENT_PADDING = "sapUiNoContentPadding"; var CSS_CLASS_TOKENIZER_POPUP = "sapMTokenizerTokensPopup"; var RenderMode = library.TokenizerRenderMode; var PlacementType = library.PlacementType; var ListMode = library.ListMode; var ListType = library.ListType; var ButtonType = library.ButtonType; /** * Constructor for a new Tokenizer. * * @param {string} [sId] ID for the new control, generated automatically if no ID is given * @param {object} [mSettings] Initial settings for the new control * * @class * <h3>Overview</h3> * A Тokenizer is a container for {@link sap.m.Token Tokens}. The tokenizer supports keyboard navigation and token selection. It also handles all actions associated with the tokens like adding, deleting, selecting and editing. * <h3>Structure</h3> * The tokenizer consists of two parts: * - Tokens - displays the available tokens. * - N-more indicator - contains the number of the remaining tokens that cannot be displayed due to the limited space. * The tokens are stored in the <code>tokens</code> aggregation. * The tokenizer can determine, by setting the <code>editable</code> property, whether the tokens in it are editable. * Still the Token itself can determine if it is <code>editable</code>. This allows you to have non-editable Tokens in an editable Tokenizer. * * @extends sap.ui.core.Control * @implements sap.ui.core.ISemanticFormContent * * @borrows sap.ui.core.ISemanticFormContent.getFormFormattedValue as #getFormFormattedValue * @borrows sap.ui.core.ISemanticFormContent.getFormValueProperty as #getFormValueProperty * @borrows sap.ui.core.ISemanticFormContent.getFormObservingProperties as #getFormObservingProperties * @borrows sap.ui.core.ISemanticFormContent.getFormRenderAsControl as #getFormRenderAsControl * @author SAP SE * @version 1.146.0 * * @constructor * @public * @since 1.22 * @alias sap.m.Tokenizer * @see {@link fiori:https://experience.sap.com/fiori-design-web/token/ Tokenizer} */ var Tokenizer = Control.extend("sap.m.Tokenizer", /** @lends sap.m.Tokenizer.prototype */ { metadata : { interfaces : [ "sap.ui.core.IFormContent", "sap.ui.core.ISemanticFormContent" ], library : "sap.m", properties : { /** * true if tokens shall be editable otherwise false */ editable : {type : "boolean", group : "Misc", defaultValue : true}, /** * Defines if the Tokenizer is enabled */ enabled : {type : "boolean", group : "Misc", defaultValue : true}, /** * Defines the width of the Tokenizer. */ width : {type : "sap.ui.core.CSSSize", group : "Dimension", defaultValue : null}, /** * Defines the maximum width of the Tokenizer. */ maxWidth : {type: "sap.ui.core.CSSSize", group: "Dimension", defaultValue : "100%"}, /** * Defines the mode that the Tokenizer will use: * <ul> * <li><code>sap.m.TokenizerRenderMode.Loose</code> mode shows all tokens, no matter the width of the Tokenizer</li> * <li><code>sap.m.TokenizerRenderMode.Narrow</code> mode forces the Tokenizer to show only as much tokens as possible in its width and add an n-More indicator</li> * </ul> * * <b>Note</b>: Have in mind that the <code>renderMode</code> property is used internally by the Tokenizer and controls that use the Tokenizer. Therefore, modifying this property may alter the expected behavior of the control. */ renderMode: {type : "string", group : "Misc", defaultValue : RenderMode.Narrow}, /** * Defines the count of hidden tokens if any. If this property is set to 0, the n-More indicator will not be shown. */ hiddenTokensCount: {type : "int", group : "Misc", defaultValue : 0, visibility: "hidden"}, /** * The ID of the opener of the tokens popup. */ opener: { type: "string", multiple: false, visibility: "hidden" }, /** * The name property to be used in the HTML code for the tokenizer (e.g. for HTML forms that send data to the server via submit). * @since 1.142.0 */ name: { type: "string", group: "Misc", defaultValue: "" }, /** * Determines whether the <code>Tokenizer</code> is in display only state. * * When set to <code>true</code>, the <code>Tokenizer</code> is not editable. * This setting is used for forms in review mode. * * @since 1.142.0 */ displayOnly : {type : "boolean", group : "Behavior", defaultValue : false}, /** * Changed when tokens are changed. The value for sap.ui.core.ISemanticFormContent interface. * Contains a comma-separated list of all token texts for form processing. * @private */ _semanticFormValue: {type: "string", group: "Behavior", defaultValue: "", visibility: "hidden"}, /** * Defines whether tokens are displayed on multiple lines. * @experimental since 1.142 */ multiLine: {type: "boolean", group: "Misc", defaultValue: false}, /** * Defines whether "Clear All" button is present. Ensure `multiLine` is enabled, otherwise `showClearAll` will have no effect. * @experimental since 1.142 */ showClearAll: {type: "boolean", group: "Misc", defaultValue: false} }, defaultAggregation : "tokens", aggregations : { /** * the currently displayed tokens */ tokens : {type : "sap.m.Token", multiple : true, singularName : "token"}, /** * Hidden text used for accesibility */ _tokensInfo: {type: "sap.ui.core.InvisibleText", multiple: false, visibility: "hidden"} }, associations : { /** * Association to controls / ids which describe this control (see WAI-ARIA attribute aria-describedby). */ ariaDescribedBy: {type: "sap.ui.core.Control", multiple: true, singularName: "ariaDescribedBy"}, /** * Association to controls / ids which label this control (see WAI-ARIA attribute aria-labelledby). */ ariaLabelledBy: {type: "sap.ui.core.Control", multiple: true, singularName: "ariaLabelledBy"} }, events : { /** * Fired when the tokens aggregation changed (add / remove token) * @deprecated Since version 1.82, replaced by <code>tokenDelete</code> event. */ tokenChange : { deprecated: true, parameters : { /** * type of tokenChange event. * There are four TokenChange types: "added", "removed", "removedAll", "tokensChanged". * Use sap.m.Tokenizer.TokenChangeType.Added for "added", sap.m.Tokenizer.TokenChangeType.Removed for "removed", sap.m.Tokenizer.TokenChangeType.RemovedAll for "removedAll" and sap.m.Tokenizer.TokenChangeType.TokensChanged for "tokensChanged". */ type: { type : "string"}, /** * the added token or removed token. * This parameter is used when tokenChange type is "added" or "removed". */ token: { type: "sap.m.Token"}, /** * the array of removed tokens. * This parameter is used when tokenChange type is "removedAll". */ tokens: { type: "sap.m.Token[]"}, /** * the array of tokens that are added. * This parameter is used when tokenChange type is "tokenChanged". */ addedTokens : { type: "sap.m.Token[]"}, /** * the array of tokens that are removed. * This parameter is used when tokenChange type is "tokenChanged". */ removedTokens : { type: "sap.m.Token[]"} } }, /** * Fired when the tokens aggregation changed due to a user interaction (add / remove token) * @deprecated Since version 1.82, replaced by <code>tokenDelete</code> event. * @since 1.46 */ tokenUpdate: { deprecated: true, allowPreventDefault : true, parameters: { /** * Type of tokenChange event. * There are two TokenUpdate types: "added", "removed" * Use sap.m.Tokenizer.TokenUpdateType.Added for "added" and sap.m.Tokenizer.TokenUpdateType.Removed for "removed". */ type: {type: "string"}, /** * The array of tokens that are added. * This parameter is used when tokenUpdate type is "added". */ addedTokens: {type: "sap.m.Token[]"}, /** * The array of tokens that are removed. * This parameter is used when tokenUpdate type is "removed". */ removedTokens: {type: "sap.m.Token[]"} } }, /** * Fired when a token is deleted by clicking icon, pressing backspace or delete button. * <Note:> Once the event is fired, application is responsible for removing / destroying the token from the aggregation. * @public * @since 1.82 */ tokenDelete: { parameters: { /** * The array of tokens that are removed. */ tokens: { type: "sap.m.Token[]" }, /** * Keycode of the key pressed for deletion (backspace or delete). */ keyCode: { type: "number" } } }, /** * Fired when the render mode of the Tokenizer changes between <code>Narrow</code> (collapsed) and <code>Loose</code> (expanded). * @public * @since 1.133 */ renderModeChange: { parameters: { /** * The render mode of the Tokenizer. */ renderMode: { type: "string" } } } } }, renderer: TokenizerRenderer }); var oRb = Library.getResourceBundleFor("sap.m"); Tokenizer.prototype.init = function() { // Do not allow text selection in the Tokenizer // If called with 'false', the method prevents the // default behavior and propagation of the 'selectstart' event. // For more info - check sap.ui.core.Control.js this.allowTextSelection(false); this._oTokensWidthMap = {}; this._oIndicator = null; this._bInForm = false; this._bShouldRenderTabIndex = null; this._iPopoverIndexToFocusAfterDelete = null; this._oScroller = new ScrollEnablement(this, this.getId() + "-scrollContainer", { horizontal : true, vertical : false, nonTouchScrolling : true }); // The ratio between the font size of the token and the font size of the items used in the // n-more popover. this._fFontSizeRatio = 1.0; if (ControlBehavior.isAccessibilityEnabled()) { var sAriaTokenizerContainToken = new InvisibleText({ text: oRb.getText("TOKENIZER_ARIA_NO_TOKENS") }); this.setAggregation("_tokensInfo", sAriaTokenizerContainToken); } // listen for delete event of tokens, it bubbles this.attachEvent("delete", function(oEvent) { var oToken = oEvent.getSource(); var aSelectedTokens = this.getSelectedTokens(); var aTokens = this._getVisibleTokens(); var iTokenIndex = aTokens.indexOf(oToken); // Mark that we're in a deletion operation this._bDeletionInProgress = true; // Prevent _bFocusFirstToken from interfering with deletion focus this._bFocusFirstToken = false; this._fireCompatibilityEvents(oToken, aSelectedTokens); this.fireEvent("tokenDelete", { tokens: [oToken] }); this._applyFocusAfterDeletion(iTokenIndex, aTokens.length, "click"); oEvent.cancelBubble(); }, this); this._bThemeApplied = false; this._handleThemeApplied = () => { this._bThemeApplied = true; Theming.detachApplied(this._handleThemeApplied); }; Theming.attachApplied(this._handleThemeApplied); this._observeTokens(); }; /** * Calculates the proper index for focusing after deletion. * For backspace: focus goes to previous token. * For delete and click: focus goes to next token (or previous if at the end). * * @private * @param {number} iDeletedIndex Index of the deleted item * @param {number} iTotalLength Total length of items * @param {string} sDeletionType Type of deletion: "click", "backspace", or "delete" * @returns {number} Index to focus after deletion */ Tokenizer.prototype._calculateFocusIndexAfterDeletion = function(iDeletedIndex, iTotalLength, sDeletionType) { // For Backspace: focus goes to previous token if (sDeletionType === "backspace") { return iDeletedIndex > 0 ? iDeletedIndex - 1 : 0; } // For Delete and Click: focus goes to next token (or previous if at the end) if (iDeletedIndex < iTotalLength - 1) { return iDeletedIndex; // Focus next item at same position } return iDeletedIndex - 1; // At the end - focus previous item }; /** * Ensures the focus index is within valid bounds of the items array. * * @private * @param {number} iIndex The calculated index * @param {array} aItems The array of tokens (sap.m.Token) or list items (sap.m.StandardListItem) * @returns {object|null} The token or list item to focus, or null if array is empty */ Tokenizer.prototype._getItemToFocusWithinBounds = function(iIndex, aItems) { if (aItems.length === 0) { return null; } var iIndexToFocus = iIndex; // Ensure index is within bounds if (iIndexToFocus < 0) { iIndexToFocus = 0; } else if (iIndexToFocus >= aItems.length) { iIndexToFocus = aItems.length - 1; } return aItems[iIndexToFocus]; }; /** * Calculates and applies focus to the correct token after deletion. * Uses setTimeout to ensure DOM is updated before focusing. * * @private * @param {number} iDeletedIndex Index of the deleted token * @param {number} iTotalLength Total number of tokens before deletion * @param {string} sDeletionType Type of deletion: "click", "backspace", or "delete" */ Tokenizer.prototype._applyFocusAfterDeletion = function(iDeletedIndex, iTotalLength, sDeletionType) { setTimeout(function() { // Calculate the focus index var iCalculatedIndex = this._calculateFocusIndexAfterDeletion(iDeletedIndex, iTotalLength, sDeletionType); var aRemainingTokens = this._getVisibleTokens(); var oTokenToFocus = this._getItemToFocusWithinBounds(iCalculatedIndex, aRemainingTokens); if (oTokenToFocus && oTokenToFocus.getDomRef()) { oTokenToFocus.focus(); } // Clear the deletion flag this._bDeletionInProgress = false; }.bind(this), 0); }; /** * Fires deprecated events for backwards compatibility * * @private * @param {object} oToken Updated token * @param {array} aSelectedTokens Array of selected changed tokens * @deprecated As of version 1.82, replaced by <code>tokenDelete</code> event */ Tokenizer.prototype._fireCompatibilityEvents = function(oToken, aSelectedTokens) { this.fireTokenChange({ type: Tokenizer.TokenChangeType.Removed, token: oToken, tokens: aSelectedTokens.length ? aSelectedTokens : [oToken], addedTokens: [], removedTokens: aSelectedTokens.length ? aSelectedTokens : [oToken] }); this.fireTokenUpdate({ type: Tokenizer.TokenChangeType.Removed, addedTokens: [], removedTokens: aSelectedTokens.length ? aSelectedTokens : [oToken] }); }; /** * Opens or closes the token popup when N-more label is pressed. * * @private */ Tokenizer.prototype._handleNMoreIndicatorPress = function () { this._bIsOpenedByNMoreIndicator = true; this._togglePopup(this.getTokensPopup(), this._getEffectiveOpener()); }; Tokenizer.prototype._getEffectiveOpener = function() { return document.getElementById(this.getProperty("opener")) || this.getDomRef(); }; /** * Getter for the list containing tokens. * * @returns {sap.m.List} The list * @private */ Tokenizer.prototype._getTokensList = function () { if (!this._oTokensList) { this._oTokensList = new List({ width: "auto", mode: ListMode.Delete }).attachDelete(this._handleListItemDelete, this) .attachItemPress(this._handleListItemPress, this); } return this._oTokensList; }; Tokenizer.prototype.getFormFormattedValue = function () { this._bInForm = true; const sTokens = this.getTokens() .map(function (oToken) { return oToken.getText(); }) .join(", "); // show en-dash in form display mode when empty if (!sTokens) { return "\u2013"; } return sTokens; }; Tokenizer.prototype.getFormValueProperty = function () { return "_semanticFormValue"; }; Tokenizer.prototype.getFormObservingProperties = function() { return ["_semanticFormValue"]; }; Tokenizer.prototype.getFormRenderAsControl = function () { // if there are no tokens, we render the tokenizer as en-dash and not as a control if (this.getDisplayOnly() && !this.getTokens().length) { return false; } else { return this.getDisplayOnly(); } }; /** * ISemanticFormContent interface works only with properties. The state of Tokenizer is kept as Tokens. * Update _semanticFormValue property so it'd match Tokenizer's state, but as a string which could be reused. * * @private */ Tokenizer.prototype.updateFormValueProperty = function () { this.setProperty("_semanticFormValue", this.getFormFormattedValue(), true); }; /** * Changes list mode. * * @param sMode {sap.m.ListMode} * @private */ Tokenizer.prototype._setPopoverMode = function (sMode) { var oPopover = this.getTokensPopup(); var oSettings = { showArrow: true, placement: PlacementType.VerticalPreferredBottom }; oPopover.setShowArrow(oSettings.showArrow); oPopover.setPlacement(oSettings.placement); this._getTokensList().setMode(sMode); }; /** * Fills a list by creating new list items and mapping them to certain token. * * There might be a filtering function, so only certain tokens can be mapped to a ListItem. * * @param oList {sap.m.List} * @param fnFilter {function} * @private */ Tokenizer.prototype._fillTokensList = function (oList, fnFilter) { var iIndexToFocus = this._iPopoverIndexToFocusAfterDelete; var bShouldRestoreFocus = iIndexToFocus !== undefined && iIndexToFocus !== null && this.getTokensPopup().isOpen(); // Clear the stored index before rebuilding if (bShouldRestoreFocus) { this._iPopoverIndexToFocusAfterDelete = null; } oList.destroyItems(); fnFilter = fnFilter ? fnFilter : function () { return true; }; this.getTokens() .filter(fnFilter) .forEach(function (oToken) { oList.addItem(this._mapTokenToListItem(oToken)); }, this); // Restore focus to the appropriate list item after deletion if (bShouldRestoreFocus) { var fnRestoreFocus = function() { oList.removeEventDelegate(this._oFocusRestoreDelegate); delete this._oFocusRestoreDelegate; var aListItems = oList.getItems(); var oItemToFocus = this._getItemToFocusWithinBounds(iIndexToFocus, aListItems); if (oItemToFocus && oItemToFocus.getDomRef()) { oItemToFocus.focus(); } }.bind(this); // Remove any existing delegate if (this._oFocusRestoreDelegate) { oList.removeEventDelegate(this._oFocusRestoreDelegate); } // Use event delegate to hook into the List's rendering cycle this._oFocusRestoreDelegate = { onAfterRendering: fnRestoreFocus }; oList.addEventDelegate(this._oFocusRestoreDelegate); } }; /** * Handles token deletion from the List. * * @param oEvent * @private */ Tokenizer.prototype._handleListItemDelete = function (oEvent) { var oListItem = oEvent.getParameter("listItem"); var sSelectedId = oListItem && oListItem.data("tokenId"); var oTokenToDelete; var oTokensList = this._getTokensList(); var aListItems = oTokensList.getItems(); var iDeletedIndex = aListItems.indexOf(oListItem); oTokenToDelete = this.getTokens().filter(function(oToken){ return (oToken.getId() === sSelectedId) && oToken.getEditable(); })[0]; if (oTokenToDelete) { // Calculate focus index using helper function this._iPopoverIndexToFocusAfterDelete = this._calculateFocusIndexAfterDeletion(iDeletedIndex, aListItems.length, "click"); this.fireTokenUpdate({ addedTokens: [], removedTokens: [oTokenToDelete], type: Tokenizer.TokenUpdateType.Removed }); this.fireTokenDelete({ tokens: [oTokenToDelete] }); this._adjustTokensVisibility(); } }; /** * Handles token press from the List. * * @param {sap.ui.base.Event} oEvent object * @private */ Tokenizer.prototype._handleListItemPress = function (oEvent) { var oListItem = oEvent.getParameter("listItem"); var sSelectedId = oListItem && oListItem.data("tokenId"); var oPressedToken = this.getTokens().filter(function(oToken){ return (oToken.getId() === sSelectedId); })[0]; if (oPressedToken) { oPressedToken.firePress(); } }; Tokenizer.prototype.focusFirstTokenItem = function () { const oTokenList = this._getTokensList(); const aTokenListItems = oTokenList.getItems(); if (aTokenListItems.length) { aTokenListItems[0].focus(); } }; Tokenizer.prototype._handleTokenizerAfterOpen = function (oEvent) { this.focusFirstTokenItem(); this.setRenderMode(RenderMode.Loose); this.fireRenderModeChange({ renderMode: RenderMode.Loose }); }; Tokenizer.prototype.addPopupClasses = function(oPopup) { if (oPopup.hasStyleClass(CSS_CLASS_TOKENIZER_POPUP)) { return; } oPopup.addStyleClass(CSS_CLASS_TOKENIZER_POPUP); oPopup.addStyleClass(CSS_CLASS_NO_CONTENT_PADDING); }; /** * Returns N-More Popover/Dialog. * * @private * @ui5-restricted sap.m.MultiInput, sap.m.MultiComboBox * @returns {sap.m.ResponsivePopover} The popover containing the tokens */ Tokenizer.prototype.getTokensPopup = function () { var oTokenList = this._getTokensList(); if (this._oPopup) { return this._oPopup; } this._oPopup = new ResponsivePopover({ showArrow: true, showCloseButton: false, showHeader: Device.system.phone, placement: PlacementType.Auto, offsetX: 0, offsetY: 3, horizontalScrolling: false, title: this._getDialogTitle(), content: this._getTokensList() }) .attachBeforeOpen(function () { var iWidestElement = this.getEditable() ? 120 : 32, // Paddings & Delete icons in editable mode && paddings in non-editable mode oPopup = this._oPopup, fnGetDensityMode = function () { var oParent = this.getDomRef() && this.getDomRef().parentElement; var sDensityMode = "Cozy"; if (!oParent) { return sDensityMode; } if (oParent.closest(".sapUiSizeCompact") !== null || document.body.classList.contains("sapUiSizeCompact")) { sDensityMode = "Compact"; } return sDensityMode; }.bind(this), fnGetRatioPromise = new Promise(function (resolve) { Parameters.get({ name: ["_sap_m_Tokenizer_FontSizeRatio" + fnGetDensityMode()], callback: function (sFontSizeRatio) { var fRatio = parseFloat(sFontSizeRatio); if (isNaN(fRatio)) { resolve(this._fFontSizeRatio); return; } resolve(fRatio); }.bind(this) }); }.bind(this)); if (oPopup.getContent && !oPopup.getContent().length) { oPopup.addContent(oTokenList); } this._fillTokensList(oTokenList); iWidestElement += Object.keys(this._oTokensWidthMap) // Object.values is not supported in IE .map(function (sKey) { return this._oTokensWidthMap[sKey]; }, this) .sort(function (a, b) { return a - b; }) // Just sort() returns odd results .pop() || 0; // Get the longest element in PX // The row below takes into consideration the ratio of the token's width to item's font size // which in turn is used to adjust the longest element's width so that there is no truncation // in the n-more popover. // width = width + (width * <<ratio converted in difference>>); fnGetRatioPromise.then(function (fRatio) { iWidestElement += Math.ceil(iWidestElement * ( 1 - fRatio )); oPopup.setContentWidth(iWidestElement + "px"); }); }, this) .attachAfterClose(this.afterPopupClose, this) .attachAfterOpen(this._handleTokenizerAfterOpen, this); this.addDependent(this._oPopup); this.addPopupClasses(this._oPopup); if (Device.system.phone) { this._oPopup.setBeginButton(new Button({ text: oRb.getText("SUGGESTIONSPOPOVER_CLOSE_BUTTON"), type: ButtonType.Emphasized, press: function () { this._oPopup.close(); }.bind(this) })); this._oPopup.setEndButton(new Button({ text: oRb.getText("TOKENIZER_CANCEL_BUTTON"), press: function () { this._oPopup.close(); }.bind(this) })); } return this._oPopup; }; /** * Function to execute after the n-more popover is closed. * * @protected */ Tokenizer.prototype.afterPopupClose = function () { if (!this.checkFocus()) { this.setRenderMode(RenderMode.Narrow); this.fireRenderModeChange({ renderMode: "Narrow" }); } }; Tokenizer.prototype._getDialogTitle = function () { var oResourceBundle = Library.getResourceBundleFor("sap.m"); var aLabeles = this.getAriaLabelledBy().map(function(sLabelID) { return Element.getElementById(sLabelID); }); return aLabeles.length ? aLabeles[0].getText?.() : oResourceBundle.getText("TOKENIZER_MOBILE_DIALOG_TITLE"); }; /** * Toggles the popover. * * @private * @ui5-restricted sap.m.MultiInput, sap.m.MultiComboBox */ Tokenizer.prototype._togglePopup = function () { var oOpenBy = this._getEffectiveOpener(), oPopover = this.getTokensPopup(), oPopoverIsOpen = oPopover.isOpen(), bEditable = this.getEditable(); this._setPopoverMode(bEditable ? ListMode.Delete : ListMode.None); if (oPopoverIsOpen) { oPopover.close(); } else { oPopover.openBy(oOpenBy); } }; /** * Generates a StandardListItem from token. * * @param {sap.m.Token} oToken The token * @private * @returns {sap.m.StandardListItem | null} The generated ListItem */ Tokenizer.prototype._mapTokenToListItem = function (oToken) { if (!oToken) { return null; } var oListItem = new StandardListItem({ selected: true, wrapping: true, type: ListType.Active, wrapCharLimit: 10000 }).data("tokenId", oToken.getId()); var fnOnSapShowHide = function (oEvent) { this._togglePopup(this.getTokensPopup()); if (this.getTokens().length && this._bIsOpenedByNMoreIndicator) { this.getTokens()[0].focus(); this._bIsOpenedByNMoreIndicator = false; } }; oListItem.setTitle(oToken.getText()); oListItem.addDelegate({ onkeydown: function (oEvent) { oEvent.preventDefault(); if (!((oEvent.ctrlKey || oEvent.metaKey) && oEvent.which === KeyCodes.I) && oEvent.which !== KeyCodes.ESCAPE) { return; } this._togglePopup(this.getTokensPopup()); if (this.getTokens().length && this._bIsOpenedByNMoreIndicator) { this.getTokens()[0].focus(); this._bIsOpenedByNMoreIndicator = false; } }, onsapshow: fnOnSapShowHide, onsaphide: fnOnSapShowHide }, this); return oListItem; }; /** Gets the width of the tokenizer that will be used for the calculation for hiding * or revealing the tokens. * * @returns {number} The width of the DOM in pixels. * @private */ Tokenizer.prototype._getPixelWidth = function () { var sMaxWidth = this.getMaxWidth(), iTokenizerWidth, oDomRef = this.getDomRef("inner") || this.getDomRef(), iPaddingLeft; if (!oDomRef) { return; } // The padding needs to be exluded from the calculations later on // as it is actually not an available space. iPaddingLeft = parseInt(this.$().css("padding-left")); if (sMaxWidth.indexOf("px") === -1) { // We need to use pixel width in order to calculate the space left for the Tokens. // In standalone Tokenizer, we take the width of the Tokenizer itself. iTokenizerWidth = oDomRef.clientWidth; } else { iTokenizerWidth = parseInt(this.getMaxWidth()); } return iTokenizerWidth - iPaddingLeft; }; /** * Function determines which tokens should be displayed and adds N-more label. * * @private */ Tokenizer.prototype._adjustTokensVisibility = function() { if (!this.getDomRef()) { return; } var iTokenizerWidth = this._getPixelWidth(), aTokens = this._getVisibleTokens(), iTokensCount = aTokens.length, iLabelWidth, iFreeSpace, iCounter, iFirstTokenToHide = -1; if (this.getMultiLine()) { aTokens.forEach(function (oToken) { const iTokenWidth = iTokenizerWidth - this._oTokensWidthMap[oToken.getId()]; if (iTokenWidth <= 0) { oToken.setTruncated(true); } }, this); return; } // find the index of the first overflowing token aTokens.some(function (oToken, iIndex) { iTokenizerWidth = iTokenizerWidth - this._oTokensWidthMap[oToken.getId()]; if (iTokenizerWidth < 0) { iFirstTokenToHide = iIndex; return true; } else { iFreeSpace = iTokenizerWidth; return false; } }, this); if (iTokensCount === 1 && iFirstTokenToHide !== -1) { this.setFirstTokenTruncated(true); return; } else if (iTokensCount === 1 && aTokens[0].getTruncated()) { this.setFirstTokenTruncated(false); } // adjust the visibility of the tokens if (iFirstTokenToHide > -1) { for (iCounter = 0; iCounter < iTokensCount; iCounter++) { if (iCounter >= iFirstTokenToHide) { aTokens[iCounter].addStyleClass("sapMHiddenToken"); } else { aTokens[iCounter].removeStyleClass("sapMHiddenToken"); } } this._handleNMoreIndicator(iTokensCount - iFirstTokenToHide); iLabelWidth = this._oIndicator.width(); // if there is not enough space after getting the actual indicator width, hide the last visible token // and update the n-more indicator if (iLabelWidth >= iFreeSpace) { iFirstTokenToHide = iFirstTokenToHide - 1; this._handleNMoreIndicator(iTokensCount - iFirstTokenToHide); aTokens[iFirstTokenToHide].addStyleClass("sapMHiddenToken"); } this._setHiddenTokensCount(iTokensCount - iFirstTokenToHide); } else { // if no token needs to be hidden, show all this._setHiddenTokensCount(0); this._showAllTokens(); } }; /** * Sets the first token truncation. * * @param {boolean} bValue The value to set * @returns {this} <code>this</code> instance for method chaining * @protected */ Tokenizer.prototype.setFirstTokenTruncated = function (bValue) { var oToken = this.getTokens()[0]; oToken && oToken.setTruncated(bValue); if (bValue) { this.addStyleClass("sapMTokenizerOneLongToken"); } else { this.removeStyleClass("sapMTokenizerOneLongToken"); } return this; }; /** * Checks if the token is one and truncated. * * @returns {boolean} * @protected */ Tokenizer.prototype.hasOneTruncatedToken = function () { return this.getTokens().length === 1 && this.getTokens()[0].getTruncated(); }; /** * Renders the N-more label. * @private * * @param {number} iHiddenTokensCount The number of hidden tokens * @returns {this} this instance for method chaining */ Tokenizer.prototype._handleNMoreIndicator = function (iHiddenTokensCount) { if (!this.getDomRef()) { return this; } if (iHiddenTokensCount) { let sLabelKey = "MULTIINPUT_SHOW_MORE_TOKENS"; if (iHiddenTokensCount === this._getVisibleTokens().length) { if (iHiddenTokensCount === 1) { sLabelKey = "TOKENIZER_SHOW_ALL_ITEM"; } else { sLabelKey = "TOKENIZER_SHOW_ALL_ITEMS"; } } this._oIndicator.html(oRb.getText(sLabelKey, [iHiddenTokensCount])); } return this; }; /** * Returns the visible tokens. * * @returns {array} Array of tokens * @private */ Tokenizer.prototype._getVisibleTokens = function () { return this.getTokens().filter(function (oToken) { return oToken.getVisible(); }); }; /** * Function makes all tokens visible, used for collapsed=false. * * @private */ Tokenizer.prototype._showAllTokens = function() { this._getVisibleTokens().forEach(function(oToken) { // TODO: Token should provide proper API for this oToken.removeStyleClass("sapMHiddenToken"); }); }; /** * Function returns the internally used scroll delegate. * * @public * @returns {sap.ui.core.delegate.ScrollEnablement} The scroll delegate */ Tokenizer.prototype.getScrollDelegate = function() { return this._oScroller; }; /** * Function scrolls the tokens to the end. * * @public */ Tokenizer.prototype.scrollToEnd = function() { var domRef = this.getDomRef(), bRTL = Localization.getRTL(), bIsFirstTokenFocused = this.getTokens()[0] && this.getTokens()[0].getDomRef() && this.getTokens()[0].getDomRef() === document.activeElement, iScrollWidth, scrollDiv; if (!this.getDomRef() || bIsFirstTokenFocused) { return; } scrollDiv = this.$().find(".sapMTokenizerScrollContainer")[0]; iScrollWidth = scrollDiv.scrollWidth; if (bRTL) { iScrollWidth *= -1; } domRef.scrollLeft = iScrollWidth; }; Tokenizer.prototype._registerResizeHandler = function(){ if (!this._sResizeHandlerId) { this._sResizeHandlerId = ResizeHandler.register(this, this._handleResize.bind(this)); } }; Tokenizer.prototype._handleResize = function(){ this._useCollapsedMode(this.getRenderMode()); }; /** * Function sets the tokenizer's width in pixels. * * @public * @param {number} nWidth The new width in pixels */ Tokenizer.prototype.setPixelWidth = function(nWidth) { if (typeof nWidth !== "number") { Log.warning("Tokenizer.setPixelWidth called with invalid parameter. Expected parameter of type number."); return; } this.setWidth(nWidth + "px"); if (this._oScroller) { this._oScroller.refresh(); } }; /** * Function scrolls the tokens to the start. * * @public * */ Tokenizer.prototype.scrollToStart = function() { var domRef = this.getDomRef(); if (!domRef) { return; } domRef.scrollLeft = 0; }; /** * Function returns the tokens' width. * * @public * * @returns {number} The complete width of all tokens */ Tokenizer.prototype.getScrollWidth = function(){ if (!this.getDomRef()) { return 0; } return this.$().children(".sapMTokenizerScrollContainer")[0].scrollWidth; }; Tokenizer.prototype.onBeforeRendering = function() { var aTokens = this.getTokens(); if (aTokens.length !== 1 && !this.getMultiLine()) { this.setFirstTokenTruncated(false); } aTokens.forEach(function(oToken, iIndex) { oToken.setProperty("editableParent", this.getEditable()); oToken.setProperty("enabledParent", this.getEnabled()); oToken.setProperty("posinset", iIndex + 1); oToken.setProperty("setsize", aTokens.length); }, this); this._setTokensAria(); if (this.getTokensPopup() && this.getTokensPopup().isOpen() ) { const nTokensLength = this._getTokensList().getItems().length; // If tokens were deleted or added - update the popover list if (aTokens.length !== nTokensLength){ this._fillTokensList(this._getTokensList()); } } }; /** * Called after the control is rendered. * * @private */ Tokenizer.prototype.onAfterRendering = function() { var sRenderMode = this.getRenderMode(); this._oIndicator = this.$().find(".sapMTokenizerIndicator"); if (this._bThemeApplied) { this._storeTokensSizes(); } // refresh the render mode (loose/narrow) based on whether an indicator should be shown // to ensure that the N-more label is rendered correctly this._useCollapsedMode(sRenderMode); this._registerResizeHandler(); if (!this.getEnabled() && this.getTokens().length) { this.getTokens().forEach(function(oToken) { if (!oToken.getDomRef()) { return; } oToken.getDomRef().setAttribute("tabindex", "-1"); }); } var oFocusableToken = this._getTokenToFocus(); if (oFocusableToken && oFocusableToken.getDomRef() && this.getEffectiveTabIndex()) { oFocusableToken.getDomRef().setAttribute("tabindex", "0"); } if (this._bFocusFirstToken && !this._bDeletionInProgress) { this.scrollToStart(); this._bFocusFirstToken = false; return; } if (sRenderMode === RenderMode.Loose && this._nMoreIndicatorPressed) { this.scrollToEnd(); } }; /** * Called after a new theme is applied. * * @private */ Tokenizer.prototype.onThemeChanged = function() { this._storeTokensSizes(); this._useCollapsedMode(this.getRenderMode()); }; /** * Stores sizes of the tokens for layout calculations. * * @private */ Tokenizer.prototype._storeTokensSizes = function() { var aTokens = this.getTokens(); aTokens.forEach(function(oToken){ if (oToken.getDomRef() && !oToken.$().hasClass("sapMHiddenToken") && !oToken.getTruncated()) { this._oTokensWidthMap[oToken.getId()] = oToken.$().outerWidth(true); } }, this); }; /** * Returns the first selected visible token or if there is none - the first visible token. * * @private */ Tokenizer.prototype._getTokenToFocus = function() { var aVisibleTokens = this._getVisibleTokens(); return aVisibleTokens.find((oToken) => oToken.getSelected()) || aVisibleTokens[0]; }; /** * Handles the setting of collapsed state. * * @param {string} sRenderMode If true collapses the tokenizer's content * @private */ Tokenizer.prototype._useCollapsedMode = function(sRenderMode) { var aTokens = this._getVisibleTokens(); if (!aTokens.length) { this._setHiddenTokensCount(0); return; } if (sRenderMode === RenderMode.Loose) { this._setHiddenTokensCount(0); this._showAllTokens(); return; } this._adjustTokensVisibility(); }; /** * Handle the focus leave event, deselects token. * * @param {jQuery.Event} oEvent The occuring event * @private */ Tokenizer.prototype.onsapfocusleave = function(oEvent) { // when focus goes to token, keep the select status, otherwise deselect all tokens if (document.activeElement === this.getDomRef() || !this.checkFocus()) { this._oSelectionOrigin = null; } if (!this.checkFocus() && !this._bDeletionInProgress) { this._bFocusFirstToken = true; this.setRenderMode(RenderMode.Narrow); this.fireRenderModeChange({ renderMode: "Narrow" }); } }; /** * Handles keyboard deletion with unified focus calculation. * * @private * @param {jQuery.Event} oEvent The keyboard event * @param {string} sDeletionType Type of deletion: "backspace" or "delete" */ Tokenizer.prototype._handleKeyboardDeletion = function(oEvent, sDeletionType) { var oFocusedToken = this.getTokens().filter(function (oToken) { return oToken.getFocusDomRef() === document.activeElement; })[0]; if (!oFocusedToken) { return; } var aDeletingTokens = this.getSelectedTokens().length ? this.getSelectedTokens() : [oFocusedToken]; var aTokens = this._getVisibleTokens(); // This ensures focus goes to the correct position var iDeletingIndex = Math.min.apply(null, aDeletingTokens.map(function(oToken) { return aTokens.indexOf(oToken); })); // Mark that we're in a deletion operation this._bDeletionInProgress = true; // Prevent _bFocusFirstToken from interfering with deletion focus this._bFocusFirstToken = false; oEvent.preventDefault(); this.fireTokenDelete({ tokens: aDeletingTokens, keyCode: oEvent.which }); this._applyFocusAfterDeletion(iDeletingIndex, aTokens.length, sDeletionType); }; Tokenizer.prototype.onsapbackspace = function (oEvent) { return this._handleKeyboardDeletion(oEvent, "backspace"); }; Tokenizer.prototype.onsapdelete = function (oEvent) { return this._handleKeyboardDeletion(oEvent, "delete"); }; /** * Handle the key down event for Ctrl+ a , Ctrl+ c and Ctrl+ x. * * @param {jQuery.Event}oEvent The occuring event * @private */ Tokenizer.prototype.onkeydown = function(oEvent) { var bSelectAll; var bShouldOpenPopover = (oEvent.ctrlKey || oEvent.metaKey) && oEvent.which === KeyCodes.I; if (!this.getEnabled()) { return; } if (oEvent.which === KeyCodes.TAB) { this._changeAllTokensSelection(false); } // ctrl/meta + c OR ctrl/meta + A if ((oEvent.ctrlKey || oEvent.metaKey) && oEvent.which === KeyCodes.A) { //to check how many tokens are selected before Ctrl + A in Tokenizer bSelectAll = this.getSelectedTokens().length < this._getVisibleTokens().length; if (this._getVisibleTokens().length > 0) { this.focus(); this._changeAllTokensSelection(bSelectAll); oEvent.preventDefault(); oEvent.stopPropagation(); } } // ctrl/meta + c OR ctrl/meta + Insert if ((oEvent.ctrlKey || oEvent.metaKey) && (oEvent.which === KeyCodes.C || oEvent.which === KeyCodes.INSERT)) { this._copy(); } // ctr/meta + x OR Shift + Delete if (((oEvent.ctrlKey || oEvent.metaKey) && oEvent.which === KeyCodes.X) || (oEvent.shiftKey && oEvent.which === KeyCodes.DELETE)) { if (this.getEditable()) { this._cut(); } else { this._copy(); } } if (bShouldOpenPopover) { oEvent.preventDefault(); oEvent.stopPropagation(); this._togglePopup(this.getTokensPopup()); return; } }; Tokenizer.prototype.onsaphide = function(oEvent) { this._togglePopup(this.getTokensPopup()); }; Tokenizer.prototype.onsapshow = Tokenizer.prototype.onsaphide; Tokenizer.prototype._shouldPreventModifier = function (oEvent) { var bShouldPreventOnMac = Device.os.macintosh && oEvent.metaKey; var bShouldPreventOnWindows = Device.os.windows && oEvent.altKey; return bShouldPreventOnMac || bShouldPreventOnWindows; }; /** * Pseudo event for pseudo 'previous' event with modifiers (Ctrl, Alt or Shift). * * @see #onsapprevious * @param {jQuery.Event} oEvent The event object * @private */ Tokenizer.prototype.onsappreviousmodifiers = function (oEvent) { if (!this._shouldPreventModifier(oEvent)) { this.onsapprevious(oEvent); } }; Tokenizer.prototype._observeTokens = function () { var oTokensObserver = new ManagedObjectObserver(function(oChange) { var sMutation = oChange.mutation; var oItem = oChange.child; switch (sMutation) { case "insert": // invalidate tokens to recalculate the sizes oItem.attachEvent("_change", this.invalidate, this); break; case "remove": oItem.detachEvent("_change", this.invalidate, this); break; default: break; } this.updateFormValueProperty(); this.invalidate(); }.bind(this)); oTokensObserver.observe(this, { aggregations: ["tokens"] }); }; /** * Pseudo event for pseudo 'next' event with modifiers (Ctrl, Alt or Shift). * * @see #onsapnext * @param {jQuery.Event} oEvent The event object * @private */ Tokenizer.prototype.onsapnextmodifiers = function (oEvent) { if (!this._shouldPreventModifier(oEvent)) { this.onsapnext(oEvent); } }; /** * Pseudo event for keyboard Home with modifiers (Ctrl, Alt or Shift). * * @see #onsaphome * @param {jQuery.Event} oEvent The event object * @private */ Tokenizer.prototype.onsaphomemodifiers = function (oEvent) { this._selectRange(false); this.scrollToStart(); }; /** * Pseudo event for keyboard Page Up with modifiers (Ctrl, Alt or Shift). * * @see #onsaphome * @param {jQuery.Event} oEvent The event object * @private */ Tokenizer.prototype.onsappageupmodifiers = function (oEvent) { this._selectRange(false); this.scrollToStart(); }; /** * Pseudo event for keyboard End with modifiers (Ctrl, Alt or Shift). * * @see #onsapend * @param {jQuery.Event} oEvent The event object * @private */ Tokenizer.prototype.onsapendmodifiers = function (oEvent) { this._selectRange(true); this.scrollToEnd(); }; /** * Pseudo event for keyboard Page Down with modifiers (Ctrl, Alt or Shift). * * @see #onsapend * @param {jQuery.Event} oEvent The event object * @private */ Tokenizer.prototype.onsappagedownmodifiers = function (oEvent) { this._selectRange(true); this.scrollToEnd(); }; /** * Sets the selection over a range of tokens. * * @param {boolean} bForwardSection True, if the selection is onward * @private */ Tokenizer.prototype._selectRange = function (bForwardSection) { var oRange = {}, oTokens = this._getVisibleTokens(), oFocusedControl = Element.closestTo(document.activeElement), iTokenIndex = oTokens.indexOf(oFocusedControl); if (!oFocusedControl || !oFocusedControl.isA("sap.m.Token")) { return; } if (bForwardSection) { oRange.start = iTokenIndex; oRange.end = oTokens.length - 1; } else { oRange.start = 0; oRange.end = iTokenIndex; } if (oRange.start < oRange.end) { for (var i = oRange.start; i <= oRange.end; i++) { oTokens[i].setSelected(true); } } }; /** * Handles the copy event. * * @private */ Tokenizer.prototype._copy = function() { this._fillClipboard("copy"); }; Tokenizer.prototype._fillClipboard = function (sShortcutName) { var aSelectedTokens = this.getSelectedTokens(); var sTokensTexts = aSelectedTokens.map(function(oToken) { return oToken.getText(); }).join("\r\n"); // Async clipboard API (works in secure contexts - HTTPS/localhost) if (navigator.clipboard?.writeText && window.isSecureContext) { navigator.clipboard.writeText(sTokensTexts); return; } /* fill clipboard with tokens' texts so parent can handle creation */ var cutToClipboard = function(oEvent) { if (oEvent.clipboardData) { oEvent.clipboardData.setData('text/plain', sTokensTexts); } else { oEvent.originalEvent.clipboardData.setData('text/plain', sTokensTexts); } oEvent.preventDefault(); }; document.addEventListener(sShortcutName, cutToClipboard); document.execCommand(sShortcutName); document.removeEventListener(sShortcutName, cutToClipboard); }; /** * Handles the cut event. * * @private */ Tokenizer.prototype._cut = function() { var aSelectedTokens = this.getSelectedTokens(); this._fillClipboard("cut"); // compatibility this.fireTokenChange({ type: Tokenizer.TokenChangeType.Removed, token: aSelectedTokens, tokens: aSelectedTokens, addedTokens: [], removedTokens: aSelectedTokens }); // compatibility this.fireTokenUpdate({ type: Tokenizer.TokenChangeType.Removed, addedTokens: [], removedTokens: aSelectedTokens }); this.fireTokenDelete({ tokens: aSelectedTokens }); }; /** * Adjusts the scrollLeft so that the given token is visible from its left side. * @param {sap.m.Token} oToken The token that will be fully visible * @private */ Tokenizer.prototype._ensureTokenVisible = function(oToken) { if (!oToken || !oToken.getDomRef() || !this.getDomRef()) { return; } var iTokenizerLeftOffset = this.$().offset().left, iTokenizerWidth = this.$().width(), iTokenLeftOffset = oToken.$().offset().left, bRTL = Localization.getRTL(), // Margins and borders are excluded from calculations therefore we need to add them explicitly. iTokenMargin = bRTL ? parseInt(oToken.$().css("margin-left")) : parseInt(oToken.$().css("margin-right")), iTokenBorder = parseInt(oToken.$().css("border-left-width")) + parseInt(oToken.$().css("border-right-width")), iTokenWidth = oToken.$().width() + iTokenMargin + iTokenBorder, iScrollLeft = bRTL ? this.$().scrollLeftRTL() : this.$().scrollLeft(), iLeftOffset = iScrollLeft - iTokenizerLeftOffset + iTokenLeftOffset, iRightOffset = iScrollLeft + (iTokenLeftOffset - iTokenizerLeftOffset + iTokenWidth - iTokenizerWidth); if (this._getVisibleTokens().indexOf(oToken) === 0) { this.$().scrollLeft(0); return; } if (iTokenLeftOffset < iTokenizerLeftOffset) { bRTL ? this.$().scrollLeftRTL(iLeftOffset) : this.$().scrollLeft(iLeftOffset); } if (iTokenLeftOffset - iTokenizerLeftOffset + iTokenWidth > iTokenizerWidth) { bRTL ? this.$().scrollLeftRTL(iRightOffset) : this.$().scrollLeft(iRightOffset); } }; Tokenizer.prototype.onfocusin = function (oEvent) { this.setRenderMode(RenderMode.Loose); this.fireRenderModeChange({ renderMode: "Loose" }); const oFirstToken = this.getTokens()[0]; // Don't set _bFocusFirstToken if we're handling a deletion if (!this._bDeletionInProgress) { this._bFocusFirstToken = oEvent.srcControl === oFirstToken; } if (!this._bFocusFirstToken && !this._bTokenToBeDeleted) { this._ensureTokenVisible(oEvent.srcControl); } }; Tokenizer.prototype.onmousedown = function (oEvent) { this._bTokenToBeDeleted = oEvent.target.matches(".sapMTokenIcon, .sapMTokenIcon *"); }; Tokenizer.prototype.ontap = function (oEvent) { var bShiftKey = oEvent.shiftKey, bCtrlKey = (oEvent.ctrlKey || oEvent.metaKey), oTargetToken = oEvent.getMark("tokenTap"), bDeleteToken = oEvent.getMark("tokenDeletePress"), aTokens = this._getVisibleTokens(), oFocusedToken, iFocusIndex, iIndex, iMinIndex, iMaxIndex; // Close popover if it's open and user clicks on a token in tokenizer if (oTargetToken && this._oPopup && this._oPopup.isOpen()) { this._oPopup.close(); } if (bDeleteToken || !oTargetToken || (!bShiftKey && bCtrlKey)) { // Ctrl this._oSelectionOrigin = null; return; } if (!bShiftKey) { // Simple click/tap // simple select, neither ctr