chromium/ui/file_manager/file_manager/foreground/js/ui/multi_menu_button.ts

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {CrButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import {assert} from 'chrome://resources/js/assert.js';

import {crInjectTypeAndInit} from '../../../common/js/cr_ui.js';

import type {Menu} from './menu.js';
import {MenuItem, type MenuItemActivationEvent} from './menu_item.js';
import {MultiMenu} from './multi_menu.js';
import {AnchorType, positionPopupAroundElement} from './position_util.js';

/**
 * Enum for type of hide. Delayed is used when called by clicking on a
 * checkable menu item.
 */
export enum HideType {
  INSTANT = 0,
  DELAYED = 1,
}

/**
 * A button that displays a MultiMenu (menu with sub-menus).
 */
export class MultiMenuButton extends CrButtonElement {
  /**
   * Property that hosts sub-menus for filling with overflow items.
   * Used for menu-items that overflow parent menu.
   */
  overflow: Menu|null = null;

  /**
   * Padding used when restricting menu height when the window is too small
   * to show the entire menu.
   */
  private menuEndGap_: number = 0;  // padding on cr.menu + 2px

  private abortController_: AbortController|null = null;
  private menu_: MultiMenu|null = null;
  private observer_: ResizeObserver|null = null;
  private observedElement_: HTMLElement|null = null;

  /**
   * Initializes the menu button.
   */
  initialize() {
    this.setAttribute('aria-expanded', 'false');

    // Listen to the touch events on the document so that we can handle it
    // before cancelled by other UI components.
    this.ownerDocument.addEventListener('touchstart', this, {passive: true});
    this.addEventListener('mousedown', this);
    this.addEventListener('keydown', this);
    this.addEventListener('dblclick', this);
    this.addEventListener('blur', this);

    this.menuEndGap_ = 18;  // padding on cr.menu + 2px

    // Adding the 'custom-appearance' class prevents widgets.css from
    // changing the appearance of this element.
    this.classList.add('custom-appearance');
    this.classList.add('menu-button');  // For styles in menu_button.css.

    this.menu = this.getAttribute('menu')!;

    // Align the menu if the button moves. When the button moves, the parent
    // container resizes.
    this.observer_ = new ResizeObserver(() => {
      this.positionMenu_();
    });
  }

  get menu(): MultiMenu|null {
    return this.menu_;
  }

  private setMenu_(menu: string|MultiMenu) {
    if (typeof menu === 'string' && menu[0] === '#') {
      menu = this.ownerDocument.body.querySelector<MultiMenu>(menu)!;
      assert(menu);
      crInjectTypeAndInit(menu, MultiMenu);
    }

    this.menu_ = menu as MultiMenu;
    if (menu) {
      if ('id' in this.menu_) {
        this.setAttribute('menu', '#' + this.menu_.id);
      }
    }
  }

  set menu(menu: string|MultiMenu) {
    this.setMenu_(menu);
  }

  /**
   * Checks if the menu(s) should be closed based on the target of a mouse
   * click or a touch event target.
   * @param e The event object.
   */
  private shouldDismissMenu_(e: Event): boolean {
    // All menus are dismissed when clicking outside the menus. If we are
    // showing a sub-menu, we need to detect if the target is the top
    // level menu, or in the sub menu when the sub menu is being shown.
    // The button is excluded here because it should toggle show/hide the
    // menu and handled separately.
    return e.target instanceof Node && !this.contains(e.target) &&
        !this.menu?.contains(e.target);
  }

  /**
   * Display any sub-menu hanging off the current selection.
   */
  showSubMenu() {
    if (!this.isMenuShown()) {
      return;
    }
    this.menu!.showSubMenu();
  }

  /**
   * Do we have a menu visible to handle a keyboard event.
   * @return True if there's a visible menu.
   */
  private hasVisibleMenu_(): boolean {
    if (this.isMenuShown()) {
      return true;
    }
    return false;
  }

  /**
   * Handles event callbacks.
   */
  handleEvent(e: Event) {
    if (!this.menu) {
      return;
    }

    switch (e.type) {
      case 'touchstart':
        // Touch on the menu button itself is ignored to avoid that the menu
        // opened again by the mousedown event following the touch events.
        if (this.shouldDismissMenu_(e)) {
          this.hideMenuWithoutTakingFocus_();
        }
        break;
      case 'mousedown':
        if (e.currentTarget === this.ownerDocument) {
          if (this.shouldDismissMenu_(e)) {
            this.hideMenuWithoutTakingFocus_();
          } else {
            e.preventDefault();
          }
        } else {
          if (this.isMenuShown()) {
            this.hideMenuWithoutTakingFocus_();
          } else if ((e as MouseEvent).button === 0) {
            // Only show the menu when using left mouse button.
            const mouseEvent = e as MouseEvent;
            this.showMenu(
                false, {x: mouseEvent.screenX, y: mouseEvent.screenY});
            // Prevent the button from stealing focus on mousedown unless
            // focus is on another button or cr-input element.
            if (!(document.hasFocus() &&
                  (document.activeElement?.tagName === 'BUTTON' ||
                   document.activeElement?.tagName === 'CR-BUTTON' ||
                   document.activeElement?.tagName === 'CR-INPUT'))) {
              e.preventDefault();
            }
          }
        }

        // Hide the focus ring on mouse click.
        this.classList.add('using-mouse');
        break;
      case 'keydown':
        this.handleKeyDown(e as KeyboardEvent);
        // If a menu is visible we let it handle keyboard events intended for
        // the menu.
        if (e.currentTarget === this.ownerDocument && this.hasVisibleMenu_() &&
            this.isMenuEvent(e as KeyboardEvent)) {
          this.menu.handleKeyDown(e as KeyboardEvent);
          e.preventDefault();
          e.stopPropagation();
        }

        // Show the focus ring on keypress.
        this.classList.remove('using-mouse');
        break;
      case 'focus':
        if (this.shouldDismissMenu_(e)) {
          this.hideMenu();
          // Show the focus ring on focus - if it's come from a mouse event,
          // the focus ring will be hidden in the mousedown event handler,
          // executed after this.
          this.classList.remove('using-mouse');
        }
        break;
      case 'blur':
        // No need to hide the focus ring anymore, without having focus.
        this.classList.remove('using-mouse');
        break;
      case 'activate':
        const hideDelayed = e.target instanceof MenuItem && e.target.checkable;
        const hideType = hideDelayed ? HideType.DELAYED : HideType.INSTANT;
        // If the menu-item hosts a sub-menu, don't hide
        if (this.menu.getSubMenuFromItem((e.target as MenuItem)) !== null) {
          break;
        }
        const activateEvent = e as MenuItemActivationEvent;
        if (activateEvent.originalEvent instanceof MouseEvent ||
            activateEvent.originalEvent instanceof TouchEvent) {
          this.hideMenuWithoutTakingFocus_(hideType);
        } else {
          // Keyboard. Take focus to continue keyboard operation.
          this.hideMenu(hideType);
        }
        break;
      case 'popstate':
      case 'resize':
        this.hideMenu();
        break;
      case 'contextmenu':

        if ((!this.menu || !this.menu.contains(e.target as HTMLElement))) {
          const mouseEvent = e as MouseEvent;
          this.showMenu(true, {x: mouseEvent.screenX, y: mouseEvent.screenY});
        }
        e.preventDefault();
        // Don't allow elements further up in the DOM to show their menus.
        e.stopPropagation();
        break;
      case 'dblclick':
        // Don't allow double click events to propagate.
        e.preventDefault();
        e.stopPropagation();
        break;
    }
  }

  /**
   * Shows the menu.
   * @param shouldSetFocus Whether to set focus on the
   *     selected menu item.
   * @param mousePos The position of the mouse
   *     when shown (in screen coordinates).
   */
  showMenu(shouldSetFocus: boolean, mousePos?: {x: number, y: number}) {
    this.hideMenu();

    assert(this.menu);

    this.menu.updateCommands(this);

    const event = new UIEvent(
        'menushow', {bubbles: true, cancelable: true, view: window});
    if (!this.dispatchEvent(event)) {
      return;
    }

    // Track element for which menu was opened so that command events are
    // dispatched to the correct element.
    this.menu.contextElement = this;
    this.menu.show(mousePos);

    // Toggle aria and open state.
    this.setAttribute('aria-expanded', 'true');
    this.setAttribute('menu-shown', '');

    // When the menu is shown we steal all keyboard events.
    const doc = this.ownerDocument;
    assert(doc.defaultView);
    const win = doc.defaultView;
    this.abortController_ = new AbortController();
    const signal = this.abortController_.signal;
    doc.addEventListener('keydown', this, {capture: true, signal});
    doc.addEventListener('mousedown', this, {capture: true, signal});
    doc.addEventListener('focus', this, {capture: true, signal});
    doc.addEventListener('scroll', this, {capture: true, signal});
    win.addEventListener('popstate', this, {signal});
    win.addEventListener('resize', this, {signal});
    this.menu.addEventListener('contextmenu', this, {signal});
    this.menu.addEventListener('activate', this, {signal});
    this.observedElement_ = this.parentElement;

    assert(this.observedElement_);
    assert(this.observer_);
    this.observer_.observe(this.observedElement_);
    this.positionMenu_();

    if (shouldSetFocus) {
      this.menu.focusSelectedItem();
    }
  }

  /**
   * Hides the menu. If your menu can go out of scope, make sure to call this
   * first.
   * @param hideType Type of hide.
   *     default: HideType.INSTANT.
   */
  hideMenu(hideType?: HideType) {
    this.hideMenuInternal_(true, hideType);
  }

  /**
   * Hides the menu. If your menu can go out of scope, make sure to call this
   * first.
   * @param hideType Type of hide.
   *     default: HideType.INSTANT.
   */
  private hideMenuWithoutTakingFocus_(hideType?: HideType) {
    this.hideMenuInternal_(false, hideType);
  }

  /**
   * Hides the menu. If your menu can go out of scope, make sure to call this
   * first.
   * @param shouldTakeFocus Moves the focus to the button if true.
   * @param hideType Type of hide.
   *     default: HideType.INSTANT.
   */
  private hideMenuInternal_(shouldTakeFocus: boolean, hideType?: HideType) {
    if (!this.isMenuShown()) {
      return;
    }

    // Toggle aria and open state.
    this.setAttribute('aria-expanded', 'false');
    this.removeAttribute('menu-shown');
    assert(this.menu);
    assert(this.observer_);
    assert(this.observedElement_);

    if (hideType === HideType.DELAYED) {
      this.menu.classList.add('hide-delayed');
    } else {
      this.menu.classList.remove('hide-delayed');
    }
    this.menu.hide();

    this.abortController_?.abort();
    if (shouldTakeFocus) {
      this.focus();
    }

    this.observer_.unobserve(this.observedElement_);

    const event = new UIEvent(
        'menuhide', {bubbles: true, cancelable: false, view: window});
    this.dispatchEvent(event);
  }

  /**
   * Whether the menu is shown.
   */
  isMenuShown() {
    return this.hasAttribute('menu-shown');
  }

  /**
   * Positions the menu below the menu button. We check the menu fits
   * in the viewport, and enable scrolling if required.
   */
  private positionMenu_() {
    assert(this.menu);
    const style = this.menu.style;

    style.marginTop = '8px';  // crbug.com/1066727
    // Clear any maxHeight we've set from previous calls into here.
    style.maxHeight = 'none';
    const invertLeftRight = false;
    const anchorType = AnchorType.BELOW;
    positionPopupAroundElement(this, this.menu, anchorType, invertLeftRight);
    // Check if menu is larger than the viewport and adjust its height to
    // enable scrolling if so. Note: style.bottom would have been set to 0.
    const viewportHeight = window.innerHeight;
    const menuRect = this.menu.getBoundingClientRect();
    // Limit the height to fit in the viewport.
    style.maxHeight = (viewportHeight - this.menuEndGap_) + 'px';
    // If the menu is too tall, position 2px from the bottom of the viewport
    // so users can see the end of the menu (helps when scroll is needed).
    if ((menuRect.height + this.menuEndGap_) > viewportHeight) {
      style.bottom = '2px';
    }
    // Let the browser deal with scroll bar generation.
    style.overflowY = 'auto';
  }

  /**
   * Handles the keydown event for the menu button.
   */
  handleKeyDown(e: KeyboardEvent) {
    switch (e.key) {
      case 'ArrowDown':
      case 'ArrowUp':
      case 'Enter':
      case ' ':
        if (!this.isMenuShown()) {
          this.showMenu(true);
        }
        e.preventDefault();
        break;
      case 'Escape':
      case 'Tab':
        this.hideMenu();
        break;
    }
  }

  isMenuEvent(e: KeyboardEvent) {
    switch (e.key) {
      case 'ArrowDown':
      case 'ArrowUp':
      case 'ArrowLeft':
      case 'ArrowRight':
      case 'Enter':
      case ' ':
      case 'Escape':
      case 'Tab':
        return true;
    }
    return false;
  }
}