UNPKG

@eclipse-scout/core

Version:
470 lines (408 loc) 16 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 { aria, arrays, EllipsisMenu, EnumObject, Event, EventHandler, GroupBoxMenuItemsOrder, HtmlComponent, InitModelOf, keys, KeyStroke, KeyStrokeContext, Menu, MenuBarBox, MenuBarEventMap, MenuBarLayout, MenuBarLeftKeyStroke, MenuBarModel, MenuBarRightKeyStroke, MenuDestinations, MenuFilter, MenuOrder, menus, ObjectIdProvider, ObjectOrChildModel, OrderedMenuItems, PropertyChangeEvent, scout, TooltipPosition, Widget, widgets } from '../../index'; export type MenuBarEllipsisPosition = EnumObject<typeof MenuBar.EllipsisPosition>; export type MenuBarPosition = EnumObject<typeof MenuBar.Position>; export class MenuBar extends Widget implements MenuBarModel { declare model: MenuBarModel; declare eventMap: MenuBarEventMap; declare self: MenuBar; menuSorter: MenuOrder & { menuBar?: MenuBar }; menuFilter: MenuFilter; position: MenuBarPosition; tabbable: boolean; menuboxLeft: MenuBarBox; menuboxRight: MenuBarBox; menuItems: Menu[]; // original list of menuItems that was passed to setMenuItems(), only used to check if menubar has changed orderedMenuItems: OrderedMenuItems; defaultMenu: Menu; ellipsisPosition: MenuBarEllipsisPosition; hiddenByUi: boolean; tabbableMenu: Menu; protected _menuItemPropertyChangeHandler: EventHandler<PropertyChangeEvent>; protected _focusHandler: EventHandler<Event<Menu>>; protected _ellipsis: EllipsisMenu; constructor() { super(); this.menuSorter = null; this.menuFilter = null; this.position = MenuBar.Position.TOP; this.tabbable = true; this.menuboxLeft = null; this.menuboxRight = null; this.menuItems = []; this.orderedMenuItems = { left: [], right: [], all: [] }; this.defaultMenu = null; this.ellipsisPosition = MenuBar.EllipsisPosition.RIGHT; this._menuItemPropertyChangeHandler = this._onMenuItemPropertyChange.bind(this); this._focusHandler = this._onMenuItemFocus.bind(this); this.hiddenByUi = false; this._addWidgetProperties('menuItems'); } static EllipsisPosition = { LEFT: 'left', RIGHT: 'right' } as const; static Position = { TOP: 'top', BOTTOM: 'bottom' } as const; protected override _init(options: InitModelOf<this>) { super._init(options); this.menuSorter = options.menuOrder || new GroupBoxMenuItemsOrder(); this.menuSorter.menuBar = this; if (options.menuFilter) { this.menuFilter = (menus, destination) => options.menuFilter(menus, MenuDestinations.MENU_BAR); } this.menuboxLeft = scout.create(MenuBarBox, { parent: this, cssClass: 'left', tooltipPosition: this._oppositePosition() }); this.menuboxRight = scout.create(MenuBarBox, { parent: this, cssClass: 'right', tooltipPosition: this._oppositePosition() }); this._setMenuItems(arrays.ensure(this.menuItems)); this.updateVisibility(); } protected override _destroy() { super._destroy(); this._detachMenuHandlers(); } protected override _createKeyStrokeContext(): KeyStrokeContext { return new KeyStrokeContext(); } protected override _initKeyStrokeContext() { super._initKeyStrokeContext(); this.keyStrokeContext.registerKeyStrokes([ new MenuBarLeftKeyStroke(this), new MenuBarRightKeyStroke(this) ]); } protected override _render() { this.$container = this.$parent.appendDiv('menubar'); aria.role(this.$container, 'menubar'); this.htmlComp = HtmlComponent.install(this.$container, this.session); this.htmlComp.setLayout(new MenuBarLayout(this)); this.menuboxLeft.render(this.$container); this.menuboxRight.render(this.$container); } protected override _renderProperties() { super._renderProperties(); this._renderMenuItems(); this._renderPosition(); } setPosition(position: MenuBarPosition) { this.setProperty('position', position); } protected _setPosition(position: MenuBarPosition) { this._setProperty('position', position); this.menuboxLeft.setTooltipPosition(this._oppositePosition()); this.menuboxRight.setTooltipPosition(this._oppositePosition()); } protected _renderPosition() { this.$container.toggleClass('bottom', this.position === MenuBar.Position.BOTTOM); } protected _oppositePosition(): TooltipPosition { return this.position === MenuBar.Position.TOP ? MenuBar.Position.BOTTOM : MenuBar.Position.TOP; } setEllipsisPosition(ellipsisPosition: MenuBarEllipsisPosition) { this.setProperty('ellipsisPosition', ellipsisPosition); } /** * Set the filter of the menu bar to all the menu items. */ protected _setChildMenuFilters() { this.orderedMenuItems.all.forEach(item => item.setMenuFilter(this.menuFilter)); } /** * This function can be called multiple times. The function attaches the menu handlers only if they are not yet added. */ protected _attachMenuHandlers() { this.orderedMenuItems.all.forEach(item => { if (item.events.count('propertyChange', this._menuItemPropertyChangeHandler) === 0) { item.on('propertyChange', this._menuItemPropertyChangeHandler); } if (item.events.count('focus', this._focusHandler) === 0) { item.on('focus', this._focusHandler); } }); } protected _detachMenuHandlers() { this.orderedMenuItems.all.forEach(item => { item.off('propertyChange', this._menuItemPropertyChangeHandler); item.off('focus', this._focusHandler); }); } setMenuItems(menuOrModels: ObjectOrChildModel<Menu> | ObjectOrChildModel<Menu>[]) { let menuItems = arrays.ensure(menuOrModels); if (arrays.equals(this.menuItems, menuItems)) { // Ensure existing menus are correctly linked even if the given menuItems are the same (see TableSpec for reasons) this.menuboxRight.link(this.menuboxRight.menuItems); this.menuboxLeft.link(this.menuboxLeft.menuItems); return; } this.setProperty('menuItems', menuItems); } protected _setMenuItems(menuItems: Menu[], rightFirst?: boolean) { // remove property listeners of old menu items. this._detachMenuHandlers(); this.orderedMenuItems = this._createOrderedMenus(menuItems); if (rightFirst) { this.menuboxRight.setMenuItems(this.orderedMenuItems.right); this.menuboxLeft.setMenuItems(this.orderedMenuItems.left); } else { this.menuboxLeft.setMenuItems(this.orderedMenuItems.left); this.menuboxRight.setMenuItems(this.orderedMenuItems.right); } this._setChildMenuFilters(); this._attachMenuHandlers(); this.updateVisibility(); this.updateDefaultMenu(); this._setProperty('menuItems', menuItems); } protected _renderMenuItems() { widgets.updateFirstLastMarker(this.menuItems); this.updateLeftOfButtonMarker(); this.invalidateLayoutTree(); } protected _removeMenuItems() { // NOP: by implementing this function we avoid the call to Widget.js#_internalRemoveWidgets // which would remove our menuItems, because they are defined as widget-property (see constructor). } protected _createOrderedMenus(menuItems: Menu[]): OrderedMenuItems { let orderedMenuItems = this.menuSorter.order(menuItems), ellipsisIndex = -1, ellipsis; orderedMenuItems.right.forEach(item => { item.rightAligned = true; }); if (orderedMenuItems.all.length > 0) { if (this._ellipsis) { // Disconnect existing child actions from ellipsis menu this._ellipsis.setChildActions([]); this._ellipsis.destroy(); } ellipsis = scout.create(EllipsisMenu, { parent: this, cssClass: 'overflow-menu-item' }); this._ellipsis = ellipsis; // add ellipsis to the correct position if (this.ellipsisPosition === MenuBar.EllipsisPosition.RIGHT) { // try right let reverseIndexPosition = this._getFirstStackableIndexPosition(orderedMenuItems.right.slice().reverse()); if (reverseIndexPosition > -1) { ellipsisIndex = orderedMenuItems.right.length - reverseIndexPosition; ellipsis.rightAligned = true; orderedMenuItems.right.splice(ellipsisIndex, 0, ellipsis); } else { // try left reverseIndexPosition = this._getFirstStackableIndexPosition(orderedMenuItems.left.slice().reverse()); if (reverseIndexPosition > -1) { ellipsisIndex = orderedMenuItems.left.length - reverseIndexPosition; orderedMenuItems.left.splice(ellipsisIndex, 0, ellipsis); } } } else { // try left ellipsisIndex = this._getFirstStackableIndexPosition(orderedMenuItems.left); if (ellipsisIndex > -1) { orderedMenuItems.left.splice(ellipsisIndex, 0, ellipsis); } else { // try right ellipsisIndex = this._getFirstStackableIndexPosition(orderedMenuItems.right); if (ellipsisIndex > -1) { ellipsis.rightAligned = true; orderedMenuItems.right.splice(ellipsisIndex, 0, ellipsis); } } } orderedMenuItems.all = orderedMenuItems.left.concat(orderedMenuItems.right); } return orderedMenuItems; } protected _getFirstStackableIndexPosition(menuList: Menu[]): number { let foundIndex = -1; menuList.some((menu: Menu, index: number) => { if (menu.stackable && menu.visible) { foundIndex = index; return true; } return false; }); return foundIndex; } protected _updateTabbableMenu() { // Make first valid MenuItem tabbable so that it can be focused. All other items // are not tabbable. But they can be selected with the arrow keys. if (this.tabbable) { if (this.defaultMenu && this.defaultMenu.isTabTarget()) { this.setTabbableMenu(this.defaultMenu); } else { this.setTabbableMenu(this.allMenusAsFlatList().find(item => item.isTabTarget())); } } } setTabbableMenu(menu: Menu) { if (!this.tabbable || menu === this.tabbableMenu) { return; } if (this.tabbableMenu) { this.tabbableMenu.setTabbable(false); } this.tabbableMenu = menu; if (menu) { menu.setTabbable(true); } } /** * Sets the property hiddenByUi. This does not automatically update the visibility of the menus. * We assume that {@link updateVisibility} is called later anyway. * @internal */ setHiddenByUi(hiddenByUi: boolean) { this.setProperty('hiddenByUi', hiddenByUi); } updateVisibility() { menus.updateSeparatorVisibility(this.orderedMenuItems.left); menus.updateSeparatorVisibility(this.orderedMenuItems.right); this.setVisible(!this.hiddenByUi && this.orderedMenuItems.all.some(m => m.visible && !m.ellipsis)); } /** * First rendered item that is enabled and reacts to ENTER keystroke shall be marked as 'defaultMenu' * * @param updateTabbableMenu if true (default), the "tabbable menu" is updated at the end of this method. */ updateDefaultMenu(updateTabbableMenu?: boolean) { let defaultMenu: Menu = null; for (let i = 0; i < this.orderedMenuItems.all.length; i++) { let item = this.orderedMenuItems.all[i]; if (!item.visible || !item.enabled || item.defaultMenu === false) { // Invisible or disabled menus and menus that explicitly have the "defaultMenu" // property set to false cannot be the default menu. continue; } if (item.defaultMenu) { defaultMenu = item; break; } if (!defaultMenu && this._isDefaultKeyStroke(item.actionKeyStroke)) { defaultMenu = item; } } this.setDefaultMenu(defaultMenu); if (scout.nvl(updateTabbableMenu, true)) { this._updateTabbableMenu(); } } protected _isDefaultKeyStroke(keyStroke: KeyStroke): boolean { return scout.isOneOf(keys.ENTER, keyStroke.which) && !keyStroke.ctrl && !keyStroke.alt && !keyStroke.shift; } setDefaultMenu(defaultMenu: Menu) { this.setProperty('defaultMenu', defaultMenu); } protected _setDefaultMenu(defaultMenu: Menu) { if (this.defaultMenu) { this.defaultMenu.setMenuStyle(Menu.MenuStyle.NONE); } if (defaultMenu) { defaultMenu.setMenuStyle(Menu.MenuStyle.DEFAULT); } this._setProperty('defaultMenu', defaultMenu); } /** * Add class 'left-of-button' to every menu item which is on the left of a button */ updateLeftOfButtonMarker() { this._updateLeftOfButtonMarker(this.orderedMenuItems.left); this._updateLeftOfButtonMarker(this.orderedMenuItems.right); } protected _updateLeftOfButtonMarker(items: Menu[]) { let item, previousItem; items = items.filter(item => item.visible && item.rendered); for (let i = 0; i < items.length; i++) { item = items[i]; item.$container.removeClass('left-of-button'); if (i > 0 && item.isButton()) { previousItem = items[i - 1]; previousItem.$container.addClass('left-of-button'); } } } protected _onMenuItemPropertyChange(event: PropertyChangeEvent) { // We do not update the items directly, because this listener may be fired many times in one // user request (because many menus change one or more properties). Therefore, we just invalidate // the MenuBarLayout. It will be updated automatically after the user request has finished, // because the layout calls rebuildItemsInternal(). let source = event.source as Menu; if (event.propertyName === 'overflown' || event.propertyName === 'enabledComputed' || event.propertyName === 'visible' || event.propertyName === 'hidden') { if (!this.tabbableMenu || source === this.tabbableMenu) { this._updateTabbableMenu(); } } if (event.propertyName === 'overflown' || event.propertyName === 'hidden') { if (!this.defaultMenu || source === this.defaultMenu) { this.updateDefaultMenu(); } } if (event.propertyName === 'horizontalAlignment') { // reorder this.reorderMenus(event.newValue <= 0); } if (event.propertyName === 'visible') { let oldVisible = this.visible; this.updateVisibility(); if (!oldVisible && this.visible) { // If the menubar was previously invisible (because all menus were invisible) but // is now visible, the menu-boxes and the menus have to be rendered now. Otherwise, // calculating the preferred size of the menubar, e.g. in the TableLayout, would // return the wrong value (even if the menubar itself is visible). this.revalidateLayout(); } // recalculate position of ellipsis if any menu item changed visibility. // separators may change visibility during reordering menu items. Since separators do not have any // impact of right/left order of menu items they have not to be considered to enforce a reorder. if (!source.separator) { this.reorderMenus(); } } if (event.propertyName === 'keyStroke' || event.propertyName === 'enabledComputed' || event.propertyName === 'defaultMenu' || event.propertyName === 'visible') { this.updateDefaultMenu(); } } protected _onMenuItemFocus(event: Event<Menu>) { this.setTabbableMenu(event.source); } reorderMenus(rightFirst?: boolean) { let menuItems = this.menuItems; this._setMenuItems(menuItems, rightFirst); if (this.rendered) { this.updateLeftOfButtonMarker(); } } allMenusAsFlatList(): Menu[] { return menus.flatTopLevelActions(this.orderedMenuItems.all); } } ObjectIdProvider.uuidPathSkipWidgets.add(MenuBar);