All Downloads are FREE. Search and download functionalities are using the official Maven repository.

package.src.vaadin-menu-bar-mixin.js Maven / Gradle / Ivy

The newest version!
/**
 * @license
 * Copyright (c) 2019 - 2024 Vaadin Ltd.
 * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
 */
import { DisabledMixin } from '@vaadin/a11y-base/src/disabled-mixin.js';
import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js';
import { isElementFocused, isElementHidden, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
import { KeyboardDirectionMixin } from '@vaadin/a11y-base/src/keyboard-direction-mixin.js';
import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js';
import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js';
import { SlotController } from '@vaadin/component-base/src/slot-controller.js';

/**
 * @polymerMixin
 * @mixes DisabledMixin
 * @mixes ControllerMixin
 * @mixes FocusMixin
 * @mixes KeyboardDirectionMixin
 * @mixes ResizeMixin
 */
export const MenuBarMixin = (superClass) =>
  class MenuBarMixinClass extends KeyboardDirectionMixin(
    ResizeMixin(FocusMixin(DisabledMixin(ControllerMixin(superClass)))),
  ) {
    static get properties() {
      return {
        /**
         * @typedef MenuBarItem
         * @type {object}
         * @property {string} text - Text to be set as the menu button component's textContent.
         * @property {string} tooltip - Text to be set as the menu button's tooltip.
         * Requires a `` element to be added inside the ``.
         * @property {union: string | object} component - The component to represent the button content.
         * Either a tagName or an element instance. Defaults to "vaadin-menu-bar-item".
         * @property {boolean} disabled - If true, the button is disabled and cannot be activated.
         * @property {union: string | string[]} theme - Theme(s) to be set as the theme attribute of the button, overriding any theme set on the menu bar.
         * @property {SubMenuItem[]} children - Array of submenu items.
         */

        /**
         * @typedef SubMenuItem
         * @type {object}
         * @property {string} text - Text to be set as the menu item component's textContent.
         * @property {union: string | object} component - The component to represent the item.
         * Either a tagName or an element instance. Defaults to "vaadin-menu-bar-item".
         * @property {boolean} disabled - If true, the item is disabled and cannot be selected.
         * @property {boolean} checked - If true, the item shows a checkmark next to it.
         * @property {SubMenuItem[]} children - Array of child submenu items.
         */

        /**
         * Defines a hierarchical structure, where root level items represent menu bar buttons,
         * and `children` property configures a submenu with items to be opened below
         * the button on click, Enter, Space, Up and Down arrow keys.
         *
         * #### Example
         *
         * ```js
         * menubar.items = [
         *   {
         *     text: 'File',
         *     className: 'file',
         *     children: [
         *       {text: 'Open', className: 'file open'}
         *       {text: 'Auto Save', checked: true},
         *     ]
         *   },
         *   {component: 'hr'},
         *   {
         *     text: 'Edit',
         *     children: [
         *       {text: 'Undo', disabled: true},
         *       {text: 'Redo'}
         *     ]
         *   },
         *   {text: 'Help'}
         * ];
         * ```
         *
         * @type {!Array}
         */
        items: {
          type: Array,
          value: () => [],
        },

        /**
         * The object used to localize this component.
         * To change the default localization, replace the entire
         * `i18n` object with a custom one.
         *
         * To update individual properties, extend the existing i18n object like so:
         * ```
         * menuBar.i18n = {
         *   ...menuBar.i18n,
         *   moreOptions: 'More options'
         * }
         * ```
         *
         * The object has the following JSON structure and default values:
         * ```
         * {
         *   moreOptions: 'More options'
         * }
         * ```
         *
         * @type {!MenuBarI18n}
         * @default {English/US}
         */
        i18n: {
          type: Object,
          value: () => {
            return {
              moreOptions: 'More options',
            };
          },
        },

        /**
         * A space-delimited list of CSS class names
         * to set on each sub-menu overlay element.
         *
         * @attr {string} overlay-class
         */
        overlayClass: {
          type: String,
        },

        /**
         * If true, the submenu will open on hover (mouseover) instead of click.
         * @attr {boolean} open-on-hover
         */
        openOnHover: {
          type: Boolean,
        },

        /**
         * If true, the buttons will be collapsed into the overflow menu
         * starting from the "start" end of the bar instead of the "end".
         * @attr {boolean} reverse-collapse
         */
        reverseCollapse: {
          type: Boolean,
        },

        /**
         * If true, the top-level menu items is traversable by tab
         * instead of arrow keys (i.e. disabling roving tabindex)
         * @attr {boolean} tab-navigation
         */
        tabNavigation: {
          type: Boolean,
        },

        /**
         * @type {boolean}
         * @protected
         */
        _hasOverflow: {
          type: Boolean,
          value: false,
          sync: true,
        },

        /** @protected */
        _overflow: {
          type: Object,
        },

        /** @protected */
        _container: {
          type: Object,
        },
      };
    }

    static get observers() {
      return [
        '_themeChanged(_theme, _overflow, _container)',
        '__hasOverflowChanged(_hasOverflow, _overflow)',
        '__i18nChanged(i18n, _overflow)',
        '_menuItemsChanged(items, _overflow, _container)',
        '_reverseCollapseChanged(reverseCollapse, _overflow, _container)',
        '_tabNavigationChanged(tabNavigation, _overflow, _container)',
      ];
    }

    constructor() {
      super();
      this.__boundOnContextMenuKeydown = this.__onContextMenuKeydown.bind(this);
      this.__boundOnTooltipMouseLeave = this.__onTooltipOverlayMouseLeave.bind(this);
    }

    /**
     * Override getter from `KeyboardDirectionMixin`
     * to use expanded button for arrow navigation
     * when the sub-menu is opened and has focus.
     *
     * @return {Element | null}
     * @protected
     * @override
     */
    get focused() {
      return (this._getItems() || []).find(isElementFocused) || this._expandedButton;
    }

    /**
     * Override getter from `KeyboardDirectionMixin`.
     *
     * @return {boolean}
     * @protected
     * @override
     */
    get _vertical() {
      return false;
    }

    /**
     * Override getter from `ResizeMixin` to observe parent.
     *
     * @protected
     * @override
     */
    get _observeParent() {
      return true;
    }

    /**
     * @return {!Array}
     * @protected
     */
    get _buttons() {
      return Array.from(this.querySelectorAll('vaadin-menu-bar-button'));
    }

    /** @private */
    get _subMenu() {
      return this.shadowRoot.querySelector('vaadin-menu-bar-submenu');
    }

    /** @protected */
    ready() {
      super.ready();

      this.setAttribute('role', 'menubar');

      this._overflowController = new SlotController(this, 'overflow', 'vaadin-menu-bar-button', {
        initializer: (btn) => {
          btn.setAttribute('hidden', '');

          const dots = document.createElement('div');
          dots.setAttribute('aria-hidden', 'true');
          dots.innerHTML = '·'.repeat(3);
          btn.appendChild(dots);

          this._overflow = btn;
          this._initButtonAttrs(btn);
        },
      });
      this.addController(this._overflowController);

      this.addEventListener('mousedown', () => this._hideTooltip(true));
      this.addEventListener('mouseleave', () => this._hideTooltip());

      this._subMenu.addEventListener('item-selected', this.__onItemSelected.bind(this));
      this._subMenu.addEventListener('close-all-menus', this.__onEscapeClose.bind(this));

      const overlay = this._subMenu._overlayElement;
      overlay.addEventListener('keydown', this.__boundOnContextMenuKeydown);

      const container = this.shadowRoot.querySelector('[part="container"]');
      container.addEventListener('click', this.__onButtonClick.bind(this));
      container.addEventListener('mouseover', (e) => this._onMouseOver(e));

      // Delay setting container to avoid rendering buttons immediately,
      // which would also trigger detecting overflow and force re-layout
      // See https://github.com/vaadin/web-components/issues/7271
      queueMicrotask(() => {
        this._container = container;
      });
    }

    /**
     * Override method inherited from `KeyboardDirectionMixin`
     * to use the list of menu-bar buttons as items.
     *
     * @return {Element[]}
     * @protected
     * @override
     */
    _getItems() {
      return this._buttons;
    }

    /** @protected */
    disconnectedCallback() {
      super.disconnectedCallback();
      this._hideTooltip(true);
    }

    /**
     * Implement callback from `ResizeMixin` to update buttons
     * and detect whether to show or hide the overflow button.
     *
     * @protected
     * @override
     */
    _onResize() {
      this.__detectOverflow();
    }

    /**
     * Override method inherited from `DisabledMixin`
     * to update the `disabled` property for the buttons
     * whenever the property changes on the menu bar.
     *
     * @param {boolean} newValue the new disabled value
     * @param {boolean} oldValue the previous disabled value
     * @override
     * @protected
     */
    _disabledChanged(newValue, oldValue) {
      super._disabledChanged(newValue, oldValue);
      if (oldValue !== newValue) {
        this.__updateButtonsDisabled(newValue);
      }
    }

    /**
     * A callback for the `_theme` property observer.
     * It propagates the host theme to the buttons and the sub menu.
     *
     * @param {string | null} theme
     * @private
     */
    _themeChanged(theme, overflow, container) {
      if (overflow && container) {
        this._buttons.forEach((btn) => this._setButtonTheme(btn, theme));
        this.__detectOverflow();
      }

      if (theme) {
        this._subMenu.setAttribute('theme', theme);
      } else {
        this._subMenu.removeAttribute('theme');
      }
    }

    /**
     * A callback for the 'reverseCollapse' property observer.
     *
     * @param {boolean | null} _reverseCollapse
     * @private
     */
    _reverseCollapseChanged(_reverseCollapse, overflow, container) {
      if (overflow && container) {
        this.__detectOverflow();
      }
    }

    /** @private */
    _tabNavigationChanged(tabNavigation, overflow, container) {
      if (overflow && container) {
        const target = this.querySelector('[tabindex="0"]');
        this._buttons.forEach((btn) => {
          if (target) {
            this._setTabindex(btn, btn === target);
          } else {
            this._setTabindex(btn, false);
          }
          btn.setAttribute('role', tabNavigation ? 'button' : 'menuitem');
        });
      }
      this.setAttribute('role', tabNavigation ? 'group' : 'menubar');
    }

    /** @private */
    __hasOverflowChanged(hasOverflow, overflow) {
      if (overflow) {
        overflow.toggleAttribute('hidden', !hasOverflow);
      }
    }

    /** @private */
    _menuItemsChanged(items, overflow, container) {
      if (!overflow || !container) {
        return;
      }

      if (items !== this._oldItems) {
        this._oldItems = items;
        this.__renderButtons(items);
      }

      const subMenu = this._subMenu;
      if (subMenu && subMenu.opened) {
        subMenu.close();
      }
    }

    /** @private */
    __i18nChanged(i18n, overflow) {
      if (overflow && i18n && i18n.moreOptions !== undefined) {
        if (i18n.moreOptions) {
          overflow.setAttribute('aria-label', i18n.moreOptions);
        } else {
          overflow.removeAttribute('aria-label');
        }
      }
    }

    /** @private */
    __getOverflowCount(overflow) {
      // We can't use optional chaining due to webpack 4
      return (overflow.item && overflow.item.children && overflow.item.children.length) || 0;
    }

    /** @private */
    __restoreButtons(buttons) {
      buttons.forEach((button) => {
        button.disabled = (button.item && button.item.disabled) || this.disabled;
        button.style.visibility = '';
        button.style.position = '';

        // Teleport item component back from "overflow" sub-menu
        const item = button.item && button.item.component;
        if (item instanceof HTMLElement && item.getAttribute('role') === 'menuitem') {
          this.__restoreItem(button, item);
        }
      });
      this.__updateOverflow([]);
    }

    /** @private */
    __restoreItem(button, item) {
      button.appendChild(item);
      item.removeAttribute('role');
      item.removeAttribute('aria-expanded');
      item.removeAttribute('aria-haspopup');
      item.removeAttribute('tabindex');
    }

    /** @private */
    __updateButtonsDisabled(disabled) {
      this._buttons.forEach((btn) => {
        // Disable the button if the entire menu-bar is disabled or the item alone is disabled
        btn.disabled = disabled || (btn.item && btn.item.disabled);
      });
    }

    /** @private */
    __updateOverflow(items) {
      this._overflow.item = { children: items };
      this._hasOverflow = items.length > 0;
    }

    /** @private */
    __setOverflowItems(buttons, overflow) {
      const container = this._container;

      if (container.offsetWidth < container.scrollWidth) {
        this._hasOverflow = true;

        const isRTL = this.__isRTL;
        const containerLeft = container.offsetLeft;

        const remaining = [...buttons];
        while (remaining.length) {
          const lastButton = remaining[remaining.length - 1];
          const btnLeft = lastButton.offsetLeft - containerLeft;

          // If this button isn't overflowing, then the rest aren't either
          if (
            (!isRTL && btnLeft + lastButton.offsetWidth < container.offsetWidth - overflow.offsetWidth) ||
            (isRTL && btnLeft >= overflow.offsetWidth)
          ) {
            break;
          }

          const btn = this.reverseCollapse ? remaining.shift() : remaining.pop();

          // Save width for buttons with component
          btn.style.width = getComputedStyle(btn).width;
          btn.disabled = true;
          btn.style.visibility = 'hidden';
          btn.style.position = 'absolute';
        }

        const items = buttons.filter((b) => !remaining.includes(b)).map((b) => b.item);
        this.__updateOverflow(items);

        // Ensure there is at least one button with tabindex set to 0
        // so that menu-bar is not skipped when navigating with Tab
        if (remaining.length && !remaining.some((btn) => btn.getAttribute('tabindex') === '0')) {
          this._setTabindex(remaining[remaining.length - 1], true);
        }
      }
    }

    /** @private */
    __detectOverflow() {
      if (!this._container) {
        return;
      }

      const overflow = this._overflow;
      const buttons = this._buttons.filter((btn) => btn !== overflow);
      const oldOverflowCount = this.__getOverflowCount(overflow);

      // Reset all buttons in the menu bar and the overflow button
      this.__restoreButtons(buttons);

      // Hide any overflowing buttons and put them in the 'overflow' button
      this.__setOverflowItems(buttons, overflow);

      const newOverflowCount = this.__getOverflowCount(overflow);
      if (oldOverflowCount !== newOverflowCount && this._subMenu.opened) {
        this._subMenu.close();
      }

      const isSingleButton = newOverflowCount === buttons.length || (newOverflowCount === 0 && buttons.length === 1);
      this.toggleAttribute('has-single-button', isSingleButton);

      // Apply first/last visible attributes to the visible buttons
      buttons
        .filter((btn) => btn.style.visibility !== 'hidden')
        .forEach((btn, index, visibleButtons) => {
          btn.toggleAttribute('first-visible', index === 0);
          btn.toggleAttribute('last-visible', !this._hasOverflow && index === visibleButtons.length - 1);
        });
    }

    /** @protected */
    _removeButtons() {
      this._buttons.forEach((button) => {
        if (button !== this._overflow) {
          this.removeChild(button);
        }
      });
    }

    /** @protected */
    _initButton(item) {
      const button = document.createElement('vaadin-menu-bar-button');

      const itemCopy = { ...item };
      button.item = itemCopy;

      if (item.component) {
        const component = this.__getComponent(itemCopy);
        itemCopy.component = component;
        // Save item for overflow menu
        component.item = itemCopy;
        button.appendChild(component);
      } else if (item.text) {
        button.textContent = item.text;
      }

      if (item.className) {
        button.className = item.className;
      }

      return button;
    }

    /** @protected */
    _initButtonAttrs(button) {
      button.setAttribute('role', this.tabNavigation ? 'button' : 'menuitem');

      if (button === this._overflow || (button.item && button.item.children)) {
        button.setAttribute('aria-haspopup', 'true');
        button.setAttribute('aria-expanded', 'false');
      }
    }

    /** @protected */
    _setButtonDisabled(button, disabled) {
      button.disabled = disabled;
      button.setAttribute('tabindex', disabled ? '-1' : '0');
    }

    /** @protected */
    _setButtonTheme(btn, hostTheme) {
      let theme = hostTheme;

      // Item theme takes precedence over host theme even if it's empty, as long as it's not undefined or null
      const itemTheme = btn.item && btn.item.theme;
      if (itemTheme != null) {
        theme = Array.isArray(itemTheme) ? itemTheme.join(' ') : itemTheme;
      }

      if (theme) {
        btn.setAttribute('theme', theme);
      } else {
        btn.removeAttribute('theme');
      }
    }

    /** @private */
    __getComponent(item) {
      const itemComponent = item.component;
      let component;

      const isElement = itemComponent instanceof HTMLElement;
      // Use existing item component, if any
      if (isElement && itemComponent.localName === 'vaadin-menu-bar-item') {
        component = itemComponent;
      } else {
        component = document.createElement('vaadin-menu-bar-item');
        component.appendChild(isElement ? itemComponent : document.createElement(itemComponent));
      }
      if (item.text) {
        const node = component.firstChild || component;
        node.textContent = item.text;
      }
      return component;
    }

    /** @private */
    __renderButtons(items = []) {
      this._removeButtons();

      /* Empty array, do nothing */
      if (items.length === 0) {
        return;
      }

      items.forEach((item) => {
        const button = this._initButton(item);
        this.insertBefore(button, this._overflow);
        this._setButtonDisabled(button, item.disabled);
        this._initButtonAttrs(button);
        this._setButtonTheme(button, this._theme);
      });

      this.__detectOverflow();
    }

    /**
     * @param {HTMLElement} button
     * @protected
     */
    _showTooltip(button, isHover) {
      // Check if there is a slotted vaadin-tooltip element.
      const tooltip = this._tooltipController.node;
      if (tooltip && tooltip.isConnected) {
        // If the tooltip element doesn't have a generator assigned, use a default one
        // that reads the `tooltip` property of an item.
        if (tooltip.generator === undefined) {
          tooltip.generator = ({ item }) => item && item.tooltip;
        }

        if (!tooltip._mouseLeaveListenerAdded) {
          tooltip._overlayElement.addEventListener('mouseleave', this.__boundOnTooltipMouseLeave);
          tooltip._mouseLeaveListenerAdded = true;
        }

        if (!this._subMenu.opened) {
          this._tooltipController.setTarget(button);
          this._tooltipController.setContext({ item: button.item });

          // Trigger opening using the corresponding delay.
          tooltip._stateController.open({
            hover: isHover,
            focus: !isHover,
          });
        }
      }
    }

    /** @protected */
    _hideTooltip(immediate) {
      const tooltip = this._tooltipController && this._tooltipController.node;
      if (tooltip) {
        tooltip._stateController.close(immediate);
      }
    }

    /** @private */
    __onTooltipOverlayMouseLeave(event) {
      if (event.relatedTarget !== this._tooltipController.target) {
        this._hideTooltip();
      }
    }

    /** @protected */
    _setExpanded(button, expanded) {
      button.toggleAttribute('expanded', expanded);
      button.toggleAttribute('active', expanded);
      button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
    }

    /** @protected */
    _setTabindex(button, focused) {
      if (this.tabNavigation && !button.disabled) {
        button.setAttribute('tabindex', '0');
      } else {
        button.setAttribute('tabindex', focused ? '0' : '-1');
      }
    }

    /**
     * Override method inherited from `KeyboardDirectionMixin`
     * to close the submenu for the previously focused button
     * and open another one for the newly focused button.
     *
     * @param {Element} item
     * @param {boolean} navigating
     * @protected
     * @override
     */
    _focusItem(item, navigating) {
      const wasExpanded = navigating && this.focused === this._expandedButton;
      if (wasExpanded) {
        this._close();
      }

      super._focusItem(item, navigating);

      this._buttons.forEach((btn) => {
        this._setTabindex(btn, btn === item);
      });

      if (wasExpanded && item.item && item.item.children) {
        this.__openSubMenu(item, true, { keepFocus: true });
      } else if (item === this._overflow) {
        this._hideTooltip();
      } else {
        this._showTooltip(item);
      }
    }

    /** @private */
    _getButtonFromEvent(e) {
      return Array.from(e.composedPath()).find((el) => el.localName === 'vaadin-menu-bar-button');
    }

    /**
     * Override method inherited from `FocusMixin`
     *
     * @param {boolean} focused
     * @override
     * @protected
     */
    _setFocused(focused) {
      if (focused) {
        let target = this.querySelector('[tabindex="0"]');
        if (this.tabNavigation) {
          // Switch submenu on menu button Tab / Shift Tab
          target = this.querySelector('[focused]');
          this.__switchSubMenu(target);
        }
        if (target) {
          this._buttons.forEach((btn) => {
            this._setTabindex(btn, btn === target);
            if (btn === target && btn !== this._overflow && isKeyboardActive()) {
              this._showTooltip(btn);
            }
          });
        }
      } else {
        this._hideTooltip();
      }
    }

    /**
     * @param {!KeyboardEvent} event
     * @private
     */
    _onArrowDown(event) {
      // Prevent page scroll.
      event.preventDefault();

      const button = this._getButtonFromEvent(event);
      if (button === this._expandedButton) {
        // Menu opened previously, focus first item
        this._focusFirstItem();
      } else {
        this.__openSubMenu(button, true);
      }
    }

    /**
     * @param {!KeyboardEvent} event
     * @private
     */
    _onArrowUp(event) {
      // Prevent page scroll.
      event.preventDefault();

      const button = this._getButtonFromEvent(event);
      if (button === this._expandedButton) {
        // Menu opened previously, focus last item
        this._focusLastItem();
      } else {
        this.__openSubMenu(button, true, { focusLast: true });
      }
    }

    /**
     * Override an event listener from `KeyboardMixin`:
     * - to close the sub-menu for expanded button,
     * - to close a tooltip for collapsed button.
     *
     * @param {!KeyboardEvent} event
     * @protected
     * @override
     */
    _onEscape(event) {
      if (event.composedPath().includes(this._expandedButton)) {
        this._close(true);
      }

      this._hideTooltip(true);
    }

    /**
     * Override an event listener from `KeyboardMixin`.
     *
     * @param {!KeyboardEvent} event
     * @protected
     * @override
     */
    _onKeyDown(event) {
      switch (event.key) {
        case 'ArrowDown':
          this._onArrowDown(event);
          break;
        case 'ArrowUp':
          this._onArrowUp(event);
          break;
        default:
          super._onKeyDown(event);
          break;
      }
    }

    /**
     * @param {!MouseEvent} e
     * @protected
     */
    _onMouseOver(e) {
      const button = this._getButtonFromEvent(e);
      if (!button) {
        // Hide tooltip on mouseover to disabled button
        this._hideTooltip();
      } else if (button !== this._expandedButton) {
        const isOpened = this._subMenu.opened;
        if (button.item.children && (this.openOnHover || isOpened)) {
          this.__openSubMenu(button, false);
        } else if (isOpened) {
          this._close();
        }

        if (button === this._overflow || (this.openOnHover && button.item.children)) {
          this._hideTooltip();
        } else {
          this._showTooltip(button, true);
        }
      }
    }

    /** @private */
    __onContextMenuKeydown(e) {
      const item = Array.from(e.composedPath()).find((el) => el._item);
      if (item) {
        const list = item.parentNode;
        if (e.keyCode === 38 && item === list.items[0]) {
          this._close(true);
        }
        // ArrowLeft, or ArrowRight on non-parent submenu item
        if (e.keyCode === 37 || (e.keyCode === 39 && !item._item.children)) {
          // Prevent ArrowLeft from being handled in context-menu
          e.stopImmediatePropagation();
          this._onKeyDown(e);
        } else if (e.keyCode === 9 && this.tabNavigation) {
          // Switch opened submenu on submenu item Tab / Shift Tab
          const items = this._getItems() || [];
          const currentIdx = items.indexOf(this.focused);
          const increment = e.shiftKey ? -1 : 1;
          let idx = currentIdx + increment;
          idx = this._getAvailableIndex(items, idx, increment, (item) => !isElementHidden(item));
          this.__switchSubMenu(items[idx]);
        }
      }
    }

    /** @private */
    __switchSubMenu(target) {
      const wasExpanded = this._expandedButton != null && this._expandedButton !== target;
      if (wasExpanded) {
        this._close();
        if (target.item && target.item.children) {
          this.__openSubMenu(target, true, { keepFocus: true });
        }
      }
    }

    /** @private */
    __fireItemSelected(value) {
      this.dispatchEvent(new CustomEvent('item-selected', { detail: { value } }));
    }

    /** @private */
    __onButtonClick(e) {
      const button = this._getButtonFromEvent(e);
      if (button) {
        this.__openSubMenu(button, button.__triggeredWithActiveKeys);
      }
    }

    /** @private */
    __openSubMenu(button, keydown, options = {}) {
      const subMenu = this._subMenu;
      const item = button.item;

      if (subMenu.opened) {
        this._close();
        if (subMenu.listenOn === button) {
          return;
        }
      }

      const items = item && item.children;
      if (!items || items.length === 0) {
        this.__fireItemSelected(item);
        return;
      }

      subMenu.items = items;
      subMenu.listenOn = button;
      const overlay = subMenu._overlayElement;
      overlay.noVerticalOverlap = true;

      this._expandedButton = button;

      requestAnimationFrame(async () => {
        // After changing items, buttons are recreated so the old button is
        // no longer in the DOM. Reset position target to null to prevent
        // overlay from closing due to target width / height equal to 0.
        if (overlay.positionTarget && !overlay.positionTarget.isConnected) {
          overlay.positionTarget = null;
        }

        button.dispatchEvent(
          new CustomEvent('opensubmenu', {
            detail: {
              children: items,
            },
          }),
        );
        this._hideTooltip(true);

        this._setExpanded(button, true);

        // Delay setting position target until overlay is rendered
        // to correctly measure item content in Lit based version.
        if (overlay.updateComplete) {
          await overlay.updateComplete;
        }

        overlay.positionTarget = button;
      });

      this.style.pointerEvents = 'auto';

      overlay.addEventListener(
        'vaadin-overlay-open',
        () => {
          if (options.focusLast) {
            this._focusLastItem();
          }

          if (options.keepFocus) {
            this._focusItem(this._expandedButton, false);
          }

          // Do not focus item when open not from keyboard
          if (!keydown) {
            overlay.$.overlay.focus();
          }
        },
        { once: true },
      );
    }

    /** @private */
    _focusFirstItem() {
      const list = this._subMenu._overlayElement.firstElementChild;
      list.focus();
    }

    /** @private */
    _focusLastItem() {
      const list = this._subMenu._overlayElement.firstElementChild;
      const item = list.items[list.items.length - 1];
      if (item) {
        item.focus();
      }
    }

    /** @private */
    __onItemSelected(e) {
      e.stopPropagation();
      this.__fireItemSelected(e.detail.value);
    }

    /** @private */
    __onEscapeClose() {
      this.__deactivateButton(true);
    }

    /** @private */
    __deactivateButton(restoreFocus) {
      const button = this._expandedButton;
      if (button && button.hasAttribute('expanded')) {
        this._setExpanded(button, false);
        if (restoreFocus) {
          this._focusItem(button, false);
        }
        this._expandedButton = null;
      }
    }

    /**
     * @param {boolean} restoreFocus
     * @protected
     */
    _close(restoreFocus = false) {
      this.style.pointerEvents = '';
      this.__deactivateButton(restoreFocus);
      if (this._subMenu.opened) {
        this._subMenu.close();
      }
    }

    /**
     * Closes the current submenu.
     */
    close() {
      this._close();
    }
  };




© 2015 - 2025 Weber Informatics LLC | Privacy Policy