Commit d7e2fff5 authored by Luciano Pacheco's avatar Luciano Pacheco Committed by Commit Bot

[Files app] A11y announce file selection changes

Change file list and file grid view to announce file selection changes
when triggered by user input (keyboard, mouse and tap). The following
actions are announced on selection changes, via aria-live:

  1. Selecting a single file entry.
  2. Selecting a range of file entires.
  3. Adding a file entry to the current selection.
  4. Removing a file entry from the current selection.
  5. Selecting all file entries.
  6. Deselecting all files entries.

Add i18n translation support for these messages.

Change ToolbarController, FileGrid and FileTable to receive an
{A11yAnnounce} implementation to be able to send aria-live messages.

Add getItemLabel to FileGrid, FileTable and FileTableList to be able to
retrieve item's label based on the selection index to be able to add
the item name/label to the aria message.

Change test util RemoteCall.waitAndClickElement to accept modifier keys
so we can send Ctrl and Shift modifiers for testing multiple file entry
selection in FilesApp integration test.

While here, change == and != to === and !==. Also call the constructor
|super| instead of "cr.ui.table.TableList.prototype.mergeItems.call".

NOTE: Add tests only for Keyboard and Mouse for now, because the touch
emulation event isn't triggering file selections yet.

Test: browser_tests --gtest_filter="*KeyboardSelectionA11y* : *MouseSelectionA11y*"
Bug: 888636
Change-Id: I1bfdcf3987a7bcf8d29bfce6468c3b109f5fb239
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1638299
Commit-Queue: Luciano Pacheco <lucmult@chromium.org>
Commit-Queue: Noel Gordon <noel@chromium.org>
Reviewed-by: default avatarNoel Gordon <noel@chromium.org>
Auto-Submit: Luciano Pacheco <lucmult@chromium.org>
Cr-Commit-Position: refs/heads/master@{#666567}
parent 99a823f9
......@@ -900,7 +900,9 @@ WRAPPED_INSTANTIATE_TEST_SUITE_P(
TestCase("showGridViewDownloads").InGuestMode(),
TestCase("showGridViewDrive").DisableDriveFs(),
TestCase("showGridViewDrive").EnableDriveFs(),
TestCase("showGridViewButtonSwitches")));
TestCase("showGridViewButtonSwitches"),
TestCase("showGridViewKeyboardSelectionA11y"),
TestCase("showGridViewMouseSelectionA11y")));
WRAPPED_INSTANTIATE_TEST_SUITE_P(
Providers, /* providers.js */
......@@ -946,7 +948,9 @@ WRAPPED_INSTANTIATE_TEST_SUITE_P(
FilesAppBrowserTest,
::testing::Values(TestCase("fileListAriaAttributes"),
TestCase("fileListFocusFirstItem"),
TestCase("fileListSelectLastFocusedItem")));
TestCase("fileListSelectLastFocusedItem"),
TestCase("fileListKeyboardSelectionA11y"),
TestCase("fileListMouseSelectionA11y")));
WRAPPED_INSTANTIATE_TEST_SUITE_P(
Crostini, /* crostini.js */
......
......@@ -777,6 +777,14 @@ std::unique_ptr<base::DictionaryValue> GetFileManagerStrings() {
IDS_FILE_BROWSER_COLUMN_DESC_SORT_MESSAGE);
SET_STRING("COLUMN_SORTED_ASC", IDS_FILE_BROWSER_COLUMN_SORTED_ASC_MESSAGE);
SET_STRING("COLUMN_SORTED_DESC", IDS_FILE_BROWSER_COLUMN_SORTED_DESC_MESSAGE);
SET_STRING("SELECTION_ADD_SINGLE_ENTRY",
IDS_FILE_BROWSER_SELECTION_ADD_SINGLE_ENTRY);
SET_STRING("SELECTION_REMOVE_SINGLE_ENTRY",
IDS_FILE_BROWSER_SELECTION_REMOVE_SINGLE_ENTRY);
SET_STRING("SELECTION_SINGLE_ENTRY", IDS_FILE_BROWSER_SELECTION_SINGLE_ENTRY);
SET_STRING("SELECTION_ADD_RANGE", IDS_FILE_BROWSER_SELECTION_ADD_RANGE);
SET_STRING("SELECTION_CANCELLATION", IDS_FILE_BROWSER_SELECTION_CANCELLATION);
SET_STRING("SELECTION_ALL_ENTRIES", IDS_FILE_BROWSER_SELECTION_ALL_ENTRIES);
SET_STRING("SIZE_GB", IDS_FILE_BROWSER_SIZE_GB);
SET_STRING("SIZE_KB", IDS_FILE_BROWSER_SIZE_KB);
SET_STRING("SIZE_MB", IDS_FILE_BROWSER_SIZE_MB);
......
......@@ -113,6 +113,28 @@
<message name="IDS_FILE_BROWSER_COLUMN_SORTED_DESC_MESSAGE" desc="Message read by Chromevox/screenreader after sorting a column in the File list in descending order.">
File list sorted by <ph name="COLUMN_NAME">$1<ex>date modified</ex></ph> in descending order.
</message>
<!-- File selection a11y -->
<message name="IDS_FILE_BROWSER_SELECTION_ADD_SINGLE_ENTRY" desc="Message spoken by Chromevox/screenreader when adding a single entry (file or folder) to the existing selection.">
Added <ph name="ENTRY_NAME">$1<ex>file.txt</ex></ph> to selection.
</message>
<message name="IDS_FILE_BROWSER_SELECTION_REMOVE_SINGLE_ENTRY" desc="Message spoken by Chromevox/screenreader when remove a single entry (file or folder) from the existing selection.">
Removed <ph name="ENTRY_NAME">$1<ex>file.txt</ex></ph> from selection.
</message>
<message name="IDS_FILE_BROWSER_SELECTION_SINGLE_ENTRY" desc="Message spoken by Chromevox/screenreader when selection only one entry (without multiple selection).">
Selected <ph name="ENTRY_NAME">$1<ex>file.txt</ex></ph>.
</message>
<message name="IDS_FILE_BROWSER_SELECTION_ADD_RANGE" desc="Message spoken by Chromevox/screenreader when user selects a range of entries.">
Selected a range of <ph name="ENTRY_COUNT">$1<ex>10</ex></ph> entries from <ph name="FROM_ENTRY_NAME">$2<ex>file.txt</ex></ph> to <ph name="TO_ENTRY_NAME">$3<ex>another_file.txt</ex></ph>.
</message>
<message name="IDS_FILE_BROWSER_SELECTION_CANCELLATION" desc="Message spoken by Chromevox/screenreader user cancels a selection via 'Cancel selection' button or hitting Esc key.">
Removed all entries from selection.
</message>
<message name="IDS_FILE_BROWSER_SELECTION_ALL_ENTRIES" desc="Message spoken by Chromevox/screenreader user selects all entries e.g.: using Ctlr+A.">
Selected all entries.
</message>
<message name="IDS_FILE_BROWSER_SIZE_BYTES" desc="Size in bytes.">
<ph name="NUMBER_OF_BYTES">$1<ex>42</ex></ph> bytes
</message>
......
b6ca789018038780c9a28d2f478afb8583816a2a
\ No newline at end of file
589945b82c1f270ec8c2ba102be0d49f6e424b5c
\ No newline at end of file
a7c5c7ab0d8b043a7288d17a666146c73885f2f9
\ No newline at end of file
6ae8c9af482130d9fa23bb66e9d91c9566cfdbb9
\ No newline at end of file
cc589193dd76253dad91587b34bd8aa854877d68
\ No newline at end of file
b85cccc457069752f37732cb7caf4d194c8ba26c
\ No newline at end of file
......@@ -570,7 +570,8 @@ class FileManager extends cr.EventTarget {
this.toolbarController_ = new ToolbarController(
this.ui_.toolbar, this.ui_.dialogNavigationList, this.ui_.listContainer,
assert(this.ui_.locationLine), this.selectionHandler_,
this.directoryModel_, this.volumeManager_);
this.directoryModel_, this.volumeManager_,
/** @type {!A11yAnnounce} */ (this.ui_));
this.emptyFolderController_ = new EmptyFolderController(
this.ui_.emptyFolder, this.directoryModel_, this.ui_.alertDialog);
this.actionsController_ = new ActionsController(
......@@ -914,7 +915,8 @@ class FileManager extends cr.EventTarget {
this.dialogType == DialogType.FULL_PAGE);
const grid = queryRequiredElement('.thumbnail-grid', dom);
FileGrid.decorate(
grid, this.metadataModel_, this.volumeManager_, this.historyLoader_);
grid, this.metadataModel_, this.volumeManager_, this.historyLoader_,
/** @type {!A11yAnnounce} */ (this.ui_));
this.addHistoryObserver_();
......
......@@ -121,7 +121,7 @@ function setUp() {
// Setup FileGrid.
const grid = /** @type {!FileGrid} */ (queryRequiredElement('#file-grid'));
FileGrid.decorate(grid, metadataModel, volumeManager, historyLoader);
FileGrid.decorate(grid, metadataModel, volumeManager, historyLoader, a11y);
// Setup the ListContainer and its dependencies
listContainer =
......
......@@ -18,10 +18,11 @@ class ToolbarController {
* @param {!FileSelectionHandler} selectionHandler
* @param {!DirectoryModel} directoryModel
* @param {!VolumeManager} volumeManager
* @param {!A11yAnnounce} a11y
*/
constructor(
toolbar, navigationList, listContainer, locationLine, selectionHandler,
directoryModel, volumeManager) {
directoryModel, volumeManager, a11y) {
/**
* @private {!HTMLElement}
* @const
......@@ -107,6 +108,12 @@ class ToolbarController {
*/
this.volumeManager_ = volumeManager;
/**
* @private {!A11yAnnounce}
* @const
*/
this.a11y_ = a11y;
this.selectionHandler_.addEventListener(
FileSelectionHandler.EventType.CHANGE,
this.onSelectionChanged_.bind(this));
......@@ -209,6 +216,7 @@ class ToolbarController {
*/
onCancelSelectionButtonClicked_() {
this.directoryModel_.selectEntries([]);
this.a11y_.speakA11yMessage(str('SELECTION_CANCELLATION'));
}
/**
......
......@@ -70,6 +70,9 @@ class FileGrid extends cr.ui.Grid {
/** @private {?ObjectPropertyDescriptor|undefined} */
this.dataModelDescriptor_ = null;
/** @public {?A11yAnnounce} */
this.a11y = null;
throw new Error('Use FileGrid.decorate');
}
......@@ -102,14 +105,16 @@ class FileGrid extends cr.ui.Grid {
* @param {!MetadataModel} metadataModel File system metadata.
* @param {!VolumeManager} volumeManager Volume manager instance.
* @param {!importer.HistoryLoader} historyLoader
* @param {!A11yAnnounce} a11y
*/
static decorate(element, metadataModel, volumeManager, historyLoader) {
static decorate(element, metadataModel, volumeManager, historyLoader, a11y) {
cr.ui.Grid.decorate(element);
const self = /** @type {!FileGrid} */ (element);
self.__proto__ = FileGrid.prototype;
self.metadataModel_ = metadataModel;
self.volumeManager_ = volumeManager;
self.historyLoader_ = historyLoader;
self.a11y = a11y;
self.listThumbnailLoader_ = null;
self.beginIndex_ = 0;
......@@ -134,6 +139,25 @@ class FileGrid extends cr.ui.Grid {
self.paddingTop_ = parseFloat(style.paddingTop);
}
/**
* @param {number} index Index of the list item.
* @return {string}
*/
getItemLabel(index) {
if (index === -1) {
return '';
}
/** @type {Entry|FilesAppEntry} */
const entry = this.dataModel.item(index);
if (!entry) {
return '';
}
const locationInfo = this.volumeManager_.getLocationInfo(entry);
return util.getEntryLabel(locationInfo, entry);
}
/**
* Sets list thumbnail loader.
* @param {ListThumbnailLoader} listThumbnailLoader A list thumbnail loader.
......@@ -621,6 +645,7 @@ class FileGrid extends cr.ui.Grid {
bottom.appendChild(
filelist.renderFileNameLabel(li.ownerDocument, entry, locationInfo));
frame.appendChild(bottom);
li.setAttribute('file-name', util.getEntryLabel(locationInfo, entry));
this.updateSharedStatus_(li, entry);
}
......@@ -1011,6 +1036,11 @@ class FileGridSelectionController extends cr.ui.GridSelectionController {
filelist.handleKeyDown.call(this, e);
}
/** @return {!FileGrid} */
get filesView() {
return /** @type {!FileGrid} */ (this.grid_);
}
/** @override */
getIndexBelow(index) {
if (this.isAccessibilityEnabled()) {
......
......@@ -405,8 +405,8 @@ class FileTable extends cr.ui.Table {
/** @private {?function(!Event)} */
this.onThumbnailLoadedBound_ = null;
/** @private {?A11yAnnounce} */
this.a11y_ = null;
/** @public {?A11yAnnounce} */
this.a11y = null;
throw new Error('Designed to decorate elements');
}
......@@ -431,7 +431,7 @@ class FileTable extends cr.ui.Table {
self.metadataModel_ = metadataModel;
self.volumeManager_ = volumeManager;
self.historyLoader_ = historyLoader;
self.a11y_ = a11y;
self.a11y = a11y;
/** @private {ListThumbnailLoader} */
self.listThumbnailLoader_ = null;
......@@ -588,7 +588,7 @@ class FileTable extends cr.ui.Table {
// Delegate to parent to sort.
super.sort(index);
this.a11y_.speakA11yMessage(msg);
this.a11y.speakA11yMessage(msg);
}
/**
......@@ -860,6 +860,25 @@ class FileTable extends cr.ui.Table {
return label;
}
/**
* @param {number} index Index of the list item.
* @return {string}
*/
getItemLabel(index) {
if (index === -1) {
return '';
}
/** @type {Entry|FilesAppEntry} */
const entry = this.dataModel.item(index);
if (!entry) {
return '';
}
const locationInfo = this.volumeManager_.getLocationInfo(entry);
return util.getEntryLabel(locationInfo, entry);
}
/**
* Render the Size column of the detail table.
*
......
......@@ -142,4 +142,158 @@
chrome.test.assertEq(1, selectedRows.length);
chrome.test.assertEq(2, fileRows.indexOf(selectedRows[0]));
};
/**
* Verifies the total number of a11y messages and asserts the latest message
* is the expected one.
*
* @param {string} appId
* @param {number} expectedCount
* @param {string} expectedMessage
* @return {string} Latest a11y message.
*/
async function countAndCheckLatestA11yMessage(
appId, expectedCount, expectedMessage) {
const a11yMessages =
await remoteCall.callRemoteTestUtil('getA11yAnnounces', appId, []);
chrome.test.assertEq(
expectedCount, a11yMessages.length, 'Wrong number of a11y messages');
const latestMessage = a11yMessages[a11yMessages.length - 1];
chrome.test.assertEq(expectedMessage, latestMessage);
return latestMessage;
}
/**
* Tests that selecting/de-selecting files with keyboard produces a11y
* messages.
*
* NOTE: Test shared with grid_view.js.
* @param {boolean=} isGridView if the test is testing the grid view.
*/
testcase.fileListKeyboardSelectionA11y = async (isGridView) => {
const appId = await setupAndWaitUntilReady(
RootPath.DOWNLOADS, BASIC_LOCAL_ENTRY_SET, []);
let a11yMsgCount = 0;
const viewSelector = isGridView ? 'grid#file-list' : '#file-list';
if (isGridView) {
// Click view-button again to switch to detail view.
await remoteCall.waitAndClickElement(appId, '#view-button');
// Clicking #view-button adds 1 a11y message.
++a11yMsgCount;
}
// Keys used for keyboard navigation in the file list.
const homeKey = [viewSelector, 'Home', false, false, false];
const ctrlDownKey = [viewSelector, 'ArrowDown', true, false, false];
const ctrlSpaceKey = [viewSelector, ' ', true, false, false];
const shiftEndKey = [viewSelector, 'End', false, true, false];
const ctrlAKey = [viewSelector + ' li', 'a', true, false, false];
const escKey = [viewSelector, 'Escape', false, false, false];
// Select first item with Home key.
await remoteCall.fakeKeyDown(appId, ...homeKey);
// Check: Announced "photos" directory selection.
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount, 'Selected photos.');
// Ctrl+Down & Ctrl+Space to select second item: Beautiful Song.ogg
await remoteCall.fakeKeyDown(appId, ...ctrlDownKey);
await remoteCall.fakeKeyDown(appId, ...ctrlSpaceKey);
// Check: Announced "Beautiful Song.add" added to selection.
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount, 'Added Beautiful Song.ogg to selection.');
// Shift+End to select from 2nd item to the last item.
await remoteCall.fakeKeyDown(appId, ...shiftEndKey);
// Check: Announced range selection from "Beautiful Song.add" to hello.txt.
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount,
'Selected a range of 4 entries from Beautiful Song.ogg to hello.txt.');
// Ctrl+Space to de-select currently focused item (last item).
await remoteCall.fakeKeyDown(appId, ...ctrlSpaceKey);
// Check: Announced de-selecting hello.txt
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount, 'Removed hello.txt from selection.');
// Ctrl+A to select all items.
await remoteCall.fakeKeyDown(appId, ...ctrlAKey);
// Check: Announced selecting all entries.
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount, 'Selected all entries.');
// Esc key to deselect all.
await remoteCall.fakeKeyDown(appId, ...escKey);
// Check: Announced deselecting all entries.
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount, 'Removed all entries from selection.');
};
/**
* Tests that selecting/de-selecting files with mouse produces a11y messages.
*
* NOTE: Test shared with grid_view.js.
* @param {boolean=} isGridView if the test is testing the grid view.
*/
testcase.fileListMouseSelectionA11y = async (isGridView) => {
const appId = await setupAndWaitUntilReady(
RootPath.DOWNLOADS, BASIC_LOCAL_ENTRY_SET, []);
let a11yMsgCount = 0;
if (isGridView) {
// Click view-button again to switch to detail view.
await remoteCall.waitAndClickElement(appId, '#view-button');
// Clicking #view-button adds 1 a11y message.
++a11yMsgCount;
}
// Click first item.
await remoteCall.waitAndClickElement(
appId, '#file-list [file-name="photos"]');
// Check: Announced "photos" directory selection.
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount, 'Selected photos.');
// Ctrl+Click second item.
await remoteCall.waitAndClickElement(
appId, '#file-list [file-name="Beautiful Song.ogg"]', {ctrl: true});
// Check: Announced "Beautiful Song.add" added to selection.
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount, 'Added Beautiful Song.ogg to selection.');
// Shift+Click last item.
await remoteCall.waitAndClickElement(
appId, '#file-list [file-name="hello.txt"]', {shift: true});
// Check: Announced range selection from "Beautiful Song.add" to hello.txt.
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount,
'Selected a range of 4 entries from Beautiful Song.ogg to hello.txt.');
// Ctrl+Click to de-select the last item.
await remoteCall.waitAndClickElement(
appId, '#file-list [file-name="hello.txt"]', {ctrl: true});
// Check: Announced de-selecting hello.txt
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount, 'Removed hello.txt from selection.');
// Click on "Cancel selection" button.
await remoteCall.waitAndClickElement(appId, '#cancel-selection-button');
// Check: Announced deselecting all entries.
await countAndCheckLatestA11yMessage(
appId, ++a11yMsgCount, 'Removed all entries from selection.');
};
})();
......@@ -85,3 +85,20 @@ testcase.showGridViewButtonSwitches = async () => {
chrome.test.assertEq(2, a11yMessages.length, 'Missing a11y message');
chrome.test.assertEq('File list has changed to list view.', a11yMessages[1]);
};
/**
* Tests that selecting/de-selecting files with keyboard produces a11y
* messages.
*/
testcase.showGridViewKeyboardSelectionA11y = async () => {
const isGridView = true;
return testcase.fileListKeyboardSelectionA11y(isGridView);
};
/**
* Tests that selecting/de-selecting files with mouse produces a11y messages.
*/
testcase.showGridViewMouseSelectionA11y = async () => {
const isGridView = true;
return testcase.fileListMouseSelectionA11y(isGridView);
};
......@@ -76,8 +76,8 @@
// Check that opening the file was announced to screen reader.
chrome.test.assertTrue(a11yMessages instanceof Array);
chrome.test.assertEq(1, a11yMessages.length);
chrome.test.assertEq('Opening file image3.jpg.', a11yMessages[0]);
chrome.test.assertEq(3, a11yMessages.length);
chrome.test.assertEq('Opening file image3.jpg.', a11yMessages[2]);
// Check: the Gallery window should open.
const galleryAppId = await galleryApp.waitForWindow('gallery.html');
......
......@@ -330,12 +330,15 @@ RemoteCall.prototype.waitForAFile = function(volumeType, name) {
* If query is an array, |query[0]| specifies the first
* element(s), |query[1]| specifies elements inside the shadow DOM of
* the first element, and so on.
* @param {{shift: boolean, alt: boolean, ctrl: boolean}=} opt_keyModifiers
* Object
* @param {Promise} Promise to be fulfilled with the clicked element.
*/
RemoteCall.prototype.waitAndClickElement = async function(windowId, query) {
RemoteCall.prototype.waitAndClickElement =
async function(windowId, query, opt_keyModifiers) {
const element = await this.waitForElement(windowId, query);
const result =
await this.callRemoteTestUtil('fakeMouseClick', windowId, [query]);
const result = await this.callRemoteTestUtil(
'fakeMouseClick', windowId, [query, opt_keyModifiers]);
chrome.test.assertTrue(result, 'mouse click failed.');
return element;
};
......
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