Commit f63636c2 authored by Akihiro Ota's avatar Akihiro Ota Committed by Commit Bot

ChromeVox: Add shortcuts to navigate by list.

This change adds support for navigation by list in ChromeVox. This
behaves in a similar manner to other navigation types such as
navigation by heading, link, button, etc.

This change also fixes bug 1012907. In this bug, ChromeVox would jump
to the second to last element, instead of the last element, when
jumping to the previous element from the top of the page.

Bug: 887630
Bug: 1012907
Change-Id: I35194c9c125e6b237db1258f8cb1a4cfdc1aa769
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1830364
Commit-Queue: Akihiro Ota <akihiroota@chromium.org>
Reviewed-by: default avatarDavid Tseng <dtseng@chromium.org>
Cr-Commit-Position: refs/heads/master@{#710097}
parent 64e8a34e
...@@ -1030,7 +1030,7 @@ ...@@ -1030,7 +1030,7 @@
} }
} }
}, },
{ {
"command": "readLinkURL", "command": "readLinkURL",
"sequence": { "sequence": {
"cvoxModifier": true, "cvoxModifier": true,
...@@ -1038,6 +1038,24 @@ ...@@ -1038,6 +1038,24 @@
"keyCode": [65, 76] "keyCode": [65, 76]
} }
} }
},
{
"command": "nextList",
"sequence": {
"cvoxModifier": true,
"keys": {
"keyCode": [78, 76]
}
}
},
{
"command": "previousList",
"sequence": {
"cvoxModifier": true,
"keys": {
"keyCode": [80, 76]
}
}
} }
] ]
} }
...@@ -103,6 +103,9 @@ AutomationPredicate.link = AutomationPredicate.roles([Role.LINK]); ...@@ -103,6 +103,9 @@ AutomationPredicate.link = AutomationPredicate.roles([Role.LINK]);
AutomationPredicate.row = AutomationPredicate.roles([Role.ROW]); AutomationPredicate.row = AutomationPredicate.roles([Role.ROW]);
/** @type {AutomationPredicate.Unary} */ /** @type {AutomationPredicate.Unary} */
AutomationPredicate.table = AutomationPredicate.roles([Role.GRID, Role.TABLE]); AutomationPredicate.table = AutomationPredicate.roles([Role.GRID, Role.TABLE]);
/** @type {AutomationPredicate.Unary} */
AutomationPredicate.listLike =
AutomationPredicate.roles([Role.LIST, Role.DESCRIPTION_LIST]);
/** /**
* @param {!AutomationNode} node * @param {!AutomationNode} node
...@@ -676,4 +679,24 @@ AutomationPredicate.ignoreDuringJump = function(node) { ...@@ -676,4 +679,24 @@ AutomationPredicate.ignoreDuringJump = function(node) {
node.role == Role.INLINE_TEXT_BOX; node.role == Role.INLINE_TEXT_BOX;
}; };
/**
* Returns a predicate that will match against a list-like node. The returned
* predicate should not match the first list-like ancestor of |node| (or |node|
* itself, if it is list-like).
* @param {AutomationNode} node
* @return {AutomationPredicate.Unary}
*/
AutomationPredicate.makeListPredicate = function(node) {
// Scan upward for a list-like ancestor. We do not want to match against this
// node.
var avoidNode = node;
while (avoidNode && !AutomationPredicate.listLike(avoidNode)) {
avoidNode = avoidNode.parent;
}
return function(autoNode) {
return AutomationPredicate.listLike(autoNode) && (autoNode !== avoidNode);
};
};
}); // goog.scope }); // goog.scope
...@@ -2117,6 +2117,107 @@ TEST_F('ChromeVoxBackgroundTest', 'NonModalDialogHeadingJump', function() { ...@@ -2117,6 +2117,107 @@ TEST_F('ChromeVoxBackgroundTest', 'NonModalDialogHeadingJump', function() {
.call(doCmd('previousHeading')) .call(doCmd('previousHeading'))
.expectSpeech('Heading outside dialog') .expectSpeech('Heading outside dialog')
.replay(); .replay();
});
});
TEST_F('ChromeVoxBackgroundTest', 'NavigationByListTest', function() {
var mockFeedback = this.createMockFeedback();
this.runWithLoadedTree(function() {/*!
<p>Start here</p>
<ul>
Drinks
<li>Coffee</li>
<li>Tea</li>
</ul>
<p>A random paragraph</p>
<ul></ul>
<ol>
Lunch
<li>Burgers</li>
<li>Fries</li>
<li>Soda</li>
<ul>
Nested list
<li>Element</li>
</ul>
</ol>
<p>Another random paragraph</p>
<dl>
Colors
<dt>Red</dt>
<dd>Description for red</dd>
<dt>Blue</dt>
<dd>Description for blue</dd>
<dt>Green</dt>
<dd>Description for green</dd>
</dl>
*/}, function(root) {
mockFeedback.call(doCmd('jumpToTop'))
.call(doCmd('nextList'))
.expectSpeech('Drinks', 'List', 'with 2 items')
.call(doCmd('nextList'))
.expectSpeech('List' , 'with 0 items')
.call(doCmd('nextList'))
.expectSpeech('Lunch', 'List', 'with 3 items')
.call(doCmd('nextList'))
.expectSpeech('Nested list', 'List', 'with 1 item')
.call(doCmd('nextList'))
.expectSpeech('Colors', 'Description list', 'with 3 items')
.call(doCmd('nextList'))
// Ensure we wrap correctly.
.expectSpeech('Drinks', 'List', 'with 2 items')
.call(doCmd('nextObject'))
.call(doCmd('nextObject'))
.expectSpeech('Coffee')
// Ensure we wrap correctly and go to previous list, not top of current list.
.call(doCmd('previousList'))
.expectSpeech('Colors')
.call(doCmd('previousObject'))
.expectSpeech('Another random paragraph')
// Ensure we dive into the nested list.
.call(doCmd('previousList'))
.expectSpeech('Nested list', 'List', 'with 1 item')
.call(doCmd('previousList'))
.expectSpeech('Lunch')
.call(doCmd('nextObject'))
.call(doCmd('nextObject'))
.expectSpeech('Burgers')
// Ensure we go to the previous list, not the top of the current list.
.call(doCmd('previousList'))
.expectSpeech('List', 'with 0 items')
.call(doCmd('previousObject'))
.expectSpeech('A random paragraph')
.call(doCmd('previousList'))
.expectSpeech('Drinks', 'List', 'with 2 items')
.replay();
});
});
TEST_F('ChromeVoxBackgroundTest', 'NoListTest', function() {
var mockFeedback = this.createMockFeedback();
this.runWithLoadedTree(function() {/*!
<button>Click me</button>
*/}, function(root) {
mockFeedback.call(doCmd('nextList'))
.expectSpeech('No next list.')
.call(doCmd('previousList'))
.expectSpeech('No previous list.');
mockFeedback.replay();
});
});
TEST_F('ChromeVoxBackgroundTest', 'NavigateToLastHeading', function() {
var mockFeedback = this.createMockFeedback();
this.runWithLoadedTree(function() {/*!
<h1>First</h1>
<h1>Second</h1>
<h1>Third</h1>
*/}, function(root) {
mockFeedback.call(doCmd('jumpToTop'))
.expectSpeech('First', 'Heading 1')
.call(doCmd('previousHeading'))
.expectSpeech('Third', 'Heading 1')
.replay();
}); });
}); });
......
...@@ -299,6 +299,7 @@ CommandHandler.onCommand = function(command) { ...@@ -299,6 +299,7 @@ CommandHandler.onCommand = function(command) {
var didNavigate = false; var didNavigate = false;
var tryScrolling = true; var tryScrolling = true;
var skipSettingSelection = false; var skipSettingSelection = false;
var skipInitialAncestry = true;
switch (command) { switch (command) {
case 'nextCharacter': case 'nextCharacter':
didNavigate = true; didNavigate = true;
...@@ -522,6 +523,16 @@ CommandHandler.onCommand = function(command) { ...@@ -522,6 +523,16 @@ CommandHandler.onCommand = function(command) {
var useNode = node || originalNode; var useNode = node || originalNode;
pred = AutomationPredicate.roles([node.role]); pred = AutomationPredicate.roles([node.role]);
break; break;
case 'nextList':
pred = AutomationPredicate.makeListPredicate(current.start.node);
predErrorMsg = 'no_next_list';
break;
case 'previousList':
dir = Dir.BACKWARD;
pred = AutomationPredicate.makeListPredicate(current.start.node);
predErrorMsg = 'no_previous_list';
skipInitialAncestry = false;
break;
case 'jumpToTop': case 'jumpToTop':
var node = AutomationUtil.findNodePost( var node = AutomationUtil.findNodePost(
current.start.node.root, Dir.FORWARD, AutomationPredicate.object); current.start.node.root, Dir.FORWARD, AutomationPredicate.object);
...@@ -1007,7 +1018,8 @@ CommandHandler.onCommand = function(command) { ...@@ -1007,7 +1018,8 @@ CommandHandler.onCommand = function(command) {
if (!node) { if (!node) {
node = AutomationUtil.findNextNode( node = AutomationUtil.findNextNode(
bound, dir, pred, {skipInitialAncestry: true, root: rootPred}); bound, dir, pred,
{skipInitialAncestry: skipInitialAncestry, root: rootPred});
} }
if (node && !skipSync) { if (node && !skipSync) {
...@@ -1044,8 +1056,7 @@ CommandHandler.onCommand = function(command) { ...@@ -1044,8 +1056,7 @@ CommandHandler.onCommand = function(command) {
root, dir, AutomationPredicate.leaf) || root, dir, AutomationPredicate.leaf) ||
bound; bound;
} }
node = AutomationUtil.findNextNode( node = AutomationUtil.findNextNode(bound, dir, pred, {root: rootPred});
bound, dir, pred, {skipInitialAncestry: true, root: rootPred});
if (node && !skipSync) { if (node && !skipSync) {
node = AutomationUtil.findNodePre( node = AutomationUtil.findNodePre(
......
...@@ -163,6 +163,9 @@ Output.ROLE_INFO_ = { ...@@ -163,6 +163,9 @@ Output.ROLE_INFO_ = {
contentInfo: {msgId: 'role_contentinfo', inherits: 'abstractContainer'}, contentInfo: {msgId: 'role_contentinfo', inherits: 'abstractContainer'},
date: {msgId: 'input_type_date', inherits: 'abstractContainer'}, date: {msgId: 'input_type_date', inherits: 'abstractContainer'},
definition: {msgId: 'role_definition', inherits: 'abstractContainer'}, definition: {msgId: 'role_definition', inherits: 'abstractContainer'},
descriptionList: {msgId: 'role_description_list', inherits: 'abstractList'},
descriptionListDetail:
{msgId: 'role_description_list_detail', inherits: 'abstractItem'},
dialog: dialog:
{msgId: 'role_dialog', outputContextFirst: true, ignoreAncestry: true}, {msgId: 'role_dialog', outputContextFirst: true, ignoreAncestry: true},
directory: {msgId: 'role_directory', inherits: 'abstractContainer'}, directory: {msgId: 'role_directory', inherits: 'abstractContainer'},
......
...@@ -3748,6 +3748,18 @@ If you're done with the tutorial, use ChromeVox to navigate to the Close button ...@@ -3748,6 +3748,18 @@ If you're done with the tutorial, use ChromeVox to navigate to the Close button
<message desc="Used to describe the link behind a url." name="IDS_CHROMEVOX_URL_BEHIND_LINK"> <message desc="Used to describe the link behind a url." name="IDS_CHROMEVOX_URL_BEHIND_LINK">
Link URL: <ph name="link_url">$1</ph> Link URL: <ph name="link_url">$1</ph>
</message> </message>
<message desc="Describes an HTML description list element." name="IDS_CHROMEVOX_ROLE_DESCRIPTION_LIST">
Description list
</message>
<message desc="Describes an HTML description list detail element." name="IDS_CHROMEVOX_ROLE_DESCRIPTION_LIST_DETAIL">
Description list detail
</message>
<message desc="This is an abbreviated HTML description list element shown on a braille display. When translating, try to find a contracted form of the translation for 'description list' according to local conventions. If reasonable, use all lowercase and avoid punctuation to keep the number of characters as low as possible." name="IDS_CHROMEVOX_ROLE_DESCRIPTION_LIST_BRL">
dscrplst
</message>
<message desc="This is an abbreviated HTML description list detail element shown on a braille display. When translating, try to find a contracted form of the translation for 'description list detail' according to local conventions. If reasonable, use all lowercase and avoid punctuation to keep the number of characters as low as possible." name="IDS_CHROMEVOX_ROLE_DESCRIPTION_LIST_DETAIL_BRL">
dscrplst dtl
</message>
</messages> </messages>
</release> </release>
</grit> </grit>
...@@ -44,7 +44,8 @@ goog.require('goog.string'); ...@@ -44,7 +44,8 @@ goog.require('goog.string');
/** /**
* @define {boolean} Whether to strip out asserts or to leave them in. * @define {boolean} Whether to strip out asserts or to leave them in.
*/ */
goog.define('goog.asserts.ENABLE_ASSERTS', goog.DEBUG); goog.asserts.ENABLE_ASSERTS =
goog.define('goog.asserts.ENABLE_ASSERTS', goog.DEBUG);
......
...@@ -200,7 +200,7 @@ goog.DEBUG = true; ...@@ -200,7 +200,7 @@ goog.DEBUG = true;
* this rule: the Hebrew language. For legacy reasons the old code (iw) should * this rule: the Hebrew language. For legacy reasons the old code (iw) should
* be used instead of the new code (he), see http://wiki/Main/IIISynonyms. * be used instead of the new code (he), see http://wiki/Main/IIISynonyms.
*/ */
goog.define('goog.LOCALE', 'en'); // default to en goog.LOCALE = goog.define('goog.LOCALE', 'en'); // default to en
/** /**
...@@ -214,7 +214,7 @@ goog.define('goog.LOCALE', 'en'); // default to en ...@@ -214,7 +214,7 @@ goog.define('goog.LOCALE', 'en'); // default to en
* relying on non-standard implementations, specify * relying on non-standard implementations, specify
* "--define goog.TRUSTED_SITE=false" to the JSCompiler. * "--define goog.TRUSTED_SITE=false" to the JSCompiler.
*/ */
goog.define('goog.TRUSTED_SITE', true); goog.TRUSTED_SITE = goog.define('goog.TRUSTED_SITE', true);
/** /**
...@@ -224,7 +224,7 @@ goog.define('goog.TRUSTED_SITE', true); ...@@ -224,7 +224,7 @@ goog.define('goog.TRUSTED_SITE', true);
* running in EcmaScript Strict mode or warn about unavailable functionality. * running in EcmaScript Strict mode or warn about unavailable functionality.
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions_and_function_scope/Strict_mode * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions_and_function_scope/Strict_mode
*/ */
goog.define('goog.STRICT_MODE_COMPATIBLE', false); goog.STRICT_MODE_COMPATIBLE = goog.define('goog.STRICT_MODE_COMPATIBLE', false);
/** /**
...@@ -428,7 +428,7 @@ goog.addDependency = function(relPath, provides, requires) { ...@@ -428,7 +428,7 @@ goog.addDependency = function(relPath, provides, requires) {
* provided (and depend on the fact that some outside tool correctly ordered * provided (and depend on the fact that some outside tool correctly ordered
* the script). * the script).
*/ */
goog.define('goog.ENABLE_DEBUG_LOADER', true); goog.ENABLE_DEBUG_LOADER = goog.define('goog.ENABLE_DEBUG_LOADER', true);
/** /**
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment