/** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * Blockly menu similar to Closure's goog.ui.Menu * * @class */ // Former goog.module ID: Blockly.Menu import * as browserEvents from './browser_events.js'; import type {MenuSeparator} from './menu_separator.js'; import {MenuItem} from './menuitem.js'; import * as aria from './utils/aria.js'; import {Coordinate} from './utils/coordinate.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 and separators. */ private readonly menuItems: Array = []; /** * Coordinates of the pointerdown event that caused this menu to open. Used to * prevent the consequent pointerup 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; /** Pointer over event data. */ private pointerMoveHandler: browserEvents.Data | null = null; /** Click event data. */ private clickHandler: browserEvents.Data | null = null; /** Pointer enter event data. */ private pointerEnterHandler: browserEvents.Data | null = null; /** Pointer leave event data. */ private pointerLeaveHandler: browserEvents.Data | null = null; /** Key down event data. */ private onKeyDownHandler: browserEvents.Data | null = null; /** The menu's root DOM element. */ private element: HTMLDivElement | null = null; /** ARIA name for this menu. */ private roleName: aria.Role | null = null; /** Constructs a new Menu instance. */ constructor() {} /** * Add a new menu item to the bottom of this menu. * * @param menuItem Menu item or separator to append. * @internal */ addChild(menuItem: MenuItem | MenuSeparator) { 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'; element.tabIndex = 0; if (this.roleName) { aria.setRole(element, this.roleName); } this.element = element; // Add menu items. for (let i = 0, menuItem; (menuItem = this.menuItems[i]); i++) { element.appendChild(menuItem.createDom()); } // Add event handlers. this.pointerMoveHandler = browserEvents.conditionalBind( element, 'pointermove', this, this.handlePointerMove, true, ); this.clickHandler = browserEvents.conditionalBind( element, 'pointerup', this, this.handleClick, true, ); this.pointerEnterHandler = browserEvents.conditionalBind( element, 'pointerenter', this, this.handlePointerEnter, true, ); this.pointerLeaveHandler = browserEvents.conditionalBind( element, 'pointerleave', this, this.handlePointerLeave, 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}); } } /** Blur the menu element. */ private blur() { const el = this.getElement(); if (el) { el.blur(); } } /** * Set the menu accessibility role. * * @param roleName role name. * @internal */ setRole(roleName: aria.Role) { this.roleName = roleName; } /** Dispose of this menu. */ dispose() { // Remove event handlers. if (this.pointerMoveHandler) { browserEvents.unbind(this.pointerMoveHandler); this.pointerMoveHandler = null; } if (this.clickHandler) { browserEvents.unbind(this.clickHandler); this.clickHandler = null; } if (this.pointerEnterHandler) { browserEvents.unbind(this.pointerEnterHandler); this.pointerEnterHandler = null; } if (this.pointerLeaveHandler) { browserEvents.unbind(this.pointerLeaveHandler); this.pointerLeaveHandler = null; } if (this.onKeyDownHandler) { browserEvents.unbind(this.onKeyDownHandler); this.onKeyDownHandler = null; } // Remove menu items. for (let i = 0, menuItem; (menuItem = this.menuItems[i]); i++) { menuItem.dispose(); } this.element = null; } // Child component management. /** * 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(); // Node might be the menu border (resulting in no associated menu item), or // a menu item's div, or some element within the menu item. // Walk up parents until one meets either the menu's root element, or // a menu item's div. let currentElement: Element | null = elem; while (currentElement && currentElement !== menuElem) { if (currentElement.classList.contains('blocklyMenuItem')) { // Having found a menu item's div, locate that menu item in this menu. const items = this.getMenuItems(); for (let i = 0, menuItem; (menuItem = items[i]); i++) { if (menuItem.getElement() === currentElement) { return menuItem; } } } currentElement = currentElement.parentElement; } return null; } // Highlight management. /** * 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; // Bring the highlighted item into view. This has no effect if the menu is // not scrollable. const menuElement = this.getElement(); const menuItemElement = item.getElement(); if (!menuElement || !menuItemElement) return; style.scrollIntoContainerView(menuItemElement, menuElement); aria.setState(menuElement, 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); } /** Highlights the first highlightable item. */ private highlightFirst() { this.highlightHelper(-1, 1); } /** Highlights the last highlightable item. */ 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; const items = this.getMenuItems(); while ((menuItem = items[index])) { if (menuItem.isEnabled()) { this.setHighlighted(menuItem); break; } index += delta; } } // Pointer events. /** * Handles pointermove events. Highlight menu items as the user hovers over * them. * * @param e Pointer event to handle. */ private handlePointerMove(e: PointerEvent) { // Check whether the pointer actually did move. Move events are triggered if // the element underneath the pointer moves, even if the pointer itself has // remained stationary. In the case where the pointer is hovering over // the menu but the user is navigating through the list of items via the // keyboard and causing items off the end of the menu to scroll into view, // a pointermove event would be triggered due to the pointer now being over // a new child, but we don't want to highlight the item that's now under the // pointer. const delta = Math.max(Math.abs(e.movementX), Math.abs(e.movementY)); if (delta === 0) return; 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; // Clear out the saved opening coords immediately so they're not used twice. this.openingCoords = null; if (oldCoords && typeof e.clientX === 'number') { const newCoords = new Coordinate(e.clientX, e.clientY); if (Coordinate.distance(oldCoords, newCoords) < 1) { // This menu was opened by a pointerdown and we're handling the // consequent click event. The coords haven't changed, meaning this was // the same opening event. Don't do the usual behavior because the menu // just popped up under the pointer and the user didn't mean to activate // this item. return; } } const menuItem = this.getMenuItem(e.target as Element); if (menuItem) { menuItem.performAction(e); } } /** * Handles pointer enter events. Focus the element. * * @param _e Pointer event to handle. */ private handlePointerEnter(_e: PointerEvent) { this.focus(); } /** * Handles pointer leave events by clearing the active highlight. * * @param _e Pointer event to handle. */ private handlePointerLeave(_e: PointerEvent) { if (this.getElement()) { this.setHighlighted(null); } } // Keyboard events. /** * Attempts to handle a keyboard event. * * @param e Key event to handle. */ private handleKeyEvent(e: Event) { if (!this.menuItems.length) { // Empty menu. return; } const keyboardEvent = e as KeyboardEvent; if ( keyboardEvent.shiftKey || keyboardEvent.ctrlKey || keyboardEvent.metaKey || keyboardEvent.altKey ) { // Do not handle the key event if any modifier key is pressed. return; } const highlighted = this.highlightedItem; switch (keyboardEvent.key) { case 'Enter': case ' ': if (highlighted) { highlighted.performAction(e); } 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: // Not a key the menu is interested in. return; } // The menu used this key, don't let it have secondary effects. 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); // Recalculate height for the total content, not only box height. menuSize.height = menuDom.scrollHeight; return menuSize; } /** * Returns the action menu items (omitting separators) in this menu. * * @returns The MenuItem objects displayed in this menu. */ private getMenuItems(): MenuItem[] { return this.menuItems.filter((item) => item instanceof MenuItem); } }