blob: eaf1a27ee851221625a2d8efeb9d8a8b49ab8d3e [file] [log] [blame]
// Copyright 2018 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.
/**
* Class to handle interactions with the Switch Access menu, including moving
* through and selecting actions.
*/
class MenuManager {
/**
* @param {!NavigationManager} navigationManager
* @param {!chrome.automation.AutomationNode} desktop
*/
constructor(navigationManager, desktop) {
/**
* A list of the Menu actions that are currently enabled.
* @private {!Array<!SAConstants.MenuAction>}
*/
this.actions_ = [];
/**
* The parent automation manager.
* @private {!NavigationManager}
*/
this.navigationManager_ = navigationManager;
/**
* The root node of the screen.
* @private {!chrome.automation.AutomationNode}
*/
this.desktop_ = desktop;
/**
* The root node of the menu.
* @private {chrome.automation.AutomationNode}
*/
this.menuNode_;
/**
* The current node of the menu.
* @private {chrome.automation.AutomationNode}
*/
this.node_;
/**
* Keeps track of when we're in the Switch Access menu.
* @private {boolean}
*/
this.inMenu_ = false;
/**
* A reference to the Switch Access Menu Panel class.
* @private {PanelInterface}
*/
this.menuPanel_;
}
/**
* Enter the menu and highlight the first available action.
*
* @param {!chrome.automation.AutomationNode} navNode the currently selected
* node, for which the menu is to be displayed.
*/
enter(navNode) {
if (!this.menuPanel_) {
console.log('Error: Menu panel has not loaded.');
return;
}
const actions = this.getActionsForNode_(navNode);
// getActionsForNode_ will return null when there is only one interesting
// action (selection) specific to this node. In this case, rather than
// forcing the user to repeatedly disambiguate, we will simply select by
// default.
if (actions === null) {
this.navigationManager_.selectCurrentNode();
return;
}
this.inMenu_ = true;
if (actions !== this.actions_) {
this.actions_ = actions;
this.menuPanel_.setActions(this.actions_);
}
if (navNode.location) {
chrome.accessibilityPrivate.setSwitchAccessMenuState(
true, navNode.location, actions.length);
} else {
console.log('Unable to show Switch Access menu.');
}
let firstNode =
this.menuNode().find({role: chrome.automation.RoleType.BUTTON});
while (firstNode && !this.isActionAvailable_(firstNode.htmlAttributes.id))
firstNode = firstNode.nextSibling;
if (firstNode) {
this.node_ = firstNode;
this.updateFocusRing_();
}
}
/**
* Exits the menu.
*/
exit() {
this.clearFocusRing_();
this.inMenu_ = false;
if (this.node_)
this.node_ = null;
chrome.accessibilityPrivate.setSwitchAccessMenuState(
false, SAConstants.EMPTY_LOCATION, 0);
}
/**
* Move to the next available action in the menu. If this is no next action,
* focus the whole menu to loop again.
* @return {boolean} Whether this function had any effect.
*/
moveForward() {
this.calculateCurrentNode();
if (!this.inMenu_ || !this.node_)
return false;
this.clearFocusRing_();
const treeWalker = new AutomationTreeWalker(
this.node_, constants.Dir.FORWARD,
SwitchAccessPredicate.restrictions(this.menuNode()));
const node = treeWalker.next().node;
if (!node)
this.node_ = null;
else
this.node_ = node;
this.updateFocusRing_();
return true;
}
/**
* Move to the previous available action in the menu. If we're at the
* beginning of the list, start again at the end.
* @return {boolean} Whether this function had any effect.
*/
moveBackward() {
this.calculateCurrentNode();
if (!this.inMenu_ || !this.node_)
return false;
this.clearFocusRing_();
const treeWalker = new AutomationTreeWalker(
this.node_, constants.Dir.BACKWARD,
SwitchAccessPredicate.restrictions(this.menuNode()));
let node = treeWalker.next().node;
// If node is null, find the last enabled button.
let lastChild = this.menuNode().lastChild;
while (!node && lastChild) {
if (SwitchAccessPredicate.isActionable(lastChild)) {
node = lastChild;
break;
} else {
lastChild = lastChild.previousSibling;
}
}
this.node_ = node;
this.updateFocusRing_();
return true;
}
/**
* Perform the action indicated by the current button (or no action if the
* entire menu is selected). Then exit the menu and return to traditional
* navigation.
* @return {boolean} Whether this function had any effect.
*/
selectCurrentNode() {
this.calculateCurrentNode();
if (!this.inMenu_ || !this.node_)
return false;
if (window.switchAccess.textEditingEnabled()) {
if (this.node_.role == RoleType.BUTTON) {
this.node_.doDefault();
} else {
this.exit();
}
} else {
this.clearFocusRing_();
this.node_.doDefault();
this.exit();
}
return true;
}
/**
* Sets up the connection between the menuPanel and the menuManager.
* @param {!PanelInterface} menuPanel
* @return {!MenuManager}
*/
connectMenuPanel(menuPanel) {
this.menuPanel_ = menuPanel;
return this;
}
/**
* Get the menu node. If it's not defined, search for it.
* @return {!chrome.automation.AutomationNode}
*/
menuNode() {
if (this.menuNode_)
return this.menuNode_;
const treeWalker = new AutomationTreeWalker(
this.desktop_, constants.Dir.FORWARD,
SwitchAccessPredicate.switchAccessMenuDiscoveryRestrictions());
const node = treeWalker.next().node;
if (node) {
this.menuNode_ = node;
return this.menuNode_;
}
console.log('Unable to find the Switch Access menu.');
return this.desktop_;
}
/**
* Clear the focus ring.
* @private
*/
clearFocusRing_() {
this.updateFocusRing_(true);
}
/**
* Determines which menu actions are relevant, given the current node. If
* there are no node-specific actions, return |null|, to indicate that we
* should select the current node automatically.
*
* @param {!chrome.automation.AutomationNode} node
* @return {Array<!SAConstants.MenuAction>}
* @private
*/
getActionsForNode_(node) {
let actions = [];
let scrollableAncestor = node;
while (!scrollableAncestor.scrollable && scrollableAncestor.parent)
scrollableAncestor = scrollableAncestor.parent;
if (scrollableAncestor.scrollable) {
if (scrollableAncestor.scrollX > scrollableAncestor.scrollXMin)
actions.push(SAConstants.MenuAction.SCROLL_LEFT);
if (scrollableAncestor.scrollX < scrollableAncestor.scrollXMax)
actions.push(SAConstants.MenuAction.SCROLL_RIGHT);
if (scrollableAncestor.scrollY > scrollableAncestor.scrollYMin)
actions.push(SAConstants.MenuAction.SCROLL_UP);
if (scrollableAncestor.scrollY < scrollableAncestor.scrollYMax)
actions.push(SAConstants.MenuAction.SCROLL_DOWN);
}
const standardActions = /** @type {!Array<!SAConstants.MenuAction>} */ (
node.standardActions.filter(
action => Object.values(SAConstants.MenuAction).includes(action)));
actions = actions.concat(standardActions);
if (SwitchAccessPredicate.isTextInput(node)) {
actions.push(SAConstants.MenuAction.KEYBOARD);
actions.push(SAConstants.MenuAction.DICTATION);
if (window.switchAccess.textEditingEnabled() &&
node.state[StateType.FOCUSED]) {
actions.push(SAConstants.MenuAction.JUMP_TO_BEGINNING_OF_TEXT);
actions.push(SAConstants.MenuAction.JUMP_TO_END_OF_TEXT);
actions.push(SAConstants.MenuAction.MOVE_BACKWARD_ONE_CHAR_OF_TEXT);
actions.push(SAConstants.MenuAction.MOVE_BACKWARD_ONE_WORD_OF_TEXT);
actions.push(SAConstants.MenuAction.MOVE_DOWN_ONE_LINE_OF_TEXT);
actions.push(SAConstants.MenuAction.MOVE_FORWARD_ONE_CHAR_OF_TEXT);
actions.push(SAConstants.MenuAction.MOVE_FORWARD_ONE_WORD_OF_TEXT);
actions.push(SAConstants.MenuAction.MOVE_UP_ONE_LINE_OF_TEXT);
actions.push(SAConstants.MenuAction.SELECT_START);
actions.push(SAConstants.MenuAction.SELECT_END);
}
} else if (actions.length > 0) {
actions.push(SAConstants.MenuAction.SELECT);
}
if (actions.length === 0)
return null;
actions.push(SAConstants.MenuAction.OPTIONS);
return actions;
}
/**
* Verify if a specified action is available in the current menu.
* @param {!SAConstants.MenuAction} action
* @return {boolean}
* @private
*/
isActionAvailable_(action) {
if (!this.inMenu_)
return false;
return this.actions_.includes(action);
}
/**
* Perform a specified action on the Switch Access menu.
* @param {!SAConstants.MenuAction} action
*/
performAction(action) {
if (!window.switchAccess.textEditingEnabled()) {
this.exit();
}
// Whether or not the menu should exit after performing
// the action.
let exitAfterAction = true;
switch (action) {
case SAConstants.MenuAction.SELECT:
this.navigationManager_.selectCurrentNode();
break;
case SAConstants.MenuAction.KEYBOARD:
this.navigationManager_.openKeyboard();
break;
case SAConstants.MenuAction.DICTATION:
chrome.accessibilityPrivate.toggleDictation();
break;
case SAConstants.MenuAction.OPTIONS:
window.switchAccess.showOptionsPage();
break;
case SAConstants.MenuAction.SCROLL_DOWN:
case SAConstants.MenuAction.SCROLL_UP:
case SAConstants.MenuAction.SCROLL_LEFT:
case SAConstants.MenuAction.SCROLL_RIGHT:
this.navigationManager_.scroll(action);
break;
case SAConstants.MenuAction.JUMP_TO_BEGINNING_OF_TEXT:
this.navigationManager_.jumpToBeginningOfText();
exitAfterAction = false;
break;
case SAConstants.MenuAction.JUMP_TO_END_OF_TEXT:
this.navigationManager_.jumpToEndOfText();
exitAfterAction = false;
break;
case SAConstants.MenuAction.MOVE_BACKWARD_ONE_CHAR_OF_TEXT:
this.navigationManager_.moveBackwardOneCharOfText();
exitAfterAction = false;
break;
case SAConstants.MenuAction.MOVE_BACKWARD_ONE_WORD_OF_TEXT:
this.navigationManager_.moveBackwardOneWordOfText();
exitAfterAction = false;
break;
case SAConstants.MenuAction.MOVE_DOWN_ONE_LINE_OF_TEXT:
this.navigationManager_.moveDownOneLineOfText();
exitAfterAction = false;
break;
case SAConstants.MenuAction.MOVE_FORWARD_ONE_CHAR_OF_TEXT:
this.navigationManager_.moveForwardOneCharOfText();
exitAfterAction = false;
break;
case SAConstants.MenuAction.MOVE_FORWARD_ONE_WORD_OF_TEXT:
this.navigationManager_.moveForwardOneWordOfText();
exitAfterAction = false;
break;
case SAConstants.MenuAction.MOVE_UP_ONE_LINE_OF_TEXT:
this.navigationManager_.moveUpOneLineOfText();
exitAfterAction = false;
break;
case SAConstants.MenuAction.SELECT_START:
this.navigationManager_.textInputManager().setSelectStart();
break;
case SAConstants.MenuAction.SELECT_END:
this.navigationManager_.textInputManager().setSelectEnd();
break;
default:
this.navigationManager_.performActionOnCurrentNode(action);
}
if (window.switchAccess.textEditingEnabled() && exitAfterAction) {
this.exit();
}
}
/**
* Send a message to the menu to update the focus ring around the current
* node.
* TODO(anastasi): Revisit focus rings before launch
* @private
* @param {boolean=} opt_clear If true, will clear the focus ring.
*/
updateFocusRing_(opt_clear) {
if (!this.menuPanel_) {
console.log('Error: Menu panel has not loaded.');
return;
}
this.calculateCurrentNode();
if (!this.inMenu_ || !this.node_)
return;
let id = this.node_.htmlAttributes.id;
// If the selection will close the menu, highlight the back button.
if (id === SAConstants.MENU_ID)
id = SAConstants.BACK_ID;
const enable = !opt_clear;
this.menuPanel_.setFocusRing(id, enable);
}
/**
* Updates the value of |this.node_|.
*
* - If it has a value, change nothing.
* - Otherwise, if menu node has a reasonable value, set |this.node_| to menu
* node.
* - If not, set it to null.
*
* Return |this.node_|'s value after the update.
*
* @private
* @return {chrome.automation.AutomationNode}
*/
calculateCurrentNode() {
if (this.node_)
return this.node_;
this.node_ = this.menuNode();
if (this.node_ === this.desktop_)
this.node_ = null;
return this.node_;
}
}