chromium/ash/webui/camera_app_ui/resources/js/menu.ts

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

import {assert, assertInstanceof} from './assert.js';
import * as dom from './dom.js';
import {getKeyboardShortcut, instantiateTemplate} from './util.js';

// The minimum pixels space between menu and viewport.
const MARGIN = 8;

interface MenuOrigin {
  horizontal: 'center'|'left'|'right';
  vertical: 'bottom'|'center'|'top';
}

interface MenuPosition {
  left: number;
  top: number;
}

interface Classes {
  root: string;
  item: string;
}

interface MenuParams {
  /**
   * The target element to append the menu element.
   */
  target: HTMLElement;
  /**
   * The id to set on the menu element for aria- reference by the entry element.
   */
  id: string;
  /**
   * The root element will be passed to `render` for content customization.
   * `action` is called when the menu item is clicked.
   */
  items: Array<{
    render: (root: HTMLLIElement) => void,
    action: (e: Event) => void,
  }>;
  /**
   * The point on the anchor where the menu will attach to.
   */
  anchorOrigin?: MenuOrigin;
  /**
   * The point on the menu which will attach to the anchor's origin.
   */
  transformOrigin?: MenuOrigin;
  /**
   * The button element to trigger the menu.
   */
  entryElement: HTMLElement;
  /**
   * The alternative anchor for menu to calculate its position. Default is
   * `entryElement`.
   */
  anchorElement?: HTMLElement;
  /**
   * Relatively adjust the position of menu.
   */
  position?: MenuPosition;
  /**
   * The custom classes to set on elements created by Menu.
   */
  classes?: Partial<Classes>;
}

const DEFAULT_ANCHOR_ORIGIN: MenuOrigin = {
  vertical: 'bottom',
  horizontal: 'left',
} as const;
const DEFAULT_TRANSFORM_ORIGIN: MenuOrigin = {
  vertical: 'top',
  horizontal: 'left',
} as const;
const DEFAULT_POSITION: MenuPosition = {
  left: 0,
  top: 0,
} as const;
const DEFAULT_CLASSES: Classes = {
  root: 'menu-root',
  item: 'item',
} as const;

export class Menu {
  private readonly root: HTMLUListElement;

  private readonly entry: HTMLElement;

  private readonly items: HTMLLIElement[] = [];

  private readonly anchorOrigin: MenuOrigin;

  private readonly transformOrigin: MenuOrigin;

  private readonly position: MenuPosition;

  private readonly anchor: HTMLElement;

  private readonly clickAwayListener: (e: MouseEvent) => void;

  private readonly classes: Classes;

  constructor({
    entryElement,
    id,
    items,
    target,
    anchorElement = entryElement,
    anchorOrigin = DEFAULT_ANCHOR_ORIGIN,
    transformOrigin = DEFAULT_TRANSFORM_ORIGIN,
    position = DEFAULT_POSITION,
    classes = {},
  }: MenuParams) {
    const fragment = instantiateTemplate('#menu');
    this.root = dom.getFrom(fragment, '.menu-root', HTMLUListElement);
    this.entry = entryElement;
    this.anchor = anchorElement;
    entryElement.setAttribute('aria-haspopup', 'true');
    entryElement.setAttribute('aria-controls', id);
    this.root.setAttribute('aria-labelledby', entryElement.id);
    this.classes = {...DEFAULT_CLASSES, ...classes};
    this.root.classList.add(this.classes.root);
    this.root.id = id;
    for (const item of items) {
      const fragment = instantiateTemplate('#menu-item');
      const itemElement = dom.getFrom(fragment, '.item', HTMLLIElement);
      itemElement.setAttribute('tabindex', '-1');
      itemElement.classList.add(this.classes.item);
      item.render(itemElement);
      this.root.append(itemElement);
      itemElement.addEventListener('click', (e) => {
        this.handleItemClick(e, item.action);
      });
      itemElement.addEventListener('keydown', (e) => {
        this.handleItemKeydown(e, item.action);
      });
      this.items.push(itemElement);
    }
    this.setupEntry();
    target.append(this.root);
    this.clickAwayListener = (e: MouseEvent) => {
      if (e.target instanceof Node && this.root.contains(e.target)) {
        return;
      }
      this.close();
    };
    this.anchorOrigin = anchorOrigin;
    this.transformOrigin = transformOrigin;
    this.position = position;
  }

  private isOpen() {
    return this.root.getAttribute('aria-expanded') === 'true';
  }

