blob: aa887eceb2c23970a09f22b651d5da72531c7d60 [file] [log] [blame]
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview A drop-down menu in the ChromeVox panel.
*/
goog.provide('PanelMenu');
goog.provide('PanelNodeMenu');
goog.provide('PanelSearchMenu');
goog.require('AutomationTreeWalker');
goog.require('Msgs');
goog.require('Output');
goog.require('PanelMenuItem');
goog.require('constants');
goog.require('cursors.Range');
PanelMenu = class {
/**
* @param {string} menuMsg The msg id of the menu.
*/
constructor(menuMsg) {
/** @type {string} */
this.menuMsg = menuMsg;
// The item in the menu bar containing the menu's title.
this.menuBarItemElement = document.createElement('div');
this.menuBarItemElement.className = 'menu-bar-item';
this.menuBarItemElement.setAttribute('role', 'menu');
const menuTitle = Msgs.getMsg(menuMsg);
this.menuBarItemElement.textContent = menuTitle;
// The container for the menu. This part is fixed and scrolls its
// contents if necessary.
this.menuContainerElement = document.createElement('div');
this.menuContainerElement.className = 'menu-container';
this.menuContainerElement.style.visibility = 'hidden';
// The menu itself. It contains all of the items, and it scrolls within
// its container.
this.menuElement = document.createElement('table');
this.menuElement.className = 'menu';
this.menuElement.setAttribute('role', 'menu');
this.menuElement.setAttribute('aria-label', menuTitle);
this.menuContainerElement.appendChild(this.menuElement);
/**
* The items in the menu.
* @type {Array<PanelMenuItem>}
* @private
*/
this.items_ = [];
/**
* The return value from window.setTimeout for a function to update the
* scroll bars after an item has been added to a menu. Used so that we
* don't re-layout too many times.
* @type {?number}
* @private
*/
this.updateScrollbarsTimeout_ = null;
/**
* The current active menu item index, or -1 if none.
* @type {number}
* @private
*/
this.activeIndex_ = -1;
this.menuElement.addEventListener(
'keypress', this.onKeyPress_.bind(this), true);
/** @private {boolean} */
this.enabled_ = true;
}
/**
* @param {string} menuItemTitle The title of the menu item.
* @param {string} menuItemShortcut The keystrokes to select this item.
* @param {string} menuItemBraille
* @param {string} gesture
* @param {Function} callback The function to call if this item is selected.
* @param {string=} opt_id An optional id for the menu item element.
* @return {!PanelMenuItem} The menu item just created.
*/
addMenuItem(
menuItemTitle, menuItemShortcut, menuItemBraille, gesture, callback,
opt_id) {
const menuItem = new PanelMenuItem(
menuItemTitle, menuItemShortcut, menuItemBraille, gesture, callback,
opt_id);
this.items_.push(menuItem);
this.menuElement.appendChild(menuItem.element);
// Sync the active index with focus.
menuItem.element.addEventListener(
'focus', (function(index, event) {
this.activeIndex_ = index;
}).bind(this, this.items_.length - 1),
false);
// Update the container height, adding a scroll bar if necessary - but
// to avoid excessive layout, schedule this once per batch of adding
// menu items rather than after each add.
if (!this.updateScrollbarsTimeout_) {
this.updateScrollbarsTimeout_ = window.setTimeout(
(function() {
const menuBounds = this.menuElement.getBoundingClientRect();
const maxHeight = window.innerHeight - menuBounds.top;
this.menuContainerElement.style.maxHeight = maxHeight + 'px';
this.updateScrollbarsTimeout_ = null;
}).bind(this),
0);
}
return menuItem;
}
/**
* Activate this menu, which means showing it and positioning it on the
* screen underneath its title in the menu bar.
* @param {boolean} activateFirstItem Whether or not we should activate the
* menu's
* first item.
*/
activate(activateFirstItem) {
if (!this.enabled_) {
this.menuBarItemElement.focus();
return;
}
this.menuContainerElement.style.visibility = 'visible';
this.menuContainerElement.style.opacity = 1;
this.menuBarItemElement.classList.add('active');
const barBounds =
this.menuBarItemElement.parentElement.getBoundingClientRect();
const titleBounds = this.menuBarItemElement.getBoundingClientRect();
const menuBounds = this.menuElement.getBoundingClientRect();
this.menuElement.style.minWidth = titleBounds.width + 'px';
this.menuContainerElement.style.minWidth = titleBounds.width + 'px';
if (titleBounds.left + menuBounds.width < barBounds.width) {
this.menuContainerElement.style.left = titleBounds.left + 'px';
} else {
this.menuContainerElement.style.left =
(titleBounds.right - menuBounds.width) + 'px';
}
// Make the first item active.
if (activateFirstItem) {
this.activateItem(0);
}
}
/**
* Disables this menu. When disabled, menu contents cannot be analyzed.
* When activated, focus gets placed on the menuBarItem (title element)
* instead of the first menu item.
*/
disable() {
this.enabled_ = false;
this.menuBarItemElement.classList.add('disabled');
this.menuBarItemElement.setAttribute('aria-disabled', true);
this.menuBarItemElement.setAttribute('tabindex', 0);
this.menuBarItemElement.setAttribute(
'aria-label', this.menuBarItemElement.textContent);
this.activeIndex_ = -1;
}
/**
* Hide this menu. Make it invisible first to minimize spurious
* accessibility events before the next menu activates.
*/
deactivate() {
this.menuContainerElement.style.opacity = 0.001;
this.menuBarItemElement.classList.remove('active');
this.activeIndex_ = -1;
window.setTimeout(
(function() {
this.menuContainerElement.style.visibility = 'hidden';
}).bind(this),
0);
}
/**
* Make a specific menu item index active.
* @param {number} itemIndex The index of the menu item.
*/
activateItem(itemIndex) {
this.activeIndex_ = itemIndex;
if (this.activeIndex_ >= 0 && this.activeIndex_ < this.items_.length) {
this.items_[this.activeIndex_].element.focus();
}
}
/**
* Advanced the active menu item index by a given number.
* @param {number} delta The number to add to the active menu item index.
*/
advanceItemBy(delta) {
if (!this.enabled_) {
return;
}
if (this.activeIndex_ >= 0) {
this.activeIndex_ += delta;
this.activeIndex_ =
(this.activeIndex_ + this.items_.length) % this.items_.length;
} else {
if (delta >= 0) {
this.activeIndex_ = 0;
} else {
this.activeIndex_ = this.items_.length - 1;
}
}
this.activeIndex_ = this.findEnabledItemIndex_(
this.activeIndex_, delta > 0 ? 1 : -1 /* delta */);
if (this.activeIndex_ === -1) {
return;
}
this.items_[this.activeIndex_].element.focus();
}
/**
* Sets the active menu item index to be 0.
*/
scrollToTop() {
this.activeIndex_ = 0;
this.items_[this.activeIndex_].element.focus();
}
/**
* Sets the active menu item index to be the last index.
*/
scrollToBottom() {
this.activeIndex_ = this.items_.length - 1;
this.items_[this.activeIndex_].element.focus();
}
/**
* Get the callback for the active menu item.
* @return {Function} The callback.
*/
getCallbackForCurrentItem() {
if (this.activeIndex_ >= 0 && this.activeIndex_ < this.items_.length) {
return this.items_[this.activeIndex_].callback;
}
return null;
}
/**
* Get the callback for a menu item given its DOM element.
* @param {Element} element The DOM element.
* @return {Function} The callback.
*/
getCallbackForElement(element) {
for (let i = 0; i < this.items_.length; i++) {
if (element === this.items_[i].element) {
return this.items_[i].callback;
}
}
return null;
}
/**
* Handles key presses for first letter accelerators.
*/
onKeyPress_(evt) {
if (!this.items_.length) {
return;
}
const query = String.fromCharCode(evt.charCode).toLowerCase();
for (let i = this.activeIndex_ + 1; i !== this.activeIndex_;
i = (i + 1) % this.items_.length) {
if (this.items_[i].text.toLowerCase().indexOf(query) === 0) {
this.activateItem(i);
break;
}
}
}
/**
* @return {boolean} The enabled state of this menu.
*/
get enabled() {
return this.enabled_;
}
/**
* @return {Array<PanelMenuItem>}
*/
get items() {
return this.items_;
}
/**
* Starting at |startIndex|, looks for an enabled menu item.
* @param {number} startIndex
* @param {number} delta
* @return {number} The index of the enabled item. -1 if not found.
* @private
*/
findEnabledItemIndex_(startIndex, delta) {
const endIndex = (delta > 0) ? this.items_.length : -1;
while (startIndex !== endIndex) {
if (this.items_[startIndex].enabled) {
return startIndex;
}
startIndex += delta;
}
return -1;
}
};
PanelNodeMenu = class extends PanelMenu {
/**
* @param {string} menuMsg The msg id of the menu.
* @param {chrome.automation.AutomationNode} node ChromeVox's current
* position.
* @param {AutomationPredicate.Unary} pred Filter to use on the document.
* @param {boolean} async If true, populates the menu asynchronously by
* posting a task after searching each chunk of nodes.
*/
constructor(menuMsg, node, pred, async) {
super(menuMsg);
/** @private {AutomationNode} */
this.node_ = node;
/** @private {AutomationPredicate.Unary} */
this.pred_ = pred;
/** @private {boolean} */
this.async_ = async;
/** @private {AutomationTreeWalker|undefined} */
this.walker_;
/** @private {number} */
this.nodeCount_ = 0;
this.populate_();
}
/** @override */
activate(activateFirstItem) {
super.activate(false);
if (activateFirstItem) {
// The active index might have been set prior to this call in
// |findMoreNodes|. We want to start the menu there.
const index = this.activeIndex_ === -1 ? 0 : this.activeIndex_;
this.activateItem(index);
}
}
/**
* Create the AutomationTreeWalker and kick off the search to find
* nodes that match the predicate for this menu.
* @private
*/
populate_() {
if (!this.node_) {
this.finish_();
return;
}
const root = AutomationUtil.getTopLevelRoot(this.node_);
if (!root) {
this.finish_();
return;
}
this.walker_ = new AutomationTreeWalker(root, constants.Dir.FORWARD, {
visit(node) {
return !AutomationPredicate.shouldIgnoreNode(node);
}
});
this.nodeCount_ = 0;
this.findMoreNodes_();
}
/**
* Iterate over nodes from the tree walker. If a node matches the
* predicate, add an item to the menu.
*
* If |this.async_| is true, then after MAX_NODES_BEFORE_ASYNC nodes
* have been scanned, call setTimeout to defer searching. This frees
* up the main event loop to keep the panel menu responsive, otherwise
* it basically freezes up until all of the nodes have been found.
* @private
*/
findMoreNodes_() {
while (this.walker_.next().node) {
const node = this.walker_.node;
if (this.pred_(node)) {
const output = new Output();
const range = cursors.Range.fromNode(node);
output.withoutHints();
output.withSpeech(range, range, OutputEventType.NAVIGATE);
const label = output.toString();
this.addMenuItem(label, '', '', '', (function() {
const savedNode = node;
return function() {
chrome.extension.getBackgroundPage()
.ChromeVoxState.instance['navigateToRange'](
cursors.Range.fromNode(savedNode));
};
}()));
if (node === this.node_ && !this.async_) {
this.activeIndex_ = this.items_.length - 1;
}
}
if (this.async_) {
this.nodeCount_++;
if (this.nodeCount_ >= PanelNodeMenu.MAX_NODES_BEFORE_ASYNC) {
this.nodeCount_ = 0;
window.setTimeout(this.findMoreNodes_.bind(this), 0);
return;
}
}
}
this.finish_();
}
/**
* Called when we've finished searching for nodes. If no matches were
* found, adds an item to the menu indicating none were found.
* @private
*/
finish_() {
if (!this.items_.length) {
this.addMenuItem(
Msgs.getMsg('panel_menu_item_none'), '', '', '', function() {});
}
}
};
/**
* The number of nodes to search before posting a task to finish
* searching.
* @const {number}
*/
PanelNodeMenu.MAX_NODES_BEFORE_ASYNC = 100;
/**
* Implements a menu that allows users to dynamically search the contents of the
* ChromeVox menus.
*/
PanelSearchMenu = class extends PanelMenu {
/**
* @param {!string} menuMsg The msg id of the menu.
*/
constructor(menuMsg) {
super(menuMsg);
this.searchResultCounter_ = 0;
// Add id attribute to the menu so we can associate it with search bar.
this.menuElement.setAttribute('id', 'search-results');
// Create the search bar.
this.searchBar = document.createElement('input');
this.searchBar.setAttribute('id', 'menus-search-bar');
this.searchBar.setAttribute('type', 'search');
this.searchBar.setAttribute('aria-controls', 'search-results');
this.searchBar.setAttribute('aria-activedescendant', '');
this.searchBar.setAttribute(
'placeholder', Msgs.getMsg('search_chromevox_menus_placeholder'));
this.searchBar.setAttribute(
'aria-description', Msgs.getMsg('search_chromevox_menus_description'));
this.searchBar.setAttribute('role', 'searchbox');
// Create menu item to own search bar.
const menuItem = document.createElement('tr');
menuItem.tabIndex = -1;
menuItem.setAttribute('role', 'menuitem');
menuItem.appendChild(this.searchBar);
// Add the search bar above the menu.
this.menuContainerElement.insertBefore(menuItem, this.menuElement);
}
/** @override */
activate(activateFirstItem) {
PanelMenu.prototype.activate.call(this, false);
if (this.searchBar.value === '') {
this.clear();
}
if (this.items_.length > 0) {
this.activateItem(this.activeIndex_);
}
this.searchBar.focus();
}
/** @override */
activateItem(index) {
this.resetItemAtActiveIndex();
if (this.items_.length === 0) {
return;
}
if (index >= 0) {
index = (index + this.items_.length) % this.items_.length;
} else {
if (index >= this.activeIndex_) {
index = 0;
} else {
index = this.items_.length - 1;
}
}
this.activeIndex_ = index;
const item = this.items_[this.activeIndex_];
this.searchBar.setAttribute('aria-activedescendant', item.element.id);
item.element.classList.add('active');
// Scroll item into view, if necessary. Only check y-axis.
const itemBounds = item.element.getBoundingClientRect();
const menuBarBounds = this.menuBarItemElement.getBoundingClientRect();
const topThreshold = menuBarBounds.bottom;
const bottomThreshold = window.innerHeight;
if (itemBounds.bottom > bottomThreshold) {
// Item is too far down, so align to top.
item.element.scrollIntoView(true /* alignToTop */);
} else if (itemBounds.top < topThreshold) {
// Item is too far up, so align to bottom.
item.element.scrollIntoView(false /* alignToTop */);
}
}
/** @override */
addMenuItem(
menuItemTitle, menuItemShortcut, menuItemBraille, gesture, callback,
opt_id) {
this.searchResultCounter_ += 1;
const item = PanelMenu.prototype.addMenuItem.call(
this, menuItemTitle, menuItemShortcut, menuItemBraille, gesture,
callback, 'result-number-' + this.searchResultCounter_.toString());
// Ensure that item styling is updated on mouse hovers.
item.element.addEventListener('mouseover', (event) => {
this.resetItemAtActiveIndex();
}, true);
return item;
}
/** @override */
advanceItemBy(delta) {
this.activateItem(this.activeIndex_ + delta);
}
/**
* Clears this menu's contents.
*/
clear() {
this.items_ = [];
this.activeIndex_ = -1;
while (this.menuElement.children.length !== 0) {
this.menuElement.removeChild(this.menuElement.firstChild);
}
this.searchBar.setAttribute('aria-activedescendant', '');
}
/**
* A convenience method to add a copy of an existing PanelMenuItem.
* @param {!PanelMenuItem} item The item we want to copy.
* @return {!PanelMenuItem} The menu item that was just created.
*/
copyAndAddMenuItem(item) {
return this.addMenuItem(
item.menuItemTitle, item.menuItemShortcut, item.menuItemBraille,
item.gesture, item.callback);
}
/** @override */
deactivate() {
this.resetItemAtActiveIndex();
PanelMenu.prototype.deactivate.call(this);
}
/**
* Resets the item at this.activeIndex_.
*/
resetItemAtActiveIndex() {
// Sanity check.
if (this.activeIndex_ < 0 || this.activeIndex_ >= this.items.length) {
return;
}
this.items_[this.activeIndex_].element.classList.remove('active');
}
/** @override */
scrollToTop() {
this.activateItem(0);
}
/** @override */
scrollToBottom() {
this.activateItem(this.items_.length - 1);
}
};