chromium/third_party/google-closure-library/closure/goog/ui/submenu_test.js

/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

goog.module('goog.ui.SubMenuTest');
goog.setTestOnly();

const Component = goog.require('goog.ui.Component');
const GoogEvent = goog.require('goog.events.Event');
const KeyCodes = goog.require('goog.events.KeyCodes');
const KeyHandler = goog.require('goog.events.KeyHandler');
const Menu = goog.require('goog.ui.Menu');
const MenuItem = goog.require('goog.ui.MenuItem');
const MockClock = goog.require('goog.testing.MockClock');
const Overflow = goog.require('goog.positioning.Overflow');
const State = goog.require('goog.a11y.aria.State');
const SubMenu = goog.require('goog.ui.SubMenu');
const SubMenuRenderer = goog.require('goog.ui.SubMenuRenderer');
const aria = goog.require('goog.a11y.aria');
const classlist = goog.require('goog.dom.classlist');
const dom = goog.require('goog.dom');
const events = goog.require('goog.events');
const functions = goog.require('goog.functions');
const positioning = goog.require('goog.positioning');
const style = goog.require('goog.style');
const testSuite = goog.require('goog.testing.testSuite');
const testingEvents = goog.require('goog.testing.events');

let menu;
let clonedMenuDom;

let mockClock;

// mock out goog.positioning.positionAtCoordinate so that
// the menu always fits. (we don't care about testing the
// dynamic menu positioning if the menu doesn't fit in the window.)
const oldPositionFn = positioning.positionAtCoordinate;
/** @suppress {checkTypes} suppression added to enable type checking */
positioning.positionAtCoordinate =
    (absolutePos, movableElement, movableElementCorner, margin = undefined,
     overflow = undefined) =>
        oldPositionFn.call(
            null, absolutePos, movableElement, movableElementCorner, margin,
            Overflow.IGNORE);

function assertKeyHandlingIsCorrect(keyToOpenSubMenu, keyToCloseSubMenu) {
  menu.setFocusable(true);
  menu.decorate(dom.getElement('demoMenu'));

  const plainItem = menu.getChildAt(0);
  plainItem.setMnemonic(KeyCodes.F);

  const subMenuItem1 = menu.getChildAt(1);
  subMenuItem1.setMnemonic(KeyCodes.S);
  const subMenuItem1Menu = subMenuItem1.getMenu();
  menu.setHighlighted(plainItem);

  const fireKeySequence = testingEvents.fireKeySequence;

  const keySequenceReturn =
      fireKeySequence(plainItem.getElement(), keyToOpenSubMenu);

  // Expect keyToOpenSubMenu to only be handled when ENTER because menuItem
  // handles ENTER, while subMenu handles LEFT, RIGHT and ENTER.
  if (keyToOpenSubMenu == KeyCodes.ENTER) {
    assertFalse(
        'Expected OpenSubMenu key to be handled when KeyCode is ENTER',
        keySequenceReturn);
  } else {
    assertTrue('Expected OpenSubMenu key to not be handled', keySequenceReturn);
  }
  assertFalse(subMenuItem1Menu.isVisible());

  // Expect ENTER to be handled when subMenu is visible.
  subMenuItem1Menu.setVisible(true);
  assertFalse(fireKeySequence(subMenuItem1.getElement(), KeyCodes.ENTER));

  assertFalse(
      'Expected F key to be handled',
      fireKeySequence(plainItem.getElement(), KeyCodes.F));

  assertFalse(
      'Expected DOWN key to be handled',
      fireKeySequence(plainItem.getElement(), KeyCodes.DOWN));
  assertEquals(subMenuItem1, menu.getChildAt(1));

  // Expect keyToOpenSubMenu to be handled when subMenu is not visible.
  subMenuItem1Menu.setVisible(false);
  assertFalse(
      'Expected OpenSubMenu key to be handled',
      fireKeySequence(subMenuItem1.getElement(), keyToOpenSubMenu));
  assertTrue(subMenuItem1Menu.isVisible());

  assertFalse(
      'Expected CloseSubMenu key to be handled',
      fireKeySequence(subMenuItem1.getElement(), keyToCloseSubMenu));
  assertFalse(subMenuItem1Menu.isVisible());

  assertFalse(
      'Expected UP key to be handled',
      fireKeySequence(subMenuItem1.getElement(), KeyCodes.UP));

  assertFalse(
      'Expected S key to be handled',
      fireKeySequence(plainItem.getElement(), KeyCodes.S));
  assertTrue(subMenuItem1Menu.isVisible());
}

/**
 * Asserts that this sub menu renders in the right direction relative to
 * the parent menu.
 * @param {SubMenu} subMenu The sub menu.
 * @param {boolean} left True for left-pointing, false for right-pointing.
 */
function assertRenderDirection(subMenu, left) {
  subMenu.getParent().setHighlighted(subMenu);
  subMenu.showSubMenu();
  const menuItemPosition = style.getPageOffset(subMenu.getElement());
  const menuPosition = style.getPageOffset(subMenu.getMenu().getElement());
  assert(Math.abs(menuItemPosition.y - menuPosition.y) < 5);
  assertEquals(
      'Menu at: ' + menuPosition.x + ', submenu item at: ' + menuItemPosition.x,
      left, menuPosition.x < menuItemPosition.x);
}

/**
 * Asserts that this sub menu has a properly-oriented arrow.
 * @param {SubMenu} subMenu The sub menu.
 * @param {boolean} left True for left-pointing, false for right-pointing.
 * @suppress {visibility} suppression added to enable type checking
 */
function assertArrowDirection(subMenu, left) {
  assertEquals(
      left ? SubMenuRenderer.LEFT_ARROW_ : SubMenuRenderer.RIGHT_ARROW_,
      getArrowElement(subMenu).innerHTML);
}

/**
 * Asserts that the arrow position is correct.
 * @param {SubMenu} subMenu The sub menu.
 * @param {boolean} leftAlign True for left-aligned, false for right-aligned.
 * @suppress {checkTypes} suppression added to enable type checking
 */
function assertArrowPosition(subMenu, left) {
  const arrow = getArrowElement(subMenu);
  /**
   * @suppress {strictMissingProperties} suppression added to enable type
   * checking
   */
  const expectedLeft =
      left ? 0 : arrow.offsetParent.offsetWidth - arrow.offsetWidth;
  /**
   * @suppress {strictMissingProperties} suppression added to enable type
   * checking
   */
  const actualLeft = arrow.offsetLeft;
  assertTrue(
      `Expected left offset: ${expectedLeft}
` +
          'Actual left offset: ' + actualLeft + '\n',
      Math.abs(expectedLeft - actualLeft) < 5);
}

/**
 * Gets the arrow element of a sub menu.
 * @param {SubMenu} subMenu The sub menu.
 * @return {Element} The arrow.
 * @suppress {checkTypes} suppression added to enable type checking
 */