  /**
   * Open the menu.
   */
  open(): void {
    if (this.isOpen()) {
      return;
    }
    this.root.setAttribute('aria-expanded', 'true');
    this.layout();
    window.addEventListener('click', this.clickAwayListener);
  }

  /**
   * Close the menu.
   */
  close(): void {
    if (!this.isOpen()) {
      return;
    }
    this.root.setAttribute('aria-expanded', 'false');
    this.entry.focus();
    window.removeEventListener('click', this.clickAwayListener);
  }

  private focusItem(newItem: HTMLLIElement) {
    for (const item of this.items) {
      assertInstanceof(item, HTMLLIElement).tabIndex = -1;
    }
    newItem.tabIndex = 0;
    newItem.focus();
  }

  private focusFirstItem() {
    this.focusItem(this.items[0]);
  }

  private focusLastItem() {
    this.focusItem(this.items[this.items.length - 1]);
  }

  private setupEntry() {
    this.entry.addEventListener('click', (e) => {
      if (this.isOpen()) {
        this.close();
      } else {
        this.open();
        this.focusFirstItem();
      }
      e.stopPropagation();
      e.preventDefault();
    });
    this.entry.addEventListener('keydown', (e) => {
      const key = getKeyboardShortcut(e);
      switch (key) {
        case 'ArrowDown':
          this.open();
          this.focusFirstItem();
          break;
        case 'ArrowUp':
          this.open();
          this.focusLastItem();
          break;
        default:
          return;
      }
      e.preventDefault();
      e.stopPropagation();
    });
  }

  private handleItemKeydown(e: KeyboardEvent, action: (e: Event) => void) {
    const key = getKeyboardShortcut(e);
    const item = assertInstanceof(e.currentTarget, HTMLLIElement);
    switch (key) {
      case ' ':
      case 'Enter':
        this.close();
        action(e);
        break;
      case 'Escape':
        this.close();
        break;
      case 'ArrowUp':
        this.focusPreviousItem(item);
        break;
      case 'ArrowDown':
        this.focusNextItem(item);
        break;
      case 'Home':
        this.focusFirstItem();
        break;
      case 'End':
        this.focusLastItem();
        break;
      case 'Tab':
      case 'Shift-Tab':
        this.close();
        break;
      default:
        return;
    }
    e.stopPropagation();
    e.preventDefault();
  }

  private handleItemClick(e: MouseEvent, action: (e: Event) => void) {
    this.close();
    action(e);
    e.stopPropagation();
    e.preventDefault();
  }


  private focusPreviousItem(item: HTMLLIElement) {
    const index = this.items.indexOf(item);
    assert(index !== -1);
    const nextIndex = (this.items.length + index - 1) % this.items.length;
    this.focusItem(this.items[nextIndex]);
  }

  private focusNextItem(item: HTMLLIElement) {
    const index = this.items.indexOf(item);
    assert(index !== -1);
    const nextIndex = (index + 1) % this.items.length;
    this.focusItem(this.items[nextIndex]);
  }

  private layout() {
    const {top, right, bottom, left, width, height} =
        this.anchor.getBoundingClientRect();
    let menuLeft = left;
    let menuTop = top;
    if (this.anchorOrigin.horizontal === 'center') {
      menuLeft = left + width / 2;
    } else if (this.anchorOrigin.horizontal === 'right') {
      menuLeft = right;
    }
    if (this.anchorOrigin.vertical === 'center') {
      menuTop = top + height / 2;
    } else if (this.anchorOrigin.vertical === 'bottom') {
      menuTop = bottom;
    }
    menuLeft += this.position.left;
    menuTop += this.position.top;
    const {width: offsetWidth, height: offsetHeight} =
        this.root.getBoundingClientRect();
    if (this.transformOrigin.horizontal === 'center') {
      menuLeft -= offsetWidth / 2;
    } else if (this.transformOrigin.horizontal === 'right') {
      menuLeft -= offsetWidth;
    }
    if (this.transformOrigin.vertical === 'center') {
      menuTop -= offsetHeight / 2;
    } else if (this.transformOrigin.vertical === 'bottom') {
      menuTop -= offsetHeight;
    }
    menuLeft = Math.max(
        Math.min(menuLeft, document.body.offsetWidth - offsetWidth - MARGIN),
        MARGIN);
    menuTop = Math.max(
        Math.min(menuTop, document.body.offsetHeight - offsetHeight - MARGIN),
        MARGIN);
    const style = this.root.attributeStyleMap;
    style.set('left', CSS.px(menuLeft));
    style.set('top', CSS.px(menuTop));
  }
}