UNPKG

@openui5/sap.m

Version:

OpenUI5 UI Library sap.m

729 lines (637 loc) 22.6 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.MenuWrapper sap.ui.define([ 'sap/ui/core/library', 'sap/ui/core/Control', 'sap/m/MenuWrapperRenderer', 'sap/ui/dom/containsOrEquals', 'sap/ui/events/KeyCodes', 'sap/ui/Device', 'sap/ui/core/Lib', 'sap/base/i18n/Localization', 'sap/ui/events/PseudoEvents' ], function( coreLibrary, Control, MenuWraperRenderer, containsOrEquals, KeyCodes, Device, Library, Localization, PseudoEvents ) { "use strict"; // shortcut for sap.ui.core.ItemSelectionMode const ItemSelectionMode = coreLibrary.ItemSelectionMode; const DELAY_SUBMENU_TIMER = 300; /** * Constructor for a new MenuWrapper. * * @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 * The <code>sap.m.MenuWrapper</code> control represents a single-level menu with menu items. * * @extends sap.ui.core.Control * * @author SAP SE * @version 1.146.0 * * @constructor * @private * @since 1.136.0 * @alias sap.m.MenuWrapper */ const MenuWrapper = Control.extend("sap.m.MenuWrapper", /** @lends sap.m.MenuWrapper.prototype */ { metadata : { library : "sap.m", properties: { /** * Specifies the title to be displayed when the menu is viewed on mobile devices within this wrapper. * <b>Note:</b> This property is only used when the menu is opened on mobile devices. */ title: { type: "string", defaultValue: "" }, /** * Defines whether the menu in this wrapper is a sub-menu or not. */ isSubmenu: { type: "boolean", defaultValue: false } }, aggregations: { /** * Defines the items contained within this control. */ items: { type: "sap.m.IMenuItem", multiple: true, singularName: "item", bindable: "bindable" } }, events: { /** * Fired when a <code>MenuItem</code> is selected. */ itemSelected: { enableEventBubbling: true, parameters: { /** * The <code>MenuItem</code> which was selected. */ item : {type : "sap.m.IMenuItem" } } }, /** * Fired when the menu popover must be closed. * * @since 1.136.0 */ closePopover: { enableEventBubbling: true, parameters: { /** * Whether to bubble the event to the root <code>Menu</code>. */ bubbleToRoot : {type : "boolean" }, /** * The menu item that triggered the submenu close (if any). */ origin: {type: "sap.m.IMenuItem"} } }, /** * Fired when the submenu must be closed. * Because of possible top menu beforeClose prevention, the submenu close should be done by the top menu. * That's why this event is fired to propagate the item to the top menu. * * @since 1.136.0 */ closeItemSubmenu: { enableEventBubbling: true, parameters: { /** * Item to be propagated to the top menu. */ item: {type: "sap.m.IMenuItem"} } } }, renderer: MenuWraperRenderer } }); MenuWrapper.prototype.init = function() { this._aStyleClasses = []; this._oRb = Library.getResourceBundleFor("sap.m"); }; MenuWrapper.prototype.onBeforeRendering = function() { const aGroups = this.getItems().filter(function(oItem) { return oItem.isA("sap.m.MenuItemGroup"); }); // associate menu items with their respective group and ensure single selection for menu items in groups configured with single selection mode aGroups.forEach(function(oGroup) { const aItems = oGroup.getItems(); aItems.forEach(function(oItem) { oItem.setAssociation("_group", oGroup); oItem._itemSelectionMode = oGroup.getItemSelectionMode(); }); if (oGroup.getItemSelectionMode() === ItemSelectionMode.SingleSelect) { oGroup._ensureSingleSelection(); } }); }; MenuWrapper.prototype.onAfterRendering = function() { if (document.body.classList.contains("sapUiSizeCompact") || this.hasStyleClass("sapUiSizeCompact") || this.getDomRef()?.closest(".sapUiSizeCompact")) { this.addStyleClass("sapUiSizeCompact"); } }; MenuWrapper.prototype.onmouseover = function(oEvent) { const oItem = this.getItemByDomRef(oEvent.target); if (!oItem) { return; } if (oItem !== this.oHoveredItem) { this._setHoveredItem(oItem); } this._handleSubmenusAppearance(oItem, false/*, true*/); }; MenuWrapper.prototype.onclick = function(oEvent) { const oItem = this.getItemByDomRef(oEvent.target); if (!oItem) { return; } if (oEvent.target.closest(`#${CSS.escape(oItem.getId())}-endContent`)) { this.fireClosePopover({ bubbleToRoot: true }); return; } // If Shift is pressed, and the item is in Single- or Multi- selectable group, // the menu popover should not be closed. this._bPreventPopoverClose = this._isShiftKeyPressed(oEvent); this._selectItem(oItem, true); oEvent.preventDefault(); oEvent.stopPropagation(); }; MenuWrapper.prototype.onsapselect = function(oEvent) { this._sapSelectOnKeyDown = true; oEvent.preventDefault(); oEvent.stopPropagation(); }; MenuWrapper.prototype.onsapselectmodifiers = function(oEvent) { this._sapSelectOnKeyDown = true; // If Shift is pressed, and the item is in Single- or Multi- selectable group, // the menu popover should not be closed. this._bPreventPopoverClose = this._isShiftKeyPressed(oEvent); oEvent.preventDefault(); oEvent.stopPropagation(); }; MenuWrapper.prototype.onkeydown = function(oEvent) { const iIdx = this.oHoveredItem ? this._getVisibleItems().indexOf(this.oHoveredItem) : -1, bRtl = Localization.getRTL(), iLeftArrow = bRtl ? KeyCodes.ARROW_RIGHT : KeyCodes.ARROW_LEFT, iRightArrow = bRtl ? KeyCodes.ARROW_LEFT : KeyCodes.ARROW_RIGHT; let bPreventDefault = true; if (iIdx === -1) { return; } if (!oEvent.altKey && oEvent.keyCode === KeyCodes.ARROW_DOWN) { // Go to the next selectable item this._setHoveredItem(this._getNextFocusableItem(iIdx, 1), true); } else if (!oEvent.altKey && oEvent.keyCode === KeyCodes.ARROW_UP) { // Go to the previous selectable item this._setHoveredItem(this._getPrevFocusableItem(iIdx, 1), true); } else if (oEvent.keyCode === iLeftArrow) { // Close submenu (if opened) or go to the previous end content control (if any) const aEndContent = this.oHoveredItem ? this.oHoveredItem.getEndContent() : []; if (aEndContent.length) { this._handleEndContentNavigation(oEvent, aEndContent); } else if (this.getIsSubmenu()) { this.fireClosePopover(); } } else if (oEvent.keyCode === iRightArrow) { // Open submenu (if any) or go to the next end content control (if any) const aEndContent = this.oHoveredItem ? this.oHoveredItem.getEndContent() : []; if (this.oHoveredItem && this.oHoveredItem._hasSubmenu()) { this._handleSubmenusAppearance(this.oHoveredItem, true); } else if (aEndContent.length) { this._handleEndContentNavigation(oEvent, aEndContent); } } else if (oEvent.keyCode === KeyCodes.ESCAPE) { this.fireClosePopover(); } else if (this._bOpenedByMenuButton && (oEvent.keyCode === KeyCodes.F4 || (oEvent.altKey && (oEvent.keyCode === KeyCodes.ARROW_UP || oEvent.keyCode === KeyCodes.ARROW_DOWN)))) { // Close menu on F4 or Alt+Arrow Up/Down when opened by MenuButton this.fireClosePopover(); } else if (oEvent.keyCode === KeyCodes.HOME) { // Go to the first selectable item this._setHoveredItem(this._getNextFocusableItem(-1, 1), true); } else if (oEvent.keyCode === KeyCodes.END) { // Go to the last selectable item this._setHoveredItem(this._getPrevFocusableItem(this._getItems().length, 1), true); } else if (oEvent.keyCode === KeyCodes.PAGE_UP) { // Go to the previous page of items this._setHoveredItem(this._getPrevFocusableItem(iIdx, this._getPageSize()), true); } else if (oEvent.keyCode === KeyCodes.PAGE_DOWN) { // Go to the next page of items this._setHoveredItem(this._getNextFocusableItem(iIdx, this._getPageSize()), true); } else if (oEvent.keyCode === KeyCodes.TAB) { // Close the popover and focus the next/previous element if (this.getIsSubmenu()){ oEvent.preventDefault(); } this.fireClosePopover(); } else if (oEvent.keyCode === KeyCodes.F6 && this.oFocusedEndContentItem) { this.oHoveredItem.focus(); this.oFocusedEndContentItem = null; } else { // Do not prevent default for keys that are not handled by the menu bPreventDefault = false; } if (bPreventDefault && !oEvent.metaKey && !oEvent.altKey) { oEvent.preventDefault(); oEvent.stopPropagation(); } }; MenuWrapper.prototype.onkeyup = function(oEvent) { // Similar to sapselect, but executed on keyup: // Using keydown causes side effects such as: // If the selection results in closing the menu and the focus returns to the initiating element (e.g., a button), // the keyup event may trigger on the caller. In Firefox, this can fire a click event on the button — undesirable behavior. // The attribute _sapSelectOnKeyDown helps prevent issues in the reverse scenario. For example, when the spacebar is pressed // on a Button, opening the menu may cause the space keyup event to select the first item immediately. // Device checks are in place due to new functionality in iOS 13, which introduces desktop view functionality for tablets. if (!this._sapSelectOnKeyDown && !this._bPreventPopoverClose) { return; } else { this._sapSelectOnKeyDown = false; } if (!PseudoEvents.events.sapselect.fnCheck(oEvent) && oEvent.keyCode !== KeyCodes.ENTER && !this._bPreventPopoverClose) { return; } this._selectItem(this.oHoveredItem, false, true); oEvent.preventDefault(); oEvent.stopPropagation(); }; MenuWrapper.prototype.onsapbackspacemodifiers = MenuWrapper.prototype.onsapbackspace; MenuWrapper.prototype.onfocusin = function(oEvent) { const oTarget = this.getItemByDomRef(oEvent.target); if (oTarget) { this.oHoveredItem = oTarget; } }; /** * Returns the menu item that corresponds to the given DOM reference. * * @param {HTMLElement} oDomRef The DOM reference * @returns {sap.m.IMenuItem | null} The menu item that corresponds to the given DOM reference */ MenuWrapper.prototype.getItemByDomRef = function(oDomRef) { const oItems = this._getItems(); for (let i = 0; i < oItems.length; i++) { const oItem = oItems[i], oItemRef = oItem.getDomRef(); if (containsOrEquals(oItemRef, oDomRef)) { return oItem; } } return null; }; /** * Determines whether only the Shift key is pressed during the event. * @param {jQuery.Event} oEvent Keyboard event. * @private * @returns {boolean} True if only the Shift key is pressed, false otherwise */ MenuWrapper.prototype._isShiftKeyPressed = function(oEvent) { return oEvent.shiftKey && !oEvent.metaKey && !oEvent.altKey && !oEvent.ctrlKey; }; /** * Adds, removes, toggles, or sets class name(s) in the internal _aStyleClasses array. * <b>Note: </b> Later these classes should be applied when menu item open its submenu popover. * @param {string} sClassNames - Class name(s) as a string (space-separated for multiple) * @param {"addStyleClass"|"removeStyleClass"|"toggleStyleClass"|"setStyleClass"} sMethod - Operation mode: add, remove, toggle, or set a class name(s) * @private */ MenuWrapper.prototype._processStyleClasses = function(sClassNames, sMethod) { if (sMethod === "setStyleClass") { this._aStyleClasses = []; sMethod = "addStyleClass"; } if (sClassNames === "") { return; } const aClassNames = sClassNames.split(/\s+/).filter(Boolean); const sCurrentStyleClasses = this._aStyleClasses.join(" "); aClassNames.forEach((sClass) => { const iIdx = this._aStyleClasses.indexOf(sClass); const bAddOperation = (sMethod === "addStyleClass" || sMethod === "toggleStyleClass") && iIdx === -1; const bRemoveOperation = (sMethod === "removeStyleClass" || sMethod === "toggleStyleClass") && iIdx !== -1; if (bAddOperation) { this._aStyleClasses.push(sClass); } else if (bRemoveOperation) { this._aStyleClasses.splice(iIdx, 1); } }); if (this.oOpenedSubmenuParent) { const sNewStyleClasses = this._aStyleClasses.join(" "); const oPopover = this.oOpenedSubmenuParent._getPopover(); this.oOpenedSubmenuParent._getMenuWrapper()._processStyleClasses(sCurrentStyleClasses, "removeStyleClass"); this.oOpenedSubmenuParent._getMenuWrapper()._processStyleClasses(this._aStyleClasses.join(" "), "addStyleClass"); if (oPopover) { oPopover.removeStyleClass(sCurrentStyleClasses); oPopover.addStyleClass(sNewStyleClasses); } } }; /** * Menu item selection handler. * * @param {sap.m.IMenuItem} oItem The selected menu item * @param {boolean} bWithClick Whether the selection is done with click or not * @param {boolean} bSkipDelay Whether the submenu opening delay should be skipped or not * @private */ MenuWrapper.prototype._selectItem = function(oItem, bWithClick, bSkipDelay) { if (this.oFocusedEndContentItem) { // selected end content item this.fireClosePopover({ bubbleToRoot: true }); return; } if (!oItem || !oItem.getEnabled()) { // item is disabled return; } if (oItem._hasSubmenu()) { // item has submenu this._handleSubmenusAppearance(oItem, !bWithClick/*, !bSkipDelay*/); } else if (oItem.isInteractive && oItem.isInteractive()) { // item is allowed to be pressed if (oItem._getItemSelectionMode && oItem._getItemSelectionMode() !== ItemSelectionMode.None) { oItem.setSelected(!oItem.getSelected()); } else { this._bPreventPopoverClose = false; } this.fireItemSelected({item: oItem}); oItem.firePress({item: oItem}); if (!this._bPreventPopoverClose) { this.fireClosePopover({ bubbleToRoot: true, origin: oItem }); } this._bPreventPopoverClose = false; } }; MenuWrapper.prototype._handleEndContentNavigation = function(oEvent, aEndContent) { const iIdx = this.oFocusedEndContentItem ? aEndContent.indexOf(this.oFocusedEndContentItem) : -1, bRtl = Localization.getRTL(), iRightArrow = bRtl ? KeyCodes.ARROW_LEFT : KeyCodes.ARROW_RIGHT; let iNewIdx; if (oEvent.keyCode === iRightArrow) { iNewIdx = iIdx + 1 >= aEndContent.length ? aEndContent.length - 1 : iIdx + 1; } else { iNewIdx = iIdx - 1 < 0 ? 0 : iIdx - 1; } const oFocusableItem = aEndContent[iNewIdx]; if (!oFocusableItem) { this.oFocusedEndContentItem = null; return; } if (oFocusableItem && oFocusableItem !== this.oFocusedEndContentItem && oFocusableItem.isFocusable()) { this.oFocusedEndContentItem = oFocusableItem; oFocusableItem.focus(); } }; /** * Closes any opened submenu (if any) and afterwards opens submenu for the given item (if any). * * @param {sap.m.IMenuItem} oItem the item which submenu should be opened * @param {boolean} bWithKeyboard Whether the submenu is opened via keyboard * @param {boolean } bDelayed whether the submenu is opened with delay or not * @private */ MenuWrapper.prototype._handleSubmenusAppearance = function(oItem, bWithKeyboard, bDelayed) { if (this.oOpenedSubmenuParent && this.oOpenedSubmenuParent._hasSubmenu() && !this.oOpenedSubmenuParent._getPopover().isOpen()) { this.oOpenedSubmenuParent.removeStyleClass("sapMMenuItemSubMenuOpen"); this.oOpenedSubmenuParent = null; } if (!bWithKeyboard && !Device.system.phone && oItem === this.oOpenedSubmenuParent) { return; } const bHasSubmenu = oItem._hasSubmenu(); this._closeOpenedSubmenu(/*!bWithKeyboard && bHasSubmenu*/); this._discardOpenSubmenuDelayed(); if (!bHasSubmenu || !oItem.getEnabled()) { return; } if (bDelayed) { this._openSubmenuDelayed(oItem, bWithKeyboard); } else { this._openSubmenu(oItem, bWithKeyboard); } }; /** * Returns list of items stored in <code>items</code> aggregation. If any items are part of a group, * it returns the individual items within the group instead of the group item itself. * * @returns {sap.m.MenuItem} List of all menu items * @private */ MenuWrapper.prototype._getItems = function() { const aItems = []; const findItems = (aItemItems) => { aItemItems.forEach((oItem) => { if (!this._isMenuItemGroup(oItem)) { aItems.push(oItem); } else { findItems(oItem.getItems()); } }); }; findItems(this.getItems()); return aItems; }; /** * Returns the list of visible menu items. * * @returns {sap.m.MenuItem} List of visible menu items * @private */ MenuWrapper.prototype._getVisibleItems = function() { return this._getItems().filter((oItem) => oItem.getVisible()); }; /** * Returns the previous focusable menu item in this menu (if any). * * @param {number} iIdx The index of currently selectable menu item. * @param {number} iStep The step to decrease index with * @returns {sap.m.IMenuItem} the previous selectable menu item * @private */ MenuWrapper.prototype._getPrevFocusableItem = function(iIdx, iStep) { const aItems = this._getVisibleItems(); let iPrevIdx = iIdx, iCurrentIdx = iIdx; if (!aItems.length) { return undefined; } while (iCurrentIdx > 0 && iStep > 0) { iCurrentIdx--; if (aItems[iCurrentIdx].isFocusable && aItems[iCurrentIdx].isFocusable()) { iStep--; iPrevIdx = iCurrentIdx; } } return iPrevIdx !== iIdx ? aItems[iPrevIdx] : undefined; }; /** * Returns the next focusable menu item in this menu (if any). * * @param {number} iIdx The index of currently selectable menu item * @param {number} iStep The increment value used to increase the index * @returns {sap.m.IMenuItem} The next selectable menu item located at the resulting index after applying the increment value * @private */ MenuWrapper.prototype._getNextFocusableItem = function(iIdx, iStep) { const aItems = this._getVisibleItems(); let iNextIdx = iIdx, iCurrentIdx = iIdx; if (!aItems.length) { return undefined; } while (iCurrentIdx < aItems.length - 1 && iStep > 0) { iCurrentIdx++; if (aItems[iCurrentIdx].isFocusable && aItems[iCurrentIdx].isFocusable()) { iStep--; iNextIdx = iCurrentIdx; } } return iNextIdx !== iIdx ? aItems[iNextIdx] : undefined; }; /** * Sets the hovered menu item. * * @param {sap.m.IMenuItem} oItem the menu item to be set as hovered * @param {boolean} bCloseOpenedSubmenu whether the opened submenu should be closed or not * @private */ MenuWrapper.prototype._setHoveredItem = function(oItem, bCloseOpenedSubmenu) { if (!oItem || oItem === this.oHoveredItem) { return; } if (oItem) { bCloseOpenedSubmenu && this._closeOpenedSubmenu(); this.oHoveredItem = oItem; oItem.focus(); this.oFocusedEndContentItem = null; } }; /** * Checks if an item is a MenuItemGroup or not. * * @param {sap.m.IMenuItem} oItem The item to be checked * @returns {boolean} Whether the item is a MenuItemGroup or not * @private */ MenuWrapper.prototype._isMenuItemGroup = function(oItem) { return !!oItem.getItemSelectionMode; }; /** * Returns the page size for the menu. * * @returns {number} The page size for the menu * @private */ MenuWrapper.prototype._getPageSize = function() { return 5; }; /** * Closes already opened submenu (if any). * * @param {boolean} bDelayed Whether the submenu is closed with delay or not * @private */ MenuWrapper.prototype._closeOpenedSubmenu = function(bDelayed) { if (this.oOpenedSubmenuParent) { const oSubmenuPopover = this.oOpenedSubmenuParent._getPopover(), oMenuWrapper = this.getItems().includes(this.oOpenedSubmenuParent) ? this.oOpenedSubmenuParent._getMenuWrapper() : null; if (oSubmenuPopover && oSubmenuPopover._oControl) { oSubmenuPopover._oControl._oPreviousFocus = undefined; } this.oOpenedSubmenuParent = null; if (!oMenuWrapper) { return; } if (bDelayed) { setTimeout(() => { oMenuWrapper.fireClosePopover(); }, DELAY_SUBMENU_TIMER); } else { oMenuWrapper.fireClosePopover(); } } }; /** * Opens the submenu of the given item (if any). * * @param {Object} oItem The item opener * @param {boolean} bWithKeyboard Whether the submenu is opened via keyboard * * @private */ MenuWrapper.prototype._openSubmenu = function(oItem, bWithKeyboard) { if (!oItem) { return; } const oPopover = oItem._getPopover(); oItem._getMenuWrapper()._processStyleClasses(this._aStyleClasses.join(" "), "setStyleClass"); oPopover.setInitialFocus(bWithKeyboard ? null : oItem); oItem._openSubmenu(); oItem._setExtraContent(this.getDomRef()); this.oOpenedSubmenuParent = oItem; oItem.oParentWrapper = this; }; /** * Opens the submenu of the given item with a delay. * * @param {sap.m.IMenuItem} oItem The item that have submenu * @param {boolean} bWithKeyboard Whether the submenu is opened via keyboard */ MenuWrapper.prototype._openSubmenuDelayed = function(oItem, bWithKeyboard) { this._delayedSubmenuTimer = setTimeout(() => this._openSubmenu(oItem, bWithKeyboard) , DELAY_SUBMENU_TIMER); }; /** * Discards the delayed submenu opening. * * @private */ MenuWrapper.prototype._discardOpenSubmenuDelayed = function() { if (this._delayedSubmenuTimer) { clearTimeout(this._delayedSubmenuTimer); this._delayedSubmenuTimer = null; } }; /** * Returns the number of items with icon in the menu. * * @returns {number} The number of items with icon * @private */ MenuWrapper.prototype._getItemsWithIconCount = function() { return this._getVisibleItems().filter((oItem) => oItem.getIcon && oItem.getIcon()).length; }; MenuWrapper.prototype._getAccessibilityEnabled = function() { }; /** * Configures the accessibility information necessary for rendering the menu items. * * @private */ MenuWrapper.prototype._prepareItemsAccessibilityInfo = function() { const aItems = this._getVisibleItems(), iFocusableItemsCount = aItems.filter((oItem) => oItem.isCountable && oItem.isCountable()).length; let iIndex = 1; aItems.forEach((oItem) => { const oAccInfo = { bAccessible: true }; if (oItem.isCountable && oItem.isCountable()) { oAccInfo["posinset"] = iIndex; oAccInfo["setsize"] = iFocusableItemsCount; iIndex++; } oItem._oAccInfo = oAccInfo; }); }; return MenuWrapper; });