function getArrowElement(subMenu) {
  return subMenu.getContentElement().lastChild;
}
testSuite({
  setUp() {
    clonedMenuDom = dom.getElement('demoMenu').cloneNode(true);

    menu = new Menu();
  },

  tearDown() {
    document.body.style.direction = 'ltr';
    menu.dispose();

    const element = dom.getElement('demoMenu');
    element.parentNode.replaceChild(clonedMenuDom, element);

    dom.removeChildren(dom.getElement('sandbox'));

    if (mockClock) {
      mockClock.uninstall();
      mockClock = null;
    }
  },

  testEnterOpensSubmenu() {
    assertKeyHandlingIsCorrect(KeyCodes.ENTER, KeyCodes.LEFT);
  },

  testKeyHandling_ltr() {
    assertKeyHandlingIsCorrect(KeyCodes.RIGHT, KeyCodes.LEFT);
  },

  testKeyHandling_rtl() {
    document.body.style.direction = 'rtl';
    assertKeyHandlingIsCorrect(KeyCodes.LEFT, KeyCodes.RIGHT);
  },

  testNormalLtrSubMenu() {
    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    assertArrowDirection(subMenu, false);
    assertRenderDirection(subMenu, false);
    assertArrowPosition(subMenu, false);
  },

  testNormalRtlSubMenu() {
    document.body.style.direction = 'rtl';
    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    assertArrowDirection(subMenu, true);
    assertRenderDirection(subMenu, true);
    assertArrowPosition(subMenu, true);
  },

  testLtrSubMenuAlignedToStart() {
    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    subMenu.setAlignToEnd(false);
    assertArrowDirection(subMenu, true);
    assertRenderDirection(subMenu, true);
    assertArrowPosition(subMenu, false);
  },

  testNullContentElement() {
    /** @suppress {checkTypes} suppression added to enable type checking */
    const subMenu = new SubMenu();
    subMenu.setContent('demo');
  },

  testRtlSubMenuAlignedToStart() {
    document.body.style.direction = 'rtl';
    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    subMenu.setAlignToEnd(false);
    assertArrowDirection(subMenu, false);
    assertRenderDirection(subMenu, false);
    assertArrowPosition(subMenu, true);
  },

  testSetContentKeepsArrow_ltr() {
    document.body.style.direction = 'ltr';
    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    subMenu.setAlignToEnd(false);
    subMenu.setContent('test');
    assertArrowDirection(subMenu, true);
    assertRenderDirection(subMenu, true);
    assertArrowPosition(subMenu, false);
  },

  testSetContentKeepsArrow_rtl() {
    document.body.style.direction = 'rtl';
    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    subMenu.setAlignToEnd(false);
    subMenu.setContent('test');
    assertArrowDirection(subMenu, false);
    assertRenderDirection(subMenu, false);
    assertArrowPosition(subMenu, true);
  },

  testExitDocument() {
    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    const innerMenu = subMenu.getMenu();

    assertTrue('Top-level menu was not in document', menu.isInDocument());
    assertTrue('Submenu was not in document', subMenu.isInDocument());
    assertTrue('Inner menu was not in document', innerMenu.isInDocument());

    menu.exitDocument();

    assertFalse('Top-level menu was in document', menu.isInDocument());
    assertFalse('Submenu was in document', subMenu.isInDocument());
    assertFalse('Inner menu was in document', innerMenu.isInDocument());
  },

  testDisposal() {
    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    const innerMenu = subMenu.getMenu();
    menu.dispose();

    assert('Top-level menu was not disposed', menu.getDisposed());
    assert('Submenu was not disposed', subMenu.getDisposed());
    assert('Inner menu was not disposed', innerMenu.getDisposed());
  },

  testShowAndDismissSubMenu() {
    let openEventDispatched = false;
    let closeEventDispatched = false;

    function handleEvent(e) {
      switch (e.type) {
        case Component.EventType.OPEN:
          openEventDispatched = true;
          break;
        case Component.EventType.CLOSE:
          closeEventDispatched = true;
          break;
        default:
          fail('Invalid event type: ' + e.type);
      }
    }

    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    subMenu.setHighlighted(true);

    events.listen(
        subMenu, [Component.EventType.OPEN, Component.EventType.CLOSE],
        handleEvent);

    assertFalse(
        'Submenu must not have "-open" CSS class',
        classlist.contains(subMenu.getElement(), 'goog-submenu-open'));
    assertFalse(
        'Popup menu must not be visible', subMenu.getMenu().isVisible());
    assertFalse('No OPEN event must have been dispatched', openEventDispatched);
    assertFalse(
        'No CLOSE event must have been dispatched', closeEventDispatched);

    subMenu.showSubMenu();
    assertTrue(
        'Submenu must have "-open" CSS class',
        classlist.contains(subMenu.getElement(), 'goog-submenu-open'));
    assertTrue('Popup menu must be visible', subMenu.getMenu().isVisible());
    assertTrue('OPEN event must have been dispatched', openEventDispatched);
    assertFalse(
        'No CLOSE event must have been dispatched', closeEventDispatched);

    subMenu.dismissSubMenu();
    assertFalse(
        'Submenu must not have "-open" CSS class',
        classlist.contains(subMenu.getElement(), 'goog-submenu-open'));
    assertFalse(
        'Popup menu must not be visible', subMenu.getMenu().isVisible());
    assertTrue('CLOSE event must have been dispatched', closeEventDispatched);

    events.unlisten(
        subMenu, [Component.EventType.OPEN, Component.EventType.CLOSE],
        handleEvent);
  },

  testDismissWhenSubMenuNotVisible() {
    let openEventDispatched = false;
    let closeEventDispatched = false;

    function handleEvent(e) {
      switch (e.type) {
        case Component.EventType.OPEN:
          openEventDispatched = true;
          break;
        case Component.EventType.CLOSE:
          closeEventDispatched = true;
          break;
        default:
          fail('Invalid event type: ' + e.type);
      }
    }

    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    subMenu.setHighlighted(true);

    events.listen(
        subMenu, [Component.EventType.OPEN, Component.EventType.CLOSE],
        handleEvent);

    assertFalse(
        'Submenu must not have "-open" CSS class',
        classlist.contains(subMenu.getElement(), 'goog-submenu-open'));
    assertFalse(
        'Popup menu must not be visible', subMenu.getMenu().isVisible());
    assertFalse('No OPEN event must have been dispatched', openEventDispatched);
    assertFalse(
        'No CLOSE event must have been dispatched', closeEventDispatched);

    subMenu.showSubMenu();
    subMenu.getMenu().setVisible(false);

    subMenu.dismissSubMenu();
    assertFalse(
        'Submenu must not have "-open" CSS class',
        classlist.contains(subMenu.getElement(), 'goog-submenu-open'));
    assertFalse(subMenu.menuIsVisible_);
    assertFalse(
        'Popup menu must not be visible', subMenu.getMenu().isVisible());
    assertTrue('CLOSE event must have been dispatched', closeEventDispatched);

    events.unlisten(
        subMenu, [Component.EventType.OPEN, Component.EventType.CLOSE],
        handleEvent);
  },

  /** @suppress {checkTypes} suppression added to enable type checking */
  testCloseSubMenuBehavior() {
    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    subMenu.getElement().id = 'subMenu';

    const innerMenu = subMenu.getMenu();
    innerMenu.getChildAt(0).getElement().id = 'child1';

    subMenu.setHighlighted(true);
    subMenu.showSubMenu();

    function MyFakeEvent(keyCode, opt_eventType) {
      this.type = opt_eventType || KeyHandler.EventType.KEY;
      this.keyCode = keyCode;
      this.propagationStopped = false;
      this.preventDefault = goog.nullFunction;
      this.stopPropagation = function() {
        this.propagationStopped = true;
      };
    }

    // Focus on the first item in the submenu and verify the activedescendant is
    // set correctly.
    subMenu.handleKeyEvent(new MyFakeEvent(KeyCodes.DOWN));
    assertEquals(
        'First item in submenu must be the aria-activedescendant', 'child1',
        aria.getState(menu.getElement(), State.ACTIVEDESCENDANT));

    // Dismiss the submenu and verify the activedescendant is updated correctly.
    subMenu.handleKeyEvent(new MyFakeEvent(KeyCodes.LEFT));
    assertEquals(
        'Submenu must be the aria-activedescendant', 'subMenu',
        aria.getState(menu.getElement(), State.ACTIVEDESCENDANT));
  },

  testLazyInstantiateSubMenu() {
    menu.decorate(dom.getElement('demoMenu'));
    const subMenu = menu.getChildAt(1);
    subMenu.setHighlighted(true);

    let lazyMenu;

    const key = events.listen(subMenu, Component.EventType.OPEN, (e) => {
      lazyMenu = new Menu();
      lazyMenu.addItem(new MenuItem('foo'));
      lazyMenu.addItem(new MenuItem('bar'));
      subMenu.setMenu(lazyMenu, /* opt_internal */ false);
    });

    subMenu.showSubMenu();
    assertNotNull('Popup menu must have been created', lazyMenu);
    assertEquals(
        'Popup menu must be a child of the submenu', subMenu,
        lazyMenu.getParent());
    assertTrue('Popup menu must have been rendered', lazyMenu.isInDocument());
    assertTrue('Popup menu must be visible', lazyMenu.isVisible());

    menu.dispose();
    assertTrue('Submenu must have been disposed of', subMenu.isDisposed());
    assertFalse(
        'Popup menu must not have been disposed of', lazyMenu.isDisposed());

    lazyMenu.dispose();

    events.unlistenByKey(key);
  },

  testReusableMenu() {
    const subMenuOne = new SubMenu('SubMenu One');
    const subMenuTwo = new SubMenu('SubMenu Two');
    menu.addItem(subMenuOne);
    menu.addItem(subMenuTwo);
    menu.render(dom.getElement('sandbox'));

    // It is possible for the same popup menu to be shared between different
    // submenus.
    const sharedMenu = new Menu();
    sharedMenu.addItem(new MenuItem('Hello'));
    sharedMenu.addItem(new MenuItem('World'));

    assertNull('Shared menu must not have a parent', sharedMenu.getParent());

    subMenuOne.setMenu(sharedMenu);
    assertEquals(
        'SubMenuOne must point to the shared menu', sharedMenu,
        subMenuOne.getMenu());
    assertEquals(
        'SubMenuOne must be the shared menu\'s parent', subMenuOne,
        sharedMenu.getParent());

    subMenuTwo.setMenu(sharedMenu);
    assertEquals(
        'SubMenuTwo must point to the shared menu', sharedMenu,
        subMenuTwo.getMenu());
    assertEquals(
        'SubMenuTwo must be the shared menu\'s parent', subMenuTwo,
        sharedMenu.getParent());
    assertEquals(
        'SubMenuOne must still point to the shared menu', sharedMenu,
        subMenuOne.getMenu());

    menu.setHighlighted(subMenuOne);
    subMenuOne.showSubMenu();
    assertEquals(
        'SubMenuOne must point to the shared menu', sharedMenu,
        subMenuOne.getMenu());
    assertEquals(
        'SubMenuOne must be the shared menu\'s parent', subMenuOne,
        sharedMenu.getParent());
    assertEquals(
        'SubMenuTwo must still point to the shared menu', sharedMenu,
        subMenuTwo.getMenu());
    assertTrue('Shared menu must be visible', sharedMenu.isVisible());

    menu.setHighlighted(subMenuTwo);
    subMenuTwo.showSubMenu();
    assertEquals(
        'SubMenuTwo must point to the shared menu', sharedMenu,
        subMenuTwo.getMenu());
    assertEquals(
        'SubMenuTwo must be the shared menu\'s parent', subMenuTwo,
        sharedMenu.getParent());
    assertEquals(
        'SubMenuOne must still point to the shared menu', sharedMenu,
        subMenuOne.getMenu());
    assertTrue('Shared menu must be visible', sharedMenu.isVisible());
  },

  /**
   * If you remove a submenu in the interval between when a mouseover event
   * is fired on it, and showSubMenu() is called, showSubMenu causes a null
   * value to be dereferenced. This test validates that the fix for this works.
   * (See bug 1823144).
   * @suppress {checkTypes} suppression added to enable type checking
   */
  testDeleteItemDuringSubmenuDisplayInterval() {
    mockClock = new MockClock(true);

    const submenu = new SubMenu('submenu');
    submenu.addItem(new MenuItem('submenu item 1'));
    menu.addItem(submenu);

    // Trigger mouseover, and remove item before showSubMenu can be called.
    /** @suppress {checkTypes} suppression added to enable type checking */
    const e = new GoogEvent();
    submenu.handleMouseOver(e);
    menu.removeItem(submenu);
    mockClock.tick(SubMenu.MENU_DELAY_MS);
    // (No JS error should occur.)
  },

  testShowSubMenuAfterRemoval() {
    const submenu = new SubMenu('submenu');
    menu.addItem(submenu);
    menu.removeItem(submenu);
    submenu.showSubMenu();
    // (No JS error should occur.)
  },

  /**
     Tests that if a sub menu is selectable, then it can handle actions.
     @suppress {visibility} suppression added to enable type checking
   */
  testSubmenuSelectable() {
    const submenu = new SubMenu('submenu');
    submenu.addItem(new MenuItem('submenu item 1'));
    menu.addItem(submenu);
    submenu.setSelectable(true);

    let numClicks = 0;
    const menuClickedFn = (e) => {
      numClicks++;
    };

    events.listen(submenu, Component.EventType.ACTION, menuClickedFn);
    submenu.performActionInternal(null);
    submenu.performActionInternal(null);

    assertEquals('The submenu should have fired an event', 2, numClicks);

    submenu.setSelectable(false);
    submenu.performActionInternal(null);

    assertEquals(
        'The submenu should not have fired any further events', 2, numClicks);
  },

  /**
     Tests that if a sub menu is checkable, then it can handle actions.
     @suppress {visibility} suppression added to enable type checking
   */
  testSubmenuCheckable() {
    const submenu = new SubMenu('submenu');
    submenu.addItem(new MenuItem('submenu item 1'));
    menu.addItem(submenu);
    submenu.setCheckable(true);

    let numClicks = 0;
    const menuClickedFn = (e) => {
      numClicks++;
    };

    events.listen(submenu, Component.EventType.ACTION, menuClickedFn);
    submenu.performActionInternal(null);
    submenu.performActionInternal(null);

    assertEquals('The submenu should have fired an event', 2, numClicks);

    submenu.setCheckable(false);
    submenu.performActionInternal(null);

    assertEquals(
        'The submenu should not have fired any further events', 2, numClicks);
  },

  /**
     Tests that entering a child menu cancels the dismiss timer for the
     submenu.
   */
  testEnteringChildCancelsDismiss() {
    const submenu = new SubMenu('submenu');
    submenu.isInDocument = functions.TRUE;
    submenu.addItem(new MenuItem('submenu item 1'));
    menu.addItem(submenu);

    mockClock = new MockClock(true);
    submenu.getMenu().setVisible(true);

    // This starts the dismiss timer.
    submenu.setHighlighted(false);

    // This should cancel the dismiss timer.
    submenu.getMenu().dispatchEvent(Component.EventType.ENTER);

    // Tick the length of the dismiss timer.
    mockClock.tick(SubMenu.MENU_DELAY_MS);

    // Check that the menu is now highlighted and still visible.
    assertTrue(submenu.getMenu().isVisible());
    assertTrue(submenu.isHighlighted());
  },
});