UNPKG

@eclipse-scout/core

Version:
762 lines (670 loc) 24.5 kB
/* * Copyright (c) 2010, 2025 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import { Action, ActionKeyStroke, aria, arrays, CloneOptions, ContextMenuPopup, EnumObject, HtmlComponent, icons, InitModelOf, MenuBarPopup, MenuDestinations, MenuEventMap, MenuExecKeyStroke, MenuKeyStroke, MenuModel, MenuOrder, ObjectOrChildModel, Popup, PopupAlignment, PropertyChangeEvent, scout, strings, tooltips, TreeVisitor, TreeVisitResult } from '../index'; export type SubMenuVisibility = EnumObject<typeof Menu.SubMenuVisibility>; export type MenuStyle = EnumObject<typeof Menu.MenuStyle>; export type MenuFilter = (menus: Menu[], destination: MenuDestinations) => Menu[]; export class Menu extends Action implements MenuModel { declare model: MenuModel; declare eventMap: MenuEventMap; declare self: Menu; childActions: Menu[]; defaultMenu: boolean; excludedByFilter: boolean; menuTypes: string[]; menuStyle: MenuStyle; uiCssClass: string; overflowMenu: Menu; /** * This property is set if this is a subMenu */ parentMenu: Menu; ellipsis: boolean; rightAligned: boolean; popup: Popup; popupHorizontalAlignment: PopupAlignment; popupVerticalAlignment: PopupAlignment; stackable: boolean; separator: boolean; shrinkable: boolean; subMenuVisibility: SubMenuVisibility; menuFilter: MenuFilter; createdBy: MenuOrder; $submenuIcon: JQuery; $subMenuBody: JQuery; $placeHolder: JQuery; constructor() { super(); this.childActions = []; this.defaultMenu = null; this.excludedByFilter = false; this.menuTypes = []; this.menuStyle = Menu.MenuStyle.NONE; this.parentMenu = null; this.popup = null; this.popupHorizontalAlignment = undefined; this.popupVerticalAlignment = undefined; this.stackable = true; this.separator = false; this.shrinkable = false; this.rightAligned = false; this.subMenuVisibility = Menu.SubMenuVisibility.DEFAULT; this.menuFilter = null; this.$submenuIcon = null; this.$subMenuBody = null; this._addCloneProperties(['defaultMenu', 'menuTypes', 'overflow', 'stackable', 'separator', 'shrinkable', 'parentMenu', 'menuFilter', 'subMenuVisibility']); this._addWidgetProperties('childActions'); } static SUBMENU_ICON = icons.ANGLE_DOWN_BOLD; /** * Special styles of the menu, calculated by the MenuBar. The default value is MenuStyle.NONE. */ static MenuStyle = { NONE: 0, DEFAULT: 1 } as const; static SubMenuVisibility = { /** * Default: sub-menu icon is only visible when menu has text. */ DEFAULT: 'default', /** * Text or icon: sub-menu icon is only visible when menu has text or an icon. */ TEXT_OR_ICON: 'textOrIcon', /** * Always: sub-menu icon is always visible when menu has child-actions. */ ALWAYS: 'always', /** * Never: sub-menu icon never visible. */ NEVER: 'never' } as const; protected override _init(options: InitModelOf<this>) { super._init(options); this._setChildActions(this.childActions); } protected override _initKeyStrokeContext() { super._initKeyStrokeContext(); this.keyStrokeContext.registerKeyStroke(new MenuExecKeyStroke(this)); } protected override _render() { if (this.separator) { this._renderSeparator(); } else { this._renderItem(); } this.$container.unfocusable(); this.htmlComp = HtmlComponent.install(this.$container, this.session); } protected override _renderProperties() { super._renderProperties(); this._renderMenuStyle(); this._renderActionStyle(); this._updateIconAndTextStyle(); } protected override _remove() { super._remove(); this.$submenuIcon = null; this.$subMenuBody = null; } protected _renderSeparator() { this.$container = this.$parent.appendDiv('menu-separator'); } protected _renderItem() { this.$container = this.$parent.appendDiv('menu-item'); if (this.uiCssClass) { this.$container.addClass(this.uiCssClass); } let mouseEventHandler = this._onMouseEvent.bind(this); this.$container .on('mousedown', mouseEventHandler) .on('contextmenu', mouseEventHandler) .on('click', mouseEventHandler); this._renderSubMenuIcon(); } protected override _renderActionStyle() { this.$container.toggleClass('menu-button', this.isButton() && !this.overflown); this.updateAriaRole(); } override _renderToggleAction() { this.updateAriaRole(); } /** * Aria role for menus is based on multiple properties. Properties that influence the menu role should call * this update when rendering the property */ updateAriaRole() { if (this.separator) { aria.role(this.$container, 'separator'); return; } let hasPopup = this._doActionTogglesSubMenu() || this._doActionTogglesPopup(); aria.role(this.$container, this.isToggleAction() && !hasPopup ? 'menuitemcheckbox' : 'menuitem'); } protected override _renderSelected() { if (!this._doActionTogglesSubMenu()) { super._renderSelected(); // Cannot be done in ContextMenuPopup, // because the property change event is fired before renderSelected is called, // and updateNextToSelected depends on the UI state if (this.parent instanceof ContextMenuPopup) { this.parent.updateNextToSelected(); } } let hasPopup = this._doActionTogglesSubMenu() || this._doActionTogglesPopup(); if (this.selected) { if (this._doActionTogglesSubMenu()) { this._renderSubMenuItems(this, this.childActions); } else if (this._doActionTogglesPopup()) { this._openPopup(); } aria.expanded(this.$container, hasPopup ? true : null); } else { if (this._doActionTogglesSubMenu() && this.rendered) { this._removeSubMenuItems(this); } else { this._closePopup(); this._closeSubMenus(); } aria.expanded(this.$container, hasPopup ? false : null); } this.$container.toggleClass('has-popup', hasPopup); aria.hasPopup(this.$container, hasPopup ? 'menu' : null); aria.pressed(this.$container, null); // remove pressed set by action aria.checked(this.$container, this.isToggleAction() && !hasPopup ? this.selected : null); } protected _closeSubMenus() { this.childActions.forEach(menu => { if (menu._doActionTogglesPopup()) { menu._closeSubMenus(); menu.setSelected(false); } }); } protected _removeSubMenuItems(parentMenu: Menu) { if (this.parent instanceof ContextMenuPopup) { this.parent.removeSubMenuItems(parentMenu, true); } else if (this.parent instanceof Menu) { this.parent._removeSubMenuItems(parentMenu); } } protected _renderSubMenuItems(parentMenu: Menu, menus: Menu[]) { let parent = this.parent; if (parent instanceof ContextMenuPopup) { parent.renderSubMenuItems(parentMenu, menus, true); let closeHandler = event => parentMenu.setSelected(false); let selectedChangeChangeHandler = (event: PropertyChangeEvent<boolean>) => { if (event.newValue === false) { parent.off('destroy', closeHandler); parentMenu.off('propertyChange:selected', selectedChangeChangeHandler); } }; parent.one('destroy', closeHandler); parentMenu.on('propertyChange:selected', selectedChangeChangeHandler); } else if (parent instanceof Menu) { parent._renderSubMenuItems(parentMenu, menus); } } /** * Override this method to control the toggles sub-menu behavior when this menu instance is used as parent. * Some menu subclasses like the ComboMenu need to show the popup menu instead. * @see _doActionTogglesSubMenu */ protected _togglesSubMenu(): boolean { return true; } /** @internal */ _doActionTogglesSubMenu(): boolean { if (!this.childActions.length) { return false; } if (this.parent instanceof ContextMenuPopup) { return true; } if (this.parent instanceof Menu) { return this.parent._togglesSubMenu(); } return false; } protected _onMouseEvent(event: JQuery.MouseEventBase) { if (event.type === 'mousedown') { this._doubleClickSupport.mousedown(event as JQuery.MouseDownEvent); } if (!this._allowMouseEvent(event)) { return; } // When the action is clicked the user wants to execute the action and not see the tooltip -> cancel the task // If it is already displayed it will stay tooltips.cancel(this.$container); // If toggleAction is true, a popup should be rendered on click. To create // the impression of a faster UI, open the popup already on 'mousedown', not // on 'click'. All other actions are handled on 'click'. if (event.type === 'mousedown' && this.isToggleAction()) { this.doAction(); } else if ((event.type === 'click' || event.type === 'contextmenu') && !this.isToggleAction()) { this.doAction(); } } /** * May be overridden if the criteria to open a popup differs */ protected _doActionTogglesPopup(): boolean { return this.childActions.length > 0; } protected _renderChildActions() { // Child action in a sub menu cannot be replaced dynamically, popup has to be closed first. if (!this.rendering) { this._renderSubMenuIcon(); } } setSubMenuVisibility(subMenuVisibility: SubMenuVisibility) { this.setProperty('subMenuVisibility', subMenuVisibility); } protected _renderSubMenuVisibility() { this._renderSubMenuIcon(); } protected _renderSubMenuIcon() { let visible = false; // calculate visibility of sub-menu icon if (this.childActions.length > 0) { switch (this.subMenuVisibility) { case Menu.SubMenuVisibility.DEFAULT: visible = this._hasText(); break; case Menu.SubMenuVisibility.TEXT_OR_ICON: visible = this._hasText() || !!this.iconId; break; case Menu.SubMenuVisibility.ALWAYS: visible = true; break; case Menu.SubMenuVisibility.NEVER: visible = false; break; } } if (visible) { if (!this.$submenuIcon) { let icon = icons.parseIconId(Menu.SUBMENU_ICON); this.$submenuIcon = this.$container .appendSpan('submenu-icon') .text(icon.iconCharacter); aria.hidden(this.$submenuIcon, true); this.invalidateLayoutTree(); } } else { if (this.$submenuIcon) { this.$submenuIcon.remove(); this.$submenuIcon = null; this.invalidateLayoutTree(); } } if (!this.rendering) { this._renderTextPosition(); this._updateIconAndTextStyle(); } } protected override _renderText() { super._renderText(); this.$container.toggleClass('has-text', strings.hasText(this.text) && this.textVisible); if (!this.rendering) { this._renderSubMenuIcon(); this._updateTooltip(); // tooltip shows text when menu is shrunk (see _showTextAsTooltip) } this.invalidateLayoutTree(); } protected override _renderTextPosition() { super._renderTextPosition(); let $parent = this.$container; if (this.textPosition === Action.TextPosition.BOTTOM && this.$text && this.iconId) { // Move submenu icon into text $parent = this.$text; } if (this.$submenuIcon) { // Always append to make sure submenu-icon is the last element in the DOM this.$submenuIcon.appendTo($parent); } } protected override _renderIconId() { super._renderIconId(); this.$container.toggleClass('has-icon', !!this.iconId); if (!this.rendering) { this._renderSubMenuIcon(); } this.invalidateLayoutTree(); } isTabTarget(): boolean { return this.enabledComputed && this.visible && !this.overflown && (this.isButton() || !this.separator) && (!this.parentMenu || this.parentMenu.visible && !this.parentMenu.overflown); // Necessary for ComboMenu -> must return false if ComboMenu (parentMenu) is not shown } override recomputeEnabled(parentEnabled?: boolean) { if (parentEnabled === undefined) { parentEnabled = this._getInheritedAccessibility(); } let enabledComputed; let enabledStateForChildren; if (this.enabled && this.inheritAccessibility && !parentEnabled && this.childActions.length > 0) { // the enabledComputed state here depends on the child actions: // - if there are childActions which have inheritAccessibility=false (recursively): this action must be enabledComputed=true so that these children can be reached // - otherwise this menu is set to enabledComputed=false enabledComputed = this._hasAccessibleChildMenu(); if (enabledComputed) { // this composite menu is only active because it has children with inheritAccessibility=true // but child-menus should consider the container parent instead, otherwise all children would be enabled (because this composite menu is enabled now) enabledStateForChildren = parentEnabled; } else { enabledStateForChildren = false; } } else { enabledComputed = this._computeEnabled(this.inheritAccessibility, parentEnabled); enabledStateForChildren = enabledComputed; } this._updateEnabledComputed(enabledComputed, enabledStateForChildren); } /** * Calculates the inherited enabled state of this menu. This is the enabled state of the next relevant parent. * A relevant parent is either * - the next parent menu with inheritAccessibility=false * - or the container of the menu (the parent of the root menu) * * The enabled state of the container must be used because the parent menu might be a menu which is only enabled because it has children with inheritAccessibility=false. * One exception: if a parent menu itself is inheritAccessibility=false. Then the container is not relevant anymore but this parent is taken instead. */ protected _getInheritedAccessibility(): boolean { let menu: Menu = this; let rootMenu = menu; while (menu) { if (!menu.inheritAccessibility) { // not inherited. no need to check any more parent widgets return menu.enabled; /* do not use enabledComputed here because the parents have no effect */ } rootMenu = menu; menu = menu.parentMenu; } let container = rootMenu.parent; if (container && container.initialized && container.enabledComputed !== undefined) { return container.enabledComputed; } return true; } protected _findRootMenu(): Menu { let menu: Menu = this; let result; while (menu) { result = menu; menu = menu.parentMenu; } return result; } protected _hasAccessibleChildMenu(): boolean { let childFound = false; this.visitChildMenus(child => { if (!child.inheritAccessibility && child.enabled /* do not use enabledComputed here */ && child.visible) { childFound = true; return TreeVisitResult.TERMINATE; } return TreeVisitResult.CONTINUE; }); return childFound; } /** * cannot use Widget#visitChildren() here because the child actions are not always part of the children collection * e.g. for ellipsis menus which declare childActions as 'PreserveOnPropertyChangeProperties'. this means the childActions are not automatically added to the children list even it is a widget property! */ visitChildMenus(visitor: TreeVisitor<Menu>): TreeVisitResult { for (let i = 0; i < this.childActions.length; i++) { let child = this.childActions[i]; if (child instanceof Menu) { let treeVisitResult = visitor(child); if (treeVisitResult === true || treeVisitResult === TreeVisitResult.TERMINATE) { // Visitor wants to abort the visiting return TreeVisitResult.TERMINATE; } else if (treeVisitResult !== TreeVisitResult.SKIP_SUBTREE) { treeVisitResult = child.visitChildMenus(visitor); if (treeVisitResult === TreeVisitResult.TERMINATE) { return TreeVisitResult.TERMINATE; } } } } } protected _hasText(): boolean { return strings.hasText(this.text) && this.textVisible; } protected _updateIconAndTextStyle() { let hasText = this._hasText(); let hasTextAndIcon = !!(hasText && this.iconId); let hasIcon = !!this.iconId; let hasSubMenuIcon = !!this.$submenuIcon; let hasOneIcon = (hasIcon && !hasSubMenuIcon) || (!hasIcon && hasSubMenuIcon); this.$container.toggleClass('menu-textandicon', hasTextAndIcon); this.$container.toggleClass('menu-icononly', !hasText && hasOneIcon); } /** @internal */ _closePopup() { if (this.popup && !this.popup.isRemovalPending()) { this.popup.close(); } } protected _canOpenPopup(): boolean { if (this.popup && this.popup.isRemovalPending()) { // If the popup should be opened while it is being removed, the popup needs to be removed immediately before it can be opened (the remove animation won't complete). // This is necessary to always have a consistent state between menu and popup (e.g. if menu is selected while the popup is removed). // The popup will be null afterwards (due to the destroy handler added by openPopup) this.popup.removeImmediately(); } if (this.popup) { // already open return false; } // Recheck if opening is still possible (maybe destroying the popup changed that, e.g. form of form menu was set to null) if (!this._doActionTogglesPopup()) { return false; } return true; } protected _openPopup() { if (!this._canOpenPopup()) { return; } this.popup = this._createPopup(); this.popup.open(); aria.linkElementWithControls(this.$container, this.popup.$container); this.popup.one('destroy', event => { this.popup = null; aria.removeControls(this.$container); }); // Unselect on close which comes earlier than destroy (before the animation), to give more immediate feedback this.popup.on('close', event => { this.setSelected(false); }); if (this.uiCssClass) { this.popup.$container.addClass(this.uiCssClass); } } protected _createPopup(): Popup { return scout.create(MenuBarPopup, { parent: this, menu: this, menuFilter: this.menuFilter, horizontalAlignment: this.popupHorizontalAlignment, verticalAlignment: this.popupVerticalAlignment }); } protected override _createActionKeyStroke(): ActionKeyStroke { return new MenuKeyStroke(this); } override isToggleAction(): boolean { return this.childActions.length > 0 || this.toggleAction; } isButton(): boolean { return Action.ActionStyle.BUTTON === this.actionStyle; } insertChildAction(actionsToInsert: ObjectOrChildModel<Menu>) { this.insertChildActions([actionsToInsert]); } insertChildActions(actionsToInsert: ObjectOrChildModel<Menu> | ObjectOrChildModel<Menu>[]) { actionsToInsert = arrays.ensure(actionsToInsert); if (actionsToInsert.length === 0) { return; } let actions = this.childActions as ObjectOrChildModel<Menu>[]; this.setChildActions(actions.concat(actionsToInsert)); } deleteChildAction(actionToDelete: Menu) { this.deleteChildActions([actionToDelete]); } deleteChildActions(actionsToDelete: Menu | Menu[]) { actionsToDelete = arrays.ensure(actionsToDelete); if (actionsToDelete.length === 0) { return; } let actions = this.childActions.slice(); arrays.removeAll(actions, actionsToDelete); this.setChildActions(actions); } setChildActions(childActions: ObjectOrChildModel<Menu>[]) { this.setProperty('childActions', childActions); } protected _setChildActions(childActions: Menu[]) { // disconnect existing this.childActions.forEach(childAction => { childAction.parentMenu = null; }); this._setProperty('childActions', childActions); // connect new actions this.childActions.forEach(childAction => { childAction.parentMenu = this; }); if (this.initialized) { this.recomputeEnabled(); } } protected override _setInheritAccessibility(inheritAccessibility: boolean) { let changed = this._setProperty('inheritAccessibility', inheritAccessibility); if (changed) { this._recomputeEnabledInMenuHierarchy(); } } protected override _setEnabled(enabled: boolean) { let changed = this._setProperty('enabled', enabled); if (changed) { this._recomputeEnabledInMenuHierarchy(); } } protected _setVisible(visible: boolean) { let changed = this._setProperty('visible', visible); if (changed) { this._recomputeEnabledInMenuHierarchy(); } } protected _recomputeEnabledInMenuHierarchy() { if (!this.initialized) { return; } let rootMenu = this._findRootMenu(); rootMenu.recomputeEnabled(); if (rootMenu !== this) { // necessary in case this menu or a parent menu has inheritAccessibility=false. Because then this menu and its children are skipped in the line above! this.recomputeEnabled(); } } override setSelected(selected: boolean) { if (selected === this.selected) { return; } super.setSelected(selected); if (!this._doActionTogglesSubMenu() && !this._doActionTogglesPopup()) { return; } // If menu toggles a popup and is in an ellipsis menu which is not selected it needs a special treatment if (this.overflowMenu && !this.overflowMenu.selected) { this._handleSelectedInEllipsis(); } } protected _handleSelectedInEllipsis() { // If the selection toggles a popup, open the ellipsis menu as well, otherwise the popup would not be shown if (this.selected) { this.overflowMenu.setSelected(true); } } setStackable(stackable: boolean) { this.setProperty('stackable', stackable); } protected _renderStackable() { this.invalidateLayoutTree(); } setShrinkable(shrinkable: boolean) { this.setProperty('shrinkable', shrinkable); } protected _renderShrinkable() { this.invalidateLayoutTree(); } override shrink() { if (!this.shrinkable) { return; } super.shrink(); } protected override _renderOverflown() { super._renderOverflown(); this._renderActionStyle(); } setMenuTypes(menuTypes: string[]) { this.setProperty('menuTypes', menuTypes); } setMenuStyle(menuStyle: MenuStyle) { this.setProperty('menuStyle', menuStyle); } protected _renderMenuStyle() { this.$container.toggleClass('default', this.menuStyle === Menu.MenuStyle.DEFAULT); } setDefaultMenu(defaultMenu: boolean) { this.setProperty('defaultMenu', defaultMenu); } setMenuFilter(menuFilter: MenuFilter) { this.setProperty('menuFilter', menuFilter); this.childActions.forEach(child => child.setMenuFilter(menuFilter)); } override clone(model: MenuModel, options: CloneOptions): this { let clone = super.clone(model, options) as Menu; this._deepCloneProperties(clone, 'childActions', options); clone._setChildActions(clone.childActions); return clone as this; } override focus(): boolean { let event = this.trigger('focus'); if (!event.defaultPrevented) { return super.focus(); } return false; } protected override _computeTooltipText(): string { if (this._showTextAsTooltip()) { return strings.join('\n\n', this.text, this.tooltipText); } return super._computeTooltipText(); } /** * Specifies whether the value of the `text` property should be included in the tooltip. This is the case if the menu has a text * that is currently invisible because the menus has been shrunken. Note that this does not change the value of the `tooltipText` * property or the aria attributes. Only the _computed_ tooltip text is affected. * * @see shrink * @see _computeTooltipText */ protected _showTextAsTooltip(): boolean { return this.text && !this.textVisible && this._textVisibleOrig; } }