Files
blockly/core/components/menu/menu.js
2019-09-20 10:36:06 -07:00

483 lines
13 KiB
JavaScript

/**
* @license
* Visual Blocks Editor
*
* Copyright 2019 Google Inc.
* https://developers.google.com/blockly/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @fileoverview Blockly menu similar to Closure's goog.ui.Menu
* @author samelh@google.com (Sam El-Husseini)
*/
'use strict';
goog.provide('Blockly.Menu');
goog.require('Blockly.Component');
goog.require('Blockly.utils.aria');
goog.require('Blockly.utils.dom');
goog.require('Blockly.utils.object');
/**
* A basic menu class.
* @constructor
* @extends {Blockly.Component}
*/
Blockly.Menu = function() {
Blockly.Component.call(this);
/**
* This is the element that we will listen to the real focus events on.
* A value of -1 means no menuitem is highlighted.
* @type {number}
* @private
*/
this.highlightedIndex_ = -1;
};
Blockly.utils.object.inherits(Blockly.Menu, Blockly.Component);
/**
* Creates the menu DOM.
* @override
*/
Blockly.Menu.prototype.createDom = function() {
var element = document.createElement('div');
element.setAttribute('id', this.getId());
this.setElementInternal(element);
// Set class
element.setAttribute('class', 'goog-menu goog-menu-vertical');
element.setAttribute('style', 'user-select: none;');
element.tabIndex = 0;
// Initialize ARIA role.
Blockly.utils.aria.setRole(element,
this.roleName_ || Blockly.utils.aria.Role.MENU);
};
/**
* Focus the menu element.
* @package
*/
Blockly.Menu.prototype.focus = function() {
var el = this.getElement();
if (el) {
el.focus();
Blockly.utils.dom.addClass(el, 'focused');
}
};
/**
* Blur the menu element.
* @package
*/
Blockly.Menu.prototype.blur = function() {
var el = this.getElement();
if (el) {
el.blur();
Blockly.utils.dom.removeClass(el, 'focused');
}
};
/**
* Set the menu accessibility role.
* @param {!Blockly.utils.aria.Role|string} roleName role name.
* @package
*/
Blockly.Menu.prototype.setRole = function(roleName) {
this.roleName_ = roleName;
};
/** @override */
Blockly.Menu.prototype.enterDocument = function() {
Blockly.Menu.superClass_.enterDocument.call(this);
this.forEachChild(function(child) {
if (child.isInDocument()) {
this.registerChildId_(child);
}
}, this);
this.attachEvents_();
};
/**
* Cleans up the container before its DOM is removed from the document, and
* removes event handlers. Overrides {@link Blockly.Component#exitDocument}.
* @override
*/
Blockly.Menu.prototype.exitDocument = function() {
// {@link #setHighlightedIndex} has to be called before
// {@link Blockly.Component#exitDocument}, otherwise it has no effect.
this.setHighlightedIndex(-1);
Blockly.Menu.superClass_.exitDocument.call(this);
};
/** @override */
Blockly.Menu.prototype.disposeInternal = function() {
Blockly.Menu.superClass_.disposeInternal.call(this);
this.detachEvents_();
};
/**
* Adds the event listeners to the menu.
* @private
*/
Blockly.Menu.prototype.attachEvents_ = function() {
var el = /** @type {!EventTarget} */ (this.getElement());
this.mouseOverHandler_ = Blockly.bindEventWithChecks_(el,
'mouseover', this, this.handleMouseOver_, true);
this.clickHandler_ = Blockly.bindEventWithChecks_(el,
'click', this, this.handleClick_, true);
this.mouseEnterHandler_ = Blockly.bindEventWithChecks_(el,
'mouseenter', this, this.handleMouseEnter_, true);
this.mouseLeaveHandler_ = Blockly.bindEventWithChecks_(el,
'mouseleave', this, this.handleMouseLeave_, true);
this.onKeyDownWrapper_ = Blockly.bindEventWithChecks_(el,
'keydown', this, this.handleKeyEvent);
};
/**
* Removes the event listeners from the menu.
* @private
*/
Blockly.Menu.prototype.detachEvents_ = function() {
Blockly.unbindEvent_(this.mouseOverHandler_);
Blockly.unbindEvent_(this.clickHandler_);
Blockly.unbindEvent_(this.mouseEnterHandler_);
Blockly.unbindEvent_(this.mouseLeaveHandler_);
Blockly.unbindEvent_(this.onKeyDownWrapper_);
};
// Child component management.
/**
* Map of DOM IDs to child menuitems. Each key is the DOM ID of a child
* menuitems's root element; each value is a reference to the child menu
* item itself.
* @type {?Object}
* @private
*/
Blockly.Menu.prototype.childElementIdMap_ = null;
/**
* Creates a DOM ID for the child menuitem and registers it to an internal
* hash table to be able to find it fast by id.
* @param {Blockly.Component} child The child menuitem. Its root element has
* to be created yet.
* @private
*/
Blockly.Menu.prototype.registerChildId_ = function(child) {
// Map the DOM ID of the menuitem's root element to the menuitem itself.
var childElem = child.getElement();
// If the menuitem's root element doesn't have a DOM ID assign one.
var id = childElem.id || (childElem.id = child.getId());
// Lazily create the child element ID map on first use.
if (!this.childElementIdMap_) {
this.childElementIdMap_ = {};
}
this.childElementIdMap_[id] = child;
};
/**
* Returns the child menuitem that owns the given DOM node, or null if no such
* menuitem is found.
* @param {Node} node DOM node whose owner is to be returned.
* @return {?Blockly.MenuItem} menuitem for which the DOM node belongs to.
* @protected
*/
Blockly.Menu.prototype.getMenuItem = function(node) {
// Ensure that this menu actually has child menuitems before
// looking up the menuitem.
if (this.childElementIdMap_) {
var elem = this.getElement();
while (node && node !== elem) {
var id = node.id;
if (id in this.childElementIdMap_) {
return this.childElementIdMap_[id];
}
node = node.parentNode;
}
}
return null;
};
// Highlight management.
/**
* Unhighlight the current highlighted item.
* @protected
*/
Blockly.Menu.prototype.unhighlightCurrent = function() {
var highlighted = this.getHighlighted();
if (highlighted) {
highlighted.setHighlighted(false);
}
};
/**
* Clears the currently highlighted item.
* @protected
*/
Blockly.Menu.prototype.clearHighlighted = function() {
this.unhighlightCurrent();
this.setHighlightedIndex(-1);
};
/**
* Returns the currently highlighted item (if any).
* @return {?Blockly.Component} Highlighted item (null if none).
* @protected
*/
Blockly.Menu.prototype.getHighlighted = function() {
return this.getChildAt(this.highlightedIndex_);
};
/**
* Highlights the item at the given 0-based index (if any). If another item
* was previously highlighted, it is un-highlighted.
* @param {number} index Index of item to highlight (-1 removes the current
* highlight).
* @protected
*/
Blockly.Menu.prototype.setHighlightedIndex = function(index) {
var child = this.getChildAt(index);
if (child) {
child.setHighlighted(true);
this.highlightedIndex_ = index;
} else if (this.highlightedIndex_ > -1) {
this.getHighlighted().setHighlighted(false);
this.highlightedIndex_ = -1;
}
// Bring the highlighted item into view. This has no effect if the menu is not
// scrollable.
if (child) {
Blockly.utils.style.scrollIntoContainerView(child.getElement(), this.getElement());
}
};
/**
* Highlights the given item if it exists and is a child of the container;
* otherwise un-highlights the currently highlighted item.
* @param {Blockly.MenuItem} item Item to highlight.
* @protected
*/
Blockly.Menu.prototype.setHighlighted = function(item) {
this.setHighlightedIndex(this.indexOfChild(item));
};
/**
* Highlights the next highlightable item (or the first if nothing is currently
* highlighted).
* @protected
*/
Blockly.Menu.prototype.highlightNext = function() {
this.highlightHelper(function(index, max) {
return (index + 1) % max;
}, this.highlightedIndex_);
};
/**
* Highlights the previous highlightable item (or the last if nothing is
* currently highlighted).
* @protected
*/
Blockly.Menu.prototype.highlightPrevious = function() {
this.highlightHelper(function(index, max) {
index--;
return index < 0 ? max - 1 : index;
}, this.highlightedIndex_);
};
/**
* Helper function that manages the details of moving the highlight among
* child menuitems in response to keyboard events.
* @param {function(this: Blockly.Component, number, number) : number} fn
* Function that accepts the current and maximum indices, and returns the
* next index to check.
* @param {number} startIndex Start index.
* @return {boolean} Whether the highlight has changed.
* @protected
*/
Blockly.Menu.prototype.highlightHelper = function(fn, startIndex) {
// If the start index is -1 (meaning there's nothing currently highlighted),
// try starting from the currently open item, if any.
var curIndex =
startIndex < 0 ? -1 : startIndex;
var numItems = this.getChildCount();
curIndex = fn.call(this, curIndex, numItems);
var visited = 0;
while (visited <= numItems) {
var menuItem = /** @type {Blockly.MenuItem} */ (this.getChildAt(curIndex));
if (menuItem && this.canHighlightItem(menuItem)) {
this.setHighlightedIndex(curIndex);
return true;
}
visited++;
curIndex = fn.call(this, curIndex, numItems);
}
return false;
};
/**
* Returns whether the given item can be highlighted.
* @param {Blockly.MenuItem} item The item to check.
* @return {boolean} Whether the item can be highlighted.
* @protected
*/
Blockly.Menu.prototype.canHighlightItem = function(item) {
return item.isEnabled();
};
// Mouse events.
/**
* Handles mouseover events. Highlight menuitems as the user
* hovers over them.
* @param {Event} e Mouse event to handle.
* @private
*/
Blockly.Menu.prototype.handleMouseOver_ = function(e) {
var menuItem = this.getMenuItem(/** @type {Node} */ (e.target));
if (menuItem) {
if (menuItem.isEnabled()) {
var currentHighlighted = this.getHighlighted();
if (currentHighlighted === menuItem) {
return;
}
this.unhighlightCurrent();
this.setHighlighted(menuItem);
}
}
};
/**
* Handles click events. Pass the event onto the child
* menuitem to handle.
* @param {Event} e Click to handle.
* @private
*/
Blockly.Menu.prototype.handleClick_ = function(e) {
var menuItem = this.getMenuItem(/** @type {Node} */ (e.target));
if (menuItem && menuItem.handleClick(e)) {
e.preventDefault();
}
};
/**
* Handles mouse enter events. Focus the element.
* @param {Event} _e Mouse event to handle.
* @private
*/
Blockly.Menu.prototype.handleMouseEnter_ = function(_e) {
this.focus();
};
/**
* Handles mouse leave events. Blur and clear highlight.
* @param {Event} _e Mouse event to handle.
* @private
*/
Blockly.Menu.prototype.handleMouseLeave_ = function(_e) {
if (this.getElement()) {
this.blur();
this.clearHighlighted();
}
};
// Keyboard events.
/**
* Attempts to handle a keyboard event, if the menuitem is enabled, by calling
* {@link handleKeyEventInternal}. Considered protected; should only be used
* within this package and by subclasses.
* @param {Event} e Key event to handle.
* @return {boolean} Whether the key event was handled.
* @protected
*/
Blockly.Menu.prototype.handleKeyEvent = function(e) {
if (this.getChildCount() != 0 &&
this.handleKeyEventInternal(e)) {
e.preventDefault();
e.stopPropagation();
return true;
}
return false;
};
/**
* Attempts to handle a keyboard event; returns true if the event was handled,
* false otherwise. If the container is enabled, and a child is highlighted,
* calls the child menuitem's `handleKeyEvent` method to give the menuitem
* a chance to handle the event first.
* @param {Event} e Key event to handle.
* @return {boolean} Whether the event was handled by the container (or one of
* its children).
* @protected
*/
Blockly.Menu.prototype.handleKeyEventInternal = function(e) {
// Give the highlighted menuitem the chance to handle the key event.
var highlighted = this.getHighlighted();
if (highlighted && typeof highlighted.handleKeyEvent == 'function' &&
highlighted.handleKeyEvent(e)) {
return true;
}
// Do not handle the key event if any modifier key is pressed.
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) {
return false;
}
// Either nothing is highlighted, or the highlighted menuitem didn't handle
// the key event, so attempt to handle it here.
switch (e.keyCode) {
case Blockly.utils.KeyCodes.ENTER:
if (highlighted) {
highlighted.performActionInternal(e);
}
break;
case Blockly.utils.KeyCodes.UP:
this.unhighlightCurrent();
this.highlightPrevious();
break;
case Blockly.utils.KeyCodes.DOWN:
this.unhighlightCurrent();
this.highlightNext();
break;
default:
return false;
}
return true;
};