* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
* Blockly menu similar to Closure's goog.ui.Menu
*
* @class
*/
import * as browserEvents from './browser_events.js';
import type {MenuItem} from './menuitem.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import type {Size} from './utils/size.js';
import * as style from './utils/style.js';
* A basic menu class.
*/
export class Menu {
* Array of menu items.
* (Nulls are never in the array, but typing the array as nullable prevents
* the compiler from objecting to .indexOf(null))
*/
private readonly menuItems: MenuItem[] = [];
* Coordinates of the mousedown event that caused this menu to open. Used to
* prevent the consequent mouseup event due to a simple click from
* activating a menu item immediately.
*/
openingCoords: Coordinate | null = null;
* This is the element that we will listen to the real focus events on.
* A value of null means no menu item is highlighted.
*/
private highlightedItem: MenuItem | null = null;
private mouseOverHandler: browserEvents.Data | null = null;
private clickHandler: browserEvents.Data | null = null;
private mouseEnterHandler: browserEvents.Data | null = null;
private mouseLeaveHandler: browserEvents.Data | null = null;
private onKeyDownHandler: browserEvents.Data | null = null;
private element: HTMLDivElement | null = null;
private roleName: aria.Role | null = null;
constructor() {}
* Add a new menu item to the bottom of this menu.
*
* @param menuItem Menu item to append.
* @internal
*/
addChild(menuItem: MenuItem) {
this.menuItems.push(menuItem);
}
* Creates the menu DOM.
*
* @param container Element upon which to append this menu.
* @returns The menu's root DOM element.
*/
render(container: Element): HTMLDivElement {
const element = document.createElement('div');
element.className = 'blocklyMenu goog-menu blocklyNonSelectable';
element.tabIndex = 0;
if (this.roleName) {
aria.setRole(element, this.roleName);
}
this.element = element;
for (let i = 0, menuItem; (menuItem = this.menuItems[i]); i++) {
element.appendChild(menuItem.createDom());
}
this.mouseOverHandler = browserEvents.conditionalBind(
element,
'pointerover',
this,
this.handleMouseOver,
true,
);
this.clickHandler = browserEvents.conditionalBind(
element,
'pointerup',
this,
this.handleClick,
true,
);
this.mouseEnterHandler = browserEvents.conditionalBind(
element,
'pointerenter',
this,
this.handleMouseEnter,
true,
);
this.mouseLeaveHandler = browserEvents.conditionalBind(
element,
'pointerleave',
this,
this.handleMouseLeave,
true,
);
this.onKeyDownHandler = browserEvents.conditionalBind(
element,
'keydown',
this,
this.handleKeyEvent,
);
container.appendChild(element);
return element;
}
* Gets the menu's element.
*
* @returns The DOM element.
* @internal
*/
getElement(): HTMLDivElement | null {
return this.element;
}
* Focus the menu element.
*
* @internal
*/
focus() {
const el = this.getElement();
if (el) {
el.focus({preventScroll: true});
dom.addClass(el, 'blocklyFocused');
}
}
private blur() {
const el = this.getElement();
if (el) {
el.blur();
dom.removeClass(el, 'blocklyFocused');
}
}
* Set the menu accessibility role.
*
* @param roleName role name.
* @internal
*/
setRole(roleName: aria.Role) {
this.roleName = roleName;
}
dispose() {
if (this.mouseOverHandler) {
browserEvents.unbind(this.mouseOverHandler);
this.mouseOverHandler = null;
}
if (this.clickHandler) {
browserEvents.unbind(this.clickHandler);
this.clickHandler = null;
}
if (this.mouseEnterHandler) {
browserEvents.unbind(this.mouseEnterHandler);
this.mouseEnterHandler = null;
}
if (this.mouseLeaveHandler) {
browserEvents.unbind(this.mouseLeaveHandler);
this.mouseLeaveHandler = null;
}
if (this.onKeyDownHandler) {
browserEvents.unbind(this.onKeyDownHandler);
this.onKeyDownHandler = null;
}
for (let i = 0, menuItem; (menuItem = this.menuItems[i]); i++) {
menuItem.dispose();
}
this.element = null;
}
* Returns the child menu item that owns the given DOM element,
* or null if no such menu item is found.
*
* @param elem DOM element whose owner is to be returned.
* @returns Menu item for which the DOM element belongs to.
*/
private getMenuItem(elem: Element): MenuItem | null {
const menuElem = this.getElement();
let currentElement: Element | null = elem;
while (currentElement && currentElement !== menuElem) {
if (currentElement.classList.contains('blocklyMenuItem')) {
for (let i = 0, menuItem; (menuItem = this.menuItems[i]); i++) {
if (menuItem.getElement() === currentElement) {
return menuItem;
}
}
}
currentElement = currentElement.parentElement;
}
return null;
}
* Highlights the given menu item, or clears highlighting if null.
*
* @param item Item to highlight, or null.
* @internal
*/
setHighlighted(item: MenuItem | null) {
const currentHighlighted = this.highlightedItem;
if (currentHighlighted) {
currentHighlighted.setHighlighted(false);
this.highlightedItem = null;
}
if (item) {
item.setHighlighted(true);
this.highlightedItem = item;
const el = this.getElement() as Element;
style.scrollIntoContainerView(item.getElement() as Element, el);
aria.setState(el, aria.State.ACTIVEDESCENDANT, item.getId());
}
}
* Highlights the next highlightable item (or the first if nothing is
* currently highlighted).
*
* @internal
*/
highlightNext() {
const index = this.highlightedItem
? this.menuItems.indexOf(this.highlightedItem)
: -1;
this.highlightHelper(index, 1);
}
* Highlights the previous highlightable item (or the last if nothing is
* currently highlighted).
*
* @internal
*/
highlightPrevious() {
const index = this.highlightedItem
? this.menuItems.indexOf(this.highlightedItem)
: -1;
this.highlightHelper(index < 0 ? this.menuItems.length : index, -1);
}
private highlightFirst() {
this.highlightHelper(-1, 1);
}
private highlightLast() {
this.highlightHelper(this.menuItems.length, -1);
}
* Helper function that manages the details of moving the highlight among
* child menuitems in response to keyboard events.
*
* @param startIndex Start index.
* @param delta Step direction: 1 to go down, -1 to go up.
*/
private highlightHelper(startIndex: number, delta: number) {
let index = startIndex + delta;
let menuItem;
while ((menuItem = this.menuItems[index])) {
if (menuItem.isEnabled()) {
this.setHighlighted(menuItem);
break;
}
index += delta;
}
}
* Handles mouseover events. Highlight menuitems as the user hovers over them.
*
* @param e Mouse event to handle.
*/
private handleMouseOver(e: PointerEvent) {
const menuItem = this.getMenuItem(e.target as Element);
if (menuItem) {
if (menuItem.isEnabled()) {
if (this.highlightedItem !== menuItem) {
this.setHighlighted(menuItem);
}
} else {
this.setHighlighted(null);
}
}
}
* Handles click events. Pass the event onto the child menuitem to handle.
*
* @param e Click event to handle.
*/
private handleClick(e: PointerEvent) {
const oldCoords = this.openingCoords;
this.openingCoords = null;
if (oldCoords && typeof e.clientX === 'number') {
const newCoords = new Coordinate(e.clientX, e.clientY);
if (Coordinate.distance(oldCoords, newCoords) < 1) {
return;
}
}
const menuItem = this.getMenuItem(e.target as Element);
if (menuItem) {
menuItem.performAction();
}
}
* Handles mouse enter events. Focus the element.
*
* @param _e Mouse event to handle.
*/
private handleMouseEnter(_e: PointerEvent) {
this.focus();
}
* Handles mouse leave events. Blur and clear highlight.
*
* @param _e Mouse event to handle.
*/
private handleMouseLeave(_e: PointerEvent) {
if (this.getElement()) {
this.blur();
this.setHighlighted(null);
}
}
* Attempts to handle a keyboard event, if the menu item is enabled, by
* calling
* {@link Menu#handleKeyEventInternal_}.
*
* @param e Key event to handle.
*/
private handleKeyEvent(e: Event) {
if (!this.menuItems.length) {
return;
}
const keyboardEvent = e as KeyboardEvent;
if (
keyboardEvent.shiftKey ||
keyboardEvent.ctrlKey ||
keyboardEvent.metaKey ||
keyboardEvent.altKey
) {
return;
}
const highlighted = this.highlightedItem;
switch (keyboardEvent.key) {
case 'Enter':
case ' ':
if (highlighted) {
highlighted.performAction();
}
break;
case 'ArrowUp':
this.highlightPrevious();
break;
case 'ArrowDown':
this.highlightNext();
break;
case 'PageUp':
case 'Home':
this.highlightFirst();
break;
case 'PageDown':
case 'End':
this.highlightLast();
break;
default:
return;
}
e.preventDefault();
e.stopPropagation();
}
* Get the size of a rendered menu.
*
* @returns Object with width and height properties.
* @internal
*/
getSize(): Size {
const menuDom = this.getElement() as HTMLDivElement;
const menuSize = style.getSize(menuDom);
menuSize.height = menuDom.scrollHeight;
return menuSize;
}
}