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

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

import {dispatchPropertyChange} from 'chrome://resources/ash/common/cr_deprecated.js';
import {assert, assertInstanceof} from 'chrome://resources/js/assert.js';

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

import {MenuItem, type MenuItemActivationEvent} from './menu_item.js';

export interface ShownPosition {
  mouseDownPos?: {
    x: number,
    y: number,
  };

  // Time from Date.now().
  time: number;
}

export class Menu extends HTMLElement {
  private selectedIndex_: number = -1;

  /**
   * Element for which menu is being shown.
   */
  contextElement: HTMLElement|null = null;

  private shown_: ShownPosition|null = null;

  /**
   * Initializes the menu element.
   */
  initialize() {
    this.selectedIndex_ = -1;
    this.contextElement = null;
    this.shown_ = null;

    this.addEventListener('mouseover', this.handleMouseOver_);
    this.addEventListener('mouseout', this.handleMouseOut_);
    this.addEventListener('mouseup', this.handleMouseUp_, true);

    this.classList.add('decorated');
    this.setAttribute('role', 'menu');
    this.hidden = true;  // Hide the menu by default.

    // Decorate the children as menu items.
    for (const item of this.menuItems) {
      crInjectTypeAndInit(item, MenuItem);
    }
  }

  /**
   * Adds menu item at the end of the list.
   * @param item Menu item properties.
   * @return The created menu item.
   */
  addMenuItem(item: {label?: string, iconUrl?: string} = {}): MenuItem {
    const menuItem =
        this.ownerDocument.createElement('cr-menu-item') as MenuItem;
    this.appendChild(menuItem);
    crInjectTypeAndInit(menuItem, MenuItem);

    if (item.label) {
      menuItem.label = item.label;
    }

    if (item.iconUrl) {
      menuItem.iconUrl = item.iconUrl;
    }

    return menuItem;
  }

  /**
   * Adds separator at the end of the list.
   */
  addSeparator() {
    const separator = this.ownerDocument.createElement('hr');
    crInjectTypeAndInit(separator, MenuItem);
    this.appendChild(separator);
  }

  /**
   * Clears menu.
   */
  clear() {
    this.selectedItem = undefined;
    this.textContent = '';
  }

  /**
   * Walks up the ancestors of |node| until a menu item belonging to this menu
   * is found.
   * @param node The node to start searching from.
   * @return The found menu item or undefined.
   */
  protected findMenuItem(node: Node): MenuItem|undefined {
    while (node && node.parentNode !== this && !(node instanceof MenuItem)) {
      node = node.parentNode!;
    }

    if (node) {
      assertInstanceof(node, MenuItem);
      return node;
    }

    return undefined;
  }

  /**
   * Handles mouseover events and selects the hovered item.
   */
  private handleMouseOver_(e: Event) {
    const target = e.target as HTMLElement;
    const overItem = this.findMenuItem(target);
    this.selectedItem = overItem;
  }

  /**
   * Handles mouseout events and deselects any selected item.
   * @param e The mouseout event.
   */
  private handleMouseOut_(_e: Event) {
    this.selectedItem = undefined;
  }

  /**
   * If there's a mouseup that happens quickly in about the same position,
   * stop it from propagating to items. This is to prevent accidentally
   * selecting a menu item that's created under the mouse cursor.
   * @param e A mouseup event on the menu (in capturing phase).
   */
  private handleMouseUp_(e: MouseEvent) {
    const target = e.target as HTMLElement;

    assert(this.contains(target));
    assert(this.shown_);
    if (!this.trustEvent_(e) || Date.now() - this.shown_.time > 200) {
      return;
    }

    const pos = this.shown_.mouseDownPos;
    if (!pos || Math.abs(pos.x - e.screenX) + Math.abs(pos.y - e.screenY) > 4) {
      return;
    }

    e.preventDefault();
    e.stopPropagation();
  }

  /**
   * @return Whether `e` can be trusted.
   */
  private trustEvent_(e: Event): boolean {
    return e.isTrusted || (e as any).isTrustedForTesting;
  }

  get menuItems(): MenuItem[] {
    return Array.from(this.querySelectorAll(this.menuItemSelector || '*'));
  }

  /**
   * The selected menu item or undefined if none.
   */
  get selectedItem(): MenuItem|undefined {
    return this.menuItems[this.selectedIndex];
  }

  set selectedItem(item: MenuItem|undefined) {
    const index = this.menuItems.indexOf(item!);
    this.selectedIndex = index;
  }

  /**
   * Focuses the selected item. If selectedIndex is invalid, set it to 0
   * first.
   */
  focusSelectedItem() {
    const items = this.menuItems;
    if (this.selectedIndex < 0 || this.selectedIndex > items.length) {
      // Find first visible item to focus by default.
      for (const [idx, item] of items.entries()) {
        if (item.hasAttribute('hidden') || item.isSeparator()) {
          continue;
        }
        // If the item is disabled we accept it, but try to find the next
        // enabled item, but keeping the first disabled item.
        if (!item.disabled) {
          this.selectedIndex = idx;
          break;
        } else if (this.selectedIndex === -1) {
          this.selectedIndex = idx;
        }
      }
    }

    if (this.selectedItem) {
      this.selectedItem.focus();
      this.setAttribute('aria-activedescendant', this.selectedItem.id);
    }
  }

  /**
   * Menu length
   */
  get length() {
    return this.menuItems.length;
  }

  /**
   * Returns whether the given menu item is visible.
   */
  private isItemVisible_(menuItem: MenuItem): boolean {
    if (menuItem.hidden) {
      return false;
    }
    if (menuItem.offsetParent) {
      return true;
    }
    // A "position: fixed" element won't have an offsetParent, so we have to
    // do the full style computation.
    return window.getComputedStyle(menuItem).display !== 'none';
  }

  /**
   * Returns whether the menu has any visible items.
   * @return True if the menu has visible item. Otherwise, false.
   */
  hasVisibleItems(): boolean {
    // Inspect items in reverse order to determine if the separator above each
    // set of items is required.
    for (const menuItem of this.menuItems) {
      if (this.isItemVisible_(menuItem)) {
        return true;
      }
    }
    return false;
  }

  /**
   * This is the function that handles keyboard navigation. This is usually
   * called by the element responsible for managing the menu.
   * @param e The keydown event object.
   * @return Whether the event was handled be the menu.
   */
  handleKeyDown(e: KeyboardEvent): boolean {
    let item = this.selectedItem;

    const self = this;
    const selectNextAvailable = (m: number) => {
      const menuItems = self.menuItems;
      const len = menuItems.length;
      if (!len) {
        // Edge case when there are no items.
        return;
      }
      let i = self.selectedIndex;
      if (i === -1 && m === -1) {
        // Edge case when needed to go the last item first.
        i = 0;
      }

      // `i` may be negative(-1), so modulus operation and cycle below
      // wouldn't work as assumed. This trick makes startPosition positive
      // without altering it's modulo.
      const startPosition = (i + len) % len;

      while (true) {
        i = (i + m + len) % len;

        // Check not to enter into infinite loop if all items are hidden or
        // disabled.
        if (i === startPosition) {
          break;
        }

        item = menuItems[i];
        if (item && !item.isSeparator() && !item.disabled &&
            this.isItemVisible_(item)) {
          break;
        }
      }
      if (item && !item.disabled) {
        self.selectedIndex = i;
      }
    };

    switch (e.key) {
      case 'ArrowDown':
        selectNextAvailable(1);
        this.focusSelectedItem();
        return true;
      case 'ArrowUp':
        selectNextAvailable(-1);
        this.focusSelectedItem();
        return true;
      case 'Enter':
      case ' ':
        if (item) {
          // Store |contextElement| since it'll be removed when handling the
          // 'activate' event.
          const contextElement = this.contextElement;
          const activationEvent =
              document.createEvent('Event') as MenuItemActivationEvent;
          activationEvent.initEvent('activate', true, true);
          activationEvent.originalEvent = e;
          if (item.dispatchEvent(activationEvent)) {
            if (item.command) {
              item.command.execute(contextElement);
            }
          }
        }
        return true;
    }

    return false;
  }

  hide() {
    this.hidden = true;
    this.shown_ = null;
  }

  show(mouseDownPos?: {x: number, y: number}) {
    this.shown_ = {mouseDownPos: mouseDownPos, time: Date.now()};
    this.hidden = false;
  }

  /**
   * Updates menu items command according to context.
   * @param node Node for which to actuate commands state.
   */
  updateCommands(node?: Node) {
    const menuItems = this.menuItems;

    for (const menuItem of menuItems) {
      if (!menuItem.isSeparator()) {
        menuItem.updateCommand(node);
      }
    }

    let separatorRequired = false;
    let lastSeparator = null;
    // Hide any separators without a visible item between them and the next
    // separator or the end of the menu.
    for (const menuItem of menuItems) {
      if (menuItem.isSeparator()) {
        if (separatorRequired) {
          lastSeparator = menuItem;
        }
        menuItem.hidden = true;
        separatorRequired = false;
        continue;
      }
      if (this.isItemVisible_(menuItem)) {
        if (lastSeparator) {
          lastSeparator.hidden = false;
        }
        separatorRequired = true;
      }
    }
  }

  private selectedIndexChanged_(oldSelectedIndex: number) {
    const oldSelectedItem = this.menuItems[oldSelectedIndex];
    if (oldSelectedItem) {
      oldSelectedItem.selected = false;
      oldSelectedItem.blur();
    }
    const item = this.selectedItem;
    if (item) {
      item.selected = true;
    }
  }

  /**
   * The selected menu item.
   */
  get selectedIndex(): number {
    return this.selectedIndex_;
  }

  set selectedIndex(value: number) {
    const oldValue = this.selectedIndex_;
    this.selectedIndex_ = value;
    this.selectedIndexChanged_(oldValue);
    dispatchPropertyChange(this, 'selectedIndex', value, oldValue);
  }

  /**
   * Selector for children which are menu items.
   */
  get menuItemSelector(): string {
    return this.getAttribute(convertToKebabCase('menuItemSelector')) ?? '';
  }

  set menuItemSelector(value: string) {
    domAttrSetter(this, 'menuItemSelector', value);
  }
}