Commit 71d14181 authored by hcarmona's avatar hcarmona Committed by Commit bot

Make downloads list keyboard shortcuts more consistent.

The changes make it possible to focus individual downloads by using UP/DOWN on the keyboard (like other tables and lists). It is possible to move between the links in a specific download using LEFT/RIGHT and TAB. Focus changes with TAB are achieved by relying on the focusing of elements rather than capturing the keys. This allows screen readers to focus elements without weird behavior.

Changes in FocusRow to support TAB navigation will also affect the history page. This change will keep both pages consistent.

BUG=427670,452870

Review URL: https://codereview.chromium.org/807593005

Cr-Commit-Position: refs/heads/master@{#314416}
parent 16e84de0
...@@ -29,6 +29,10 @@ ...@@ -29,6 +29,10 @@
position: relative; position: relative;
} }
.focus-row-active {
background-color: rgba(0, 0, 0, .05);
}
.download, .download,
#no-downloads-or-results { #no-downloads-or-results {
margin-top: 6px; margin-top: 6px;
......
...@@ -12,6 +12,10 @@ ...@@ -12,6 +12,10 @@
<script src="chrome://resources/js/cr/ui/command.js"></script> <script src="chrome://resources/js/cr/ui/command.js"></script>
<script src="chrome://resources/js/load_time_data.js"></script> <script src="chrome://resources/js/load_time_data.js"></script>
<script src="chrome://resources/js/util.js"></script> <script src="chrome://resources/js/util.js"></script>
<script src="chrome://resources/js/assert.js"></script>
<script src="chrome://resources/js/event_tracker.js"></script>
<script src="chrome://resources/js/cr/ui/focus_row.js"></script>
<script src="chrome://resources/js/cr/ui/focus_grid.js"></script>
<script src="chrome://downloads/downloads.js"></script> <script src="chrome://downloads/downloads.js"></script>
</head> </head>
<body> <body>
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
// TODO(jhawkins): Use hidden instead of showInline* and display:none. // TODO(jhawkins): Use hidden instead of showInline* and display:none.
// TODO(hcarmona): This file is big: it may be good to split it up.
/** /**
* The type of the download object. The definition is based on * The type of the download object. The definition is based on
...@@ -76,6 +77,103 @@ function createButton(onclick, value) { ...@@ -76,6 +77,103 @@ function createButton(onclick, value) {
return button; return button;
} }
///////////////////////////////////////////////////////////////////////////////
// DownloadFocusRow:
/**
* Provides an implementation for a single column grid.
* @constructor
* @extends {cr.ui.FocusRow}
*/
function DownloadFocusRow() {}
/**
* Decorates |focusRow| so that it can be treated as a DownloadFocusRow.
* @param {Element} focusRow The element that has all the columns represented
* by |download|.
* @param {Download} download The Download representing this row.
* @param {Node} boundary Focus events are ignored outside of this node.
*/
DownloadFocusRow.decorate = function(focusRow, download, boundary) {
focusRow.__proto__ = DownloadFocusRow.prototype;
focusRow.decorate(boundary);
// Add all clickable elements as a row into the grid.
focusRow.addElementIfFocusable_(download.nodeFileLink_, 'name');
focusRow.addElementIfFocusable_(download.nodeURL_, 'url');
focusRow.addElementIfFocusable_(download.controlShow_, 'show');
focusRow.addElementIfFocusable_(download.controlRetry_, 'retry');
focusRow.addElementIfFocusable_(download.controlPause_, 'pause');
focusRow.addElementIfFocusable_(download.controlResume_, 'resume');
focusRow.addElementIfFocusable_(download.controlRemove_, 'remove');
focusRow.addElementIfFocusable_(download.controlCancel_, 'cancel');
focusRow.addElementIfFocusable_(download.malwareSave_, 'save');
focusRow.addElementIfFocusable_(download.dangerSave_, 'save');
focusRow.addElementIfFocusable_(download.malwareDiscard_, 'discard');
focusRow.addElementIfFocusable_(download.dangerDiscard_, 'discard');
focusRow.addElementIfFocusable_(download.controlByExtensionLink_,
'extension');
};
DownloadFocusRow.prototype = {
__proto__: cr.ui.FocusRow.prototype,
/** @override */
getEquivalentElement: function(element) {
if (this.contains(element))
return element;
// All elements default to another element with the same type.
var columnType = element.getAttribute('column-type');
var equivalent = this.querySelector('[column-type=' + columnType + ']');
if (!equivalent) {
var equivalentTypes =
['show', 'retry', 'pause', 'resume', 'remove', 'cancel'];
if (equivalentTypes.indexOf(columnType) != -1) {
var allTypes = equivalentTypes.map(function(type) {
return '[column-type=' + type + ']';
}).join(', ');
equivalent = this.querySelector(allTypes);
}
}
// Return the first focusable element if no equivalent type is found.
return equivalent || this.focusableElements[0];
},
/**
* @param {Element} element The element that should be added.
* @param {string} type The column type to use for the element.
* @private
*/
addElementIfFocusable_: function(element, type) {
if (this.shouldFocus_(element)) {
this.addFocusableElement(element);
element.setAttribute('column-type', type);
}
},
/**
* Determines if element should be focusable.
* @param {!Element} element
* @return {boolean}
* @private
*/
shouldFocus_: function(element) {
if (!element)
return false;
// Hidden elements are not focusable.
var style = window.getComputedStyle(element);
if (style.visibility == 'hidden' || style.display == 'none')
return false;
// Verify all ancestors are focusable.
return !element.parentElement || this.shouldFocus_(element.parentElement);
},
};
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// Downloads // Downloads
/** /**
...@@ -91,6 +189,7 @@ function Downloads() { ...@@ -91,6 +189,7 @@ function Downloads() {
this.node_ = $('downloads-display'); this.node_ = $('downloads-display');
this.summary_ = $('downloads-summary-text'); this.summary_ = $('downloads-summary-text');
this.searchText_ = ''; this.searchText_ = '';
this.focusGrid_ = new cr.ui.FocusGrid();
// Keep track of the dates of the newest and oldest downloads so that we // Keep track of the dates of the newest and oldest downloads so that we
// know where to insert them. // know where to insert them.
...@@ -176,6 +275,29 @@ Downloads.prototype.updateResults = function() { ...@@ -176,6 +275,29 @@ Downloads.prototype.updateResults = function() {
if (loadTimeData.getBoolean('allow_deleting_history')) if (loadTimeData.getBoolean('allow_deleting_history'))
$('clear-all').hidden = !hasDownloads || this.searchText_.length > 0; $('clear-all').hidden = !hasDownloads || this.searchText_.length > 0;
this.rebuildFocusGrid_();
};
/**
* Rebuild the focusGrid_ using the elements that each download will have.
* @private
*/
Downloads.prototype.rebuildFocusGrid_ = function() {
this.focusGrid_.destroy();
var keys = Object.keys(this.downloads_);
for (var i = 0; i < keys.length; ++i) {
var download = this.downloads_[keys[i]];
DownloadFocusRow.decorate(download.node, download, this.node_);
}
// The ordering of the keys is not guaranteed, and downloads should be added
// to the FocusGrid in the order they will be in the UI.
var downloads = document.querySelectorAll('.download');
for (var i = 0; i < downloads.length; ++i) {
this.focusGrid_.addRow(downloads[i]);
}
}; };
/** /**
......
...@@ -250,6 +250,10 @@ Visit.prototype.getResultDOM = function(propertyBag) { ...@@ -250,6 +250,10 @@ Visit.prototype.getResultDOM = function(propertyBag) {
e.preventDefault(); e.preventDefault();
}.bind(this)); }.bind(this));
} }
if (focusless)
bookmarkSection.tabIndex = -1;
entryBox.appendChild(bookmarkSection); entryBox.appendChild(bookmarkSection);
var visitEntryWrapper = /** @type {HTMLElement} */( var visitEntryWrapper = /** @type {HTMLElement} */(
...@@ -884,69 +888,87 @@ HistoryModel.prototype.getGroupByDomain = function() { ...@@ -884,69 +888,87 @@ HistoryModel.prototype.getGroupByDomain = function() {
}; };
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// HistoryFocusObserver: // HistoryFocusRow:
/** /**
* Provides an implementation for a single column grid.
* @constructor * @constructor
* @implements {cr.ui.FocusRow.Observer} * @extends {cr.ui.FocusRow}
*/
function HistoryFocusRow() {}
/**
* Decorates |rowElement| so that it can be treated as a HistoryFocusRow item.
* @param {Element} rowElement The element representing this row.
* @param {Node} boundary Focus events are ignored outside of this node.
*/ */
function HistoryFocusObserver() {} HistoryFocusRow.decorate = function(rowElement, boundary) {
rowElement.__proto__ = HistoryFocusRow.prototype;
rowElement.decorate(boundary);
rowElement.addElementIfPresent_('.entry-box input', 'checkbox');
rowElement.addElementIfPresent_('.domain-checkbox', 'checkbox');
rowElement.addElementIfPresent_('.bookmark-section.starred', 'star');
rowElement.addElementIfPresent_('[is="action-link"]', 'domain');
rowElement.addElementIfPresent_('.title a', 'title');
rowElement.addElementIfPresent_('.drop-down', 'menu');
};
HistoryFocusRow.prototype = {
__proto__: cr.ui.FocusRow.prototype,
HistoryFocusObserver.prototype = {
/** @override */ /** @override */
onActivate: function(row) { onActiveStateChanged: function(state) {
this.getActiveRowElement_(row).classList.add('active'); this.classList.toggle('active', state);
}, },
/** @override */ /** @override */
onDeactivate: function(row) { getEquivalentElement: function(element) {
this.getActiveRowElement_(row).classList.remove('active'); if (this.contains(element))
return element;
// All elements default to another element with the same type.
var equivalent = this.getColumn_(element.getAttribute('column-type'));
if (!equivalent) {
switch (element.getAttribute('column-type')) {
case 'star':
equivalent = this.getColumn_('title') || this.getColumn_('domain');
break;
case 'domain':
equivalent = this.getColumn_('title');
break;
case 'title':
equivalent = this.getColumn_('domain');
break;
case 'menu':
return this.focusableElements[this.focusableElements.length - 1];
}
}
return equivalent || this.focusableElements[0];
}, },
/** /**
* @param {cr.ui.FocusRow} row The row to find an element for. * @param {string} type The type of column to return.
* @return {Element} |row|'s "active" element. * @return {?Element} The column matching the type.
* @private * @private
*/ */
getActiveRowElement_: function(row) { getColumn_: function(type) {
return findAncestorByClass(row.items[0], 'entry') || return this.querySelector('[column-type=' + type + ']');
findAncestorByClass(row.items[0], 'site-domain-wrapper');
}, },
};
/////////////////////////////////////////////////////////////////////////////// /**
// HistoryFocusGrid: * @param {string} query A query to select the appropriate element.
* @param {string} type The type to use for the element.
/** * @private
* @param {Node=} opt_boundary */
* @param {cr.ui.FocusRow.Observer=} opt_observer addElementIfPresent_: function(query, type) {
* @constructor var element = this.querySelector(query);
* @extends {cr.ui.FocusGrid} if (element) {
*/ this.addFocusableElement(element);
function HistoryFocusGrid(opt_boundary, opt_observer) { element.setAttribute('column-type', type);
cr.ui.FocusGrid.apply(this, arguments);
}
HistoryFocusGrid.prototype = {
__proto__: cr.ui.FocusGrid.prototype,
/** @override */
onMousedown: function(row, e) {
// TODO(dbeam): Can cr.ui.FocusGrid know about cr.ui.MenuButton? If so, bake
// this logic into the base class directly.
var menuButton = findAncestorByClass(e.target, 'menu-button');
if (menuButton) {
// Deactivate any other active row.
this.rows.some(function(r) {
if (r.activeIndex >= 0 && r != row) {
r.activeIndex = -1;
return true;
}
});
// Activate only the row with a pressed menu button.
row.activeIndex = row.items.indexOf(menuButton);
} }
return !!menuButton;
}, },
}; };
...@@ -964,8 +986,7 @@ function HistoryView(model) { ...@@ -964,8 +986,7 @@ function HistoryView(model) {
this.editButtonTd_ = $('edit-button'); this.editButtonTd_ = $('edit-button');
this.editingControlsDiv_ = $('editing-controls'); this.editingControlsDiv_ = $('editing-controls');
this.resultDiv_ = $('results-display'); this.resultDiv_ = $('results-display');
this.focusGrid_ = new HistoryFocusGrid(this.resultDiv_, this.focusGrid_ = new cr.ui.FocusGrid();
new HistoryFocusObserver);
this.pageDiv_ = $('results-pagination'); this.pageDiv_ = $('results-pagination');
this.model_ = model; this.model_ = model;
this.pageIndex_ = 0; this.pageIndex_ = 0;
...@@ -1197,14 +1218,14 @@ HistoryView.prototype.showNotification = function(innerHTML, isWarning) { ...@@ -1197,14 +1218,14 @@ HistoryView.prototype.showNotification = function(innerHTML, isWarning) {
HistoryView.prototype.onBeforeRemove = function(visit) { HistoryView.prototype.onBeforeRemove = function(visit) {
assert(this.currentVisits_.indexOf(visit) >= 0); assert(this.currentVisits_.indexOf(visit) >= 0);
var pos = this.focusGrid_.getPositionForTarget(document.activeElement); var rowIndex = this.focusGrid_.getRowIndexForTarget(document.activeElement);
if (!pos) if (rowIndex == -1)
return; return;
var row = this.focusGrid_.rows[pos.row + 1] || var rowToFocus = this.focusGrid_.rows[rowIndex + 1] ||
this.focusGrid_.rows[pos.row - 1]; this.focusGrid_.rows[rowIndex - 1];
if (row) if (rowToFocus)
row.focusIndex(Math.min(pos.col, row.items.length - 1)); rowToFocus.getEquivalentElement(document.activeElement).focus();
}; };
/** @param {Visit} visit The visit about to be unstarred. */ /** @param {Visit} visit The visit about to be unstarred. */
...@@ -1212,9 +1233,12 @@ HistoryView.prototype.onBeforeUnstarred = function(visit) { ...@@ -1212,9 +1233,12 @@ HistoryView.prototype.onBeforeUnstarred = function(visit) {
assert(this.currentVisits_.indexOf(visit) >= 0); assert(this.currentVisits_.indexOf(visit) >= 0);
assert(visit.bookmarkStar == document.activeElement); assert(visit.bookmarkStar == document.activeElement);
var pos = this.focusGrid_.getPositionForTarget(document.activeElement); var rowIndex = this.focusGrid_.getRowIndexForTarget(document.activeElement);
var row = this.focusGrid_.rows[pos.row]; var row = this.focusGrid_.rows[rowIndex];
row.focusIndex(Math.min(pos.col + 1, row.items.length - 1));
// Focus the title or domain when the bookmarked star is removed because the
// star will no longer be focusable.
row.querySelector('[column-type=title], [column-type=domain]').focus();
}; };
/** @param {Visit} visit The visit that was just unstarred. */ /** @param {Visit} visit The visit that was just unstarred. */
...@@ -1312,7 +1336,7 @@ HistoryView.prototype.positionNotificationBar = function() { ...@@ -1312,7 +1336,7 @@ HistoryView.prototype.positionNotificationBar = function() {
* @return {boolean} Whether |el| is in |this.focusGrid_|. * @return {boolean} Whether |el| is in |this.focusGrid_|.
*/ */
HistoryView.prototype.isInFocusGrid = function(el) { HistoryView.prototype.isInFocusGrid = function(el) {
return !!this.focusGrid_.getPositionForTarget(el); return this.focusGrid_.getRowIndexForTarget(el) != -1;
}; };
// HistoryView, private: ------------------------------------------------------ // HistoryView, private: ------------------------------------------------------
...@@ -1674,26 +1698,16 @@ var focusGridRowSelector = [ ...@@ -1674,26 +1698,16 @@ var focusGridRowSelector = [
'.site-domain-wrapper' '.site-domain-wrapper'
].join(', '); ].join(', ');
var focusGridColumnSelector = [
'.entry-box input',
'.bookmark-section.starred',
'.title a',
'.drop-down',
'.domain-checkbox',
'[is="action-link"]',
].join(', ');
/** @private */ /** @private */
HistoryView.prototype.updateFocusGrid_ = function() { HistoryView.prototype.updateFocusGrid_ = function() {
var rows = this.resultDiv_.querySelectorAll(focusGridRowSelector); var rows = this.resultDiv_.querySelectorAll(focusGridRowSelector);
var grid = []; this.focusGrid_.destroy();
for (var i = 0; i < rows.length; ++i) { for (var i = 0; i < rows.length; ++i) {
assert(rows[i].parentNode); assert(rows[i].parentNode);
grid.push(rows[i].querySelectorAll(focusGridColumnSelector)); HistoryFocusRow.decorate(rows[i], this.resultDiv_);
this.focusGrid_.addRow(rows[i]);
} }
this.focusGrid_.setGrid(grid);
}; };
/** /**
......
...@@ -426,6 +426,33 @@ DevicesView.prototype.increaseRowHeight = function(row, height) { ...@@ -426,6 +426,33 @@ DevicesView.prototype.increaseRowHeight = function(row, height) {
// DevicesView, Private ------------------------------------------------------- // DevicesView, Private -------------------------------------------------------
/**
* Provides an implementation for a single column grid.
* @constructor
* @extends {cr.ui.FocusRow}
*/
function DevicesViewFocusRow() {}
/**
* Decorates |rowElement| so that it can be treated as a DevicesViewFocusRow.
* @param {Element} rowElement The element representing this row.
* @param {Node} boundary Focus events are ignored outside of this node.
*/
DevicesViewFocusRow.decorate = function(rowElement, boundary) {
rowElement.__proto__ = DevicesViewFocusRow.prototype;
rowElement.decorate(boundary);
rowElement.addFocusableElement(rowElement);
};
DevicesViewFocusRow.prototype = {
__proto__: cr.ui.FocusRow.prototype,
/** @override */
getEquivalentElement: function(element) {
return this;
},
};
/** /**
* Update the page with results. * Update the page with results.
* @private * @private
...@@ -479,16 +506,17 @@ DevicesView.prototype.displayResults_ = function() { ...@@ -479,16 +506,17 @@ DevicesView.prototype.displayResults_ = function() {
this.focusGrids_.forEach(function(grid) { grid.destroy(); }); this.focusGrids_.forEach(function(grid) { grid.destroy(); });
this.focusGrids_.length = 0; this.focusGrids_.length = 0;
var singleColumn = function(e) { return [e]; };
var devices = this.resultDiv_.querySelectorAll('.device-contents'); var devices = this.resultDiv_.querySelectorAll('.device-contents');
for (var i = 0; i < devices.length; ++i) { for (var i = 0; i < devices.length; ++i) {
var rows = devices[i].querySelectorAll('.device-tab-entry, button'); var rows = devices[i].querySelectorAll('.device-tab-entry, button');
if (!rows.length) if (!rows.length)
continue; continue;
var grid = new cr.ui.FocusGrid(devices[i]); var grid = new cr.ui.FocusGrid();
grid.setGrid(Array.prototype.map.call(rows, singleColumn)); for (var i = 0; i < rows.length; ++i) {
DevicesViewFocusRow.decorate(rows[i], devices[i]);
grid.addRow(rows[i]);
}
this.focusGrids_.push(grid); this.focusGrids_.push(grid);
} }
}; };
......
...@@ -787,7 +787,7 @@ TEST_F('HistoryWebUIRealBackendTest', 'basic', function() { ...@@ -787,7 +787,7 @@ TEST_F('HistoryWebUIRealBackendTest', 'basic', function() {
TEST_F('HistoryWebUIRealBackendTest', 'atLeastOneFocusable', function() { TEST_F('HistoryWebUIRealBackendTest', 'atLeastOneFocusable', function() {
var results = document.querySelectorAll('#results-display [tabindex="0"]'); var results = document.querySelectorAll('#results-display [tabindex="0"]');
expectEquals(1, results.length); expectGE(results.length, 1);
testDone(); testDone();
}); });
...@@ -1004,7 +1004,7 @@ TEST_F('HistoryWebUIDeleteProhibitedTest', 'deleteProhibited', function() { ...@@ -1004,7 +1004,7 @@ TEST_F('HistoryWebUIDeleteProhibitedTest', 'deleteProhibited', function() {
TEST_F('HistoryWebUIDeleteProhibitedTest', 'atLeastOneFocusable', function() { TEST_F('HistoryWebUIDeleteProhibitedTest', 'atLeastOneFocusable', function() {
var results = document.querySelectorAll('#results-display [tabindex="0"]'); var results = document.querySelectorAll('#results-display [tabindex="0"]');
expectEquals(1, results.length); expectGE(results.length, 1);
testDone(); testDone();
}); });
......
...@@ -18,28 +18,58 @@ cr.define('cr.ui', function() { ...@@ -18,28 +18,58 @@ cr.define('cr.ui', function() {
* focusable [focused] focusable (row: 1, col: 1) * focusable [focused] focusable (row: 1, col: 1)
* focusable focusable focusable * focusable focusable focusable
* *
* And pressing right at this point would move the focus to: * And pressing right or tab at this point would move the focus to:
* *
* focusable focusable focusable * focusable focusable focusable
* focusable focusable [focused] (row: 1, col: 2) * focusable focusable [focused] (row: 1, col: 2)
* focusable focusable focusable * focusable focusable focusable
* *
* @param {Node=} opt_boundary Ignore focus events outside this node.
* @param {cr.ui.FocusRow.Observer=} opt_observer An observer of rows.
* @implements {cr.ui.FocusRow.Delegate}
* @constructor * @constructor
*/ */
function FocusGrid(opt_boundary, opt_observer) { function FocusGrid() {
/** @type {Node|undefined} */
this.boundary_ = opt_boundary;
/** @private {cr.ui.FocusRow.Observer|undefined} */
this.observer_ = opt_observer;
/** @type {!Array.<!cr.ui.FocusRow>} */ /** @type {!Array.<!cr.ui.FocusRow>} */
this.rows = []; this.rows = [];
/** @private {!EventTracker} */
this.eventTracker_ = new EventTracker;
this.eventTracker_.add(cr.doc, 'keydown', this.onKeydown_.bind(this));
this.eventTracker_.add(cr.doc, 'focusin', this.onFocusin_.bind(this));
/** @private {cr.ui.FocusRow.Delegate} */
this.delegate_ = new FocusGrid.RowDelegate(this);
} }
/**
* Row delegate to overwrite the behavior of a mouse click to deselect any row
* that wasn't clicked.
* @param {cr.ui.FocusGrid} focusGrid
* @implements {cr.ui.FocusRow.Delegate}
*/
FocusGrid.RowDelegate = function(focusGrid) {
/** @private {cr.ui.FocusGrid} */
this.focusGrid_ = focusGrid;
};
FocusGrid.RowDelegate.prototype = {
/** @override */
onKeydown: function(row, e) { return false; },
/** @override */
onMousedown: function(row, e) {
// Only care about left mouse click.
if (e.button)
return false;
// Only the clicked row should be active.
this.focusGrid_.rows.forEach(function(row) {
row.makeRowActive(row.contains(e.target));
});
e.preventDefault();
return true;
},
};
FocusGrid.prototype = { FocusGrid.prototype = {
/** /**
* Unregisters event handlers and removes all |this.rows|. * Unregisters event handlers and removes all |this.rows|.
...@@ -51,22 +81,25 @@ cr.define('cr.ui', function() { ...@@ -51,22 +81,25 @@ cr.define('cr.ui', function() {
/** /**
* @param {EventTarget} target A target item to find in this grid. * @param {EventTarget} target A target item to find in this grid.
* @return {?{row: number, col: number}} A position or null if not found. * @return {number} The row index. -1 if not found.
*/ */
getPositionForTarget: function(target) { getRowIndexForTarget: function(target) {
for (var i = 0; i < this.rows.length; ++i) { for (var i = 0; i < this.rows.length; ++i) {
for (var j = 0; j < this.rows[i].items.length; ++j) { if (this.rows[i].contains(target))
if (target == this.rows[i].items[j]) return i;
return {row: i, col: j};
}
} }
return null; return -1;
}, },
/** @override */ /**
onKeydown: function(keyRow, e) { * Handles keyboard shortcuts to move up/down in the grid.
var rowIndex = this.rows.indexOf(keyRow); * @param {Event} e The key event.
assert(rowIndex >= 0); * @private
*/
onKeydown_: function(e) {
var rowIndex = this.getRowIndexForTarget(e.target);
if (rowIndex == -1)
return;
var row = -1; var row = -1;
...@@ -79,35 +112,53 @@ cr.define('cr.ui', function() { ...@@ -79,35 +112,53 @@ cr.define('cr.ui', function() {
else if (e.keyIdentifier == 'PageDown') else if (e.keyIdentifier == 'PageDown')
row = this.rows.length - 1; row = this.rows.length - 1;
if (!this.rows[row]) var rowToFocus = this.rows[row];
return false; if (rowToFocus) {
this.ignoreFocusChange_ = true;
var colIndex = keyRow.items.indexOf(e.target); rowToFocus.getEquivalentElement(this.lastFocused).focus();
var col = Math.min(colIndex, this.rows[row].items.length - 1); e.preventDefault();
}
this.rows[row].focusIndex(col);
e.preventDefault();
return true;
}, },
/** @override */ /**
onMousedown: function(row, e) { * Keep track of the last column that the user manually focused.
return false; * @param {Event} The focusin event.
* @private
*/
onFocusin_: function(e) {
if (this.ignoreFocusChange_) {
this.ignoreFocusChange_ = false;
return;
}
if (this.getRowIndexForTarget(e.target) != -1)
this.lastFocused = e.target;
}, },
/** /**
* @param {!Array.<!NodeList|!Array.<!Element>>} grid A 2D array of nodes. * Add a FocusRow to this grid. This needs to be called AFTER adding columns
* to the row. This is so that TAB focus can be properly enabled in the
* columns.
* @param {cr.ui.FocusRow} row The row that needs to be added to this grid.
*/ */
setGrid: function(grid) { addRow: function(row) {
this.destroy(); row.delegate = row.delegate || this.delegate_;
this.rows = grid.map(function(row) { if (this.rows.length == 0) {
return new cr.ui.FocusRow(row, this.boundary_, this, this.observer_); // The first row should be active if no other row is focused.
}, this); row.makeRowActive(true);
} else if (row.contains(document.activeElement)) {
// The current row should be made active if it's the activeElement.
row.makeRowActive(true);
// Deactivate the first row.
this.rows[0].makeRowActive(false);
} else {
// All other rows should be inactive.
row.makeRowActive(false);
}
if (!this.getPositionForTarget(document.activeElement) && this.rows[0]) // Add the row after its initial focus is set.
this.rows[0].activeIndex = 0; this.rows.push(row);
}, },
}; };
......
...@@ -11,11 +11,13 @@ cr.define('cr.ui', function() { ...@@ -11,11 +11,13 @@ cr.define('cr.ui', function() {
* *
* One could create a FocusRow by doing: * One could create a FocusRow by doing:
* *
* new cr.ui.FocusRow([checkboxEl, labelEl, buttonEl]) * var focusRow = new cr.ui.FocusRow(rowBoundary, rowEl);
* *
* if there are references to each node or querying them from the DOM like so: * focusRow.addFocusableElement(checkboxEl);
* focusRow.addFocusableElement(labelEl);
* focusRow.addFocusableElement(buttonEl);
* *
* new cr.ui.FocusRow(dialog.querySelectorAll('list input[type=checkbox]')) * focusRow.setInitialFocusability(true);
* *
* Pressing left cycles backward and pressing right cycles forward in item * Pressing left cycles backward and pressing right cycles forward in item
* order. Pressing Home goes to the beginning of the list and End goes to the * order. Pressing Home goes to the beginning of the list and End goes to the
...@@ -23,57 +25,20 @@ cr.define('cr.ui', function() { ...@@ -23,57 +25,20 @@ cr.define('cr.ui', function() {
* *
* If an item in this row is focused, it'll stay active (accessible via tab). * If an item in this row is focused, it'll stay active (accessible via tab).
* If no items in this row are focused, the row can stay active until focus * If no items in this row are focused, the row can stay active until focus
* changes to a node inside |this.boundary_|. If opt_boundary isn't * changes to a node inside |this.boundary_|. If |boundary| isn't specified,
* specified, any focus change deactivates the row. * any focus change deactivates the row.
* *
* @param {!Array.<!Element>|!NodeList} items Elements to track focus of.
* @param {Node=} opt_boundary Focus events are ignored outside of this node.
* @param {FocusRow.Delegate=} opt_delegate A delegate to handle key events.
* @param {FocusRow.Observer=} opt_observer An observer that's notified if
* this focus row is added to or removed from the focus order.
* @constructor * @constructor
*/ */
function FocusRow(items, opt_boundary, opt_delegate, opt_observer) { function FocusRow() {}
/** @type {!Array.<!Element>} */
this.items = Array.prototype.slice.call(items);
assert(this.items.length > 0);
/** @type {!Node} */
this.boundary_ = opt_boundary || document;
/** @private {cr.ui.FocusRow.Delegate|undefined} */
this.delegate_ = opt_delegate;
/** @private {cr.ui.FocusRow.Observer|undefined} */
this.observer_ = opt_observer;
/** @private {!EventTracker} */
this.eventTracker_ = new EventTracker;
this.eventTracker_.add(cr.doc, 'focusin', this.onFocusin_.bind(this));
this.eventTracker_.add(cr.doc, 'keydown', this.onKeydown_.bind(this));
this.items.forEach(function(item) {
if (item != document.activeElement)
item.tabIndex = -1;
this.eventTracker_.add(item, 'mousedown', this.onMousedown_.bind(this));
}, this);
/**
* The index that should be actively participating in the page tab order.
* @type {number}
* @private
*/
this.activeIndex_ = this.items.indexOf(document.activeElement);
}
/** @interface */ /** @interface */
FocusRow.Delegate = function() {}; FocusRow.Delegate = function() {};
FocusRow.Delegate.prototype = { FocusRow.Delegate.prototype = {
/** /**
* Called when a key is pressed while an item in |this.items| is focused. If * Called when a key is pressed while an item in |this.focusableElements| is
* |e|'s default is prevented, further processing is skipped. * focused. If |e|'s default is prevented, further processing is skipped.
* @param {cr.ui.FocusRow} row The row that detected a keydown. * @param {cr.ui.FocusRow} row The row that detected a keydown.
* @param {Event} e * @param {Event} e
* @return {boolean} Whether the event was handled. * @return {boolean} Whether the event was handled.
...@@ -88,58 +53,89 @@ cr.define('cr.ui', function() { ...@@ -88,58 +53,89 @@ cr.define('cr.ui', function() {
onMousedown: assertNotReached, onMousedown: assertNotReached,
}; };
/** @interface */ FocusRow.prototype = {
FocusRow.Observer = function() {}; __proto__: HTMLDivElement.prototype,
FocusRow.Observer.prototype = {
/** /**
* Called when the row is activated (added to the focus order). * Should be called in the constructor to decorate |this|.
* @param {cr.ui.FocusRow} row The row added to the focus order. * @param {Node} boundary Focus events are ignored outside of this node.
* @param {FocusRow.Delegate=} opt_delegate A delegate to handle key events.
*/ */
onActivate: assertNotReached, decorate: function(boundary, opt_delegate) {
/** @private {!Node} */
this.boundary_ = boundary || document;
/** /** @type {cr.ui.FocusRow.Delegate|undefined} */
* Called when the row is deactivated (removed from the focus order). this.delegate = opt_delegate;
* @param {cr.ui.FocusRow} row The row removed from the focus order.
*/
onDeactivate: assertNotReached,
};
FocusRow.prototype = { /** @type {Array<Element>} */
get activeIndex() { this.focusableElements = [];
return this.activeIndex_;
/** @private {!EventTracker} */
this.eventTracker_ = new EventTracker;
this.eventTracker_.add(cr.doc, 'focusin', this.onFocusin_.bind(this));
this.eventTracker_.add(cr.doc, 'keydown', this.onKeydown_.bind(this));
}, },
set activeIndex(index) {
var wasActive = this.items[this.activeIndex_];
if (wasActive)
wasActive.tabIndex = -1;
this.items.forEach(function(item) { assert(item.tabIndex == -1); }); /**
this.activeIndex_ = index; * Called when the row's active state changes and it is added/removed from
* the focus order.
* @param {boolean} state Whether the row has become active or inactive.
*/
onActiveStateChanged: function(state) {},
if (this.items[index]) /**
this.items[index].tabIndex = 0; * Find the element that best matches |sampleElement|.
* @param {Element} sampleElement An element from a row of the same type
* which previously held focus.
* @return {!Element} The element that best matches sampleElement.
*/
getEquivalentElement: assertNotReached,
if (!this.observer_) /**
* Add an element to this FocusRow. No-op if |element| is not provided.
* @param {Element} element The element that should be added.
*/
addFocusableElement: function(element) {
if (!element)
return; return;
var isActive = index >= 0 && index < this.items.length; assert(this.focusableElements.indexOf(element) == -1);
if (isActive == !!wasActive) assert(this.contains(element));
return;
if (isActive) this.focusableElements.push(element);
this.observer_.onActivate(this); this.eventTracker_.add(element, 'mousedown',
else this.onMousedown_.bind(this));
this.observer_.onDeactivate(this);
}, },
/** /**
* Focuses the item at |index|. * Called when focus changes to activate/deactivate the row. Focus is
* @param {number} index An index to focus. Must be between 0 and * removed from the row when |element| is not in the FocusRow.
* this.items.length - 1. * @param {Element} element The element that has focus. null if focus should
* be removed.
* @private
*/
onFocusChange_: function(element) {
var isActive = this.contains(element);
var wasActive = this.classList.contains('focus-row-active');
// Only send events if the active state is different for the row.
if (isActive != wasActive)
this.makeRowActive(isActive);
},
/**
* Enables/disables the tabIndex of the focusable elements in the FocusRow.
* tabIndex can be set properly.
* @param {boolean} active True if tab is allowed for this row.
*/ */
focusIndex: function(index) { makeRowActive: function(active) {
this.items[index].focus(); this.focusableElements.forEach(function(element) {
element.tabIndex = active ? 0 : -1;
});
this.classList.toggle('focus-row-active', active);
this.onActiveStateChanged(active);
}, },
/** Call this to clean up event handling before dereferencing. */ /** Call this to clean up event handling before dereferencing. */
...@@ -153,37 +149,38 @@ cr.define('cr.ui', function() { ...@@ -153,37 +149,38 @@ cr.define('cr.ui', function() {
*/ */
onFocusin_: function(e) { onFocusin_: function(e) {
if (this.boundary_.contains(assertInstanceof(e.target, Node))) if (this.boundary_.contains(assertInstanceof(e.target, Node)))
this.activeIndex = this.items.indexOf(e.target); this.onFocusChange_(e.target);
}, },
/** /**
* @param {Event} e A focus event. * Handles a keypress for an element in this FocusRow.
* @param {Event} e The keydown event.
* @private * @private
*/ */
onKeydown_: function(e) { onKeydown_: function(e) {
var item = this.items.indexOf(e.target); if (!this.contains(e.target))
if (item < 0)
return; return;
if (this.delegate_ && this.delegate_.onKeydown(this, e)) if (this.delegate && this.delegate.onKeydown(this, e))
return; return;
var elementIndex = this.focusableElements.indexOf(e.target);
var index = -1; var index = -1;
if (e.keyIdentifier == 'Left') if (e.keyIdentifier == 'Left')
index = item + (isRTL() ? 1 : -1); index = elementIndex + (isRTL() ? 1 : -1);
else if (e.keyIdentifier == 'Right') else if (e.keyIdentifier == 'Right')
index = item + (isRTL() ? -1 : 1); index = elementIndex + (isRTL() ? -1 : 1);
else if (e.keyIdentifier == 'Home') else if (e.keyIdentifier == 'Home')
index = 0; index = 0;
else if (e.keyIdentifier == 'End') else if (e.keyIdentifier == 'End')
index = this.items.length - 1; index = this.focusableElements.length - 1;
if (!this.items[index])
return;
this.focusIndex(index); var elementToFocus = this.focusableElements[index];
e.preventDefault(); if (elementToFocus) {
this.getEquivalentElement(elementToFocus).focus();
e.preventDefault();
}
}, },
/** /**
...@@ -191,11 +188,14 @@ cr.define('cr.ui', function() { ...@@ -191,11 +188,14 @@ cr.define('cr.ui', function() {
* @private * @private
*/ */
onMousedown_: function(e) { onMousedown_: function(e) {
if (this.delegate_ && this.delegate_.onMousedown(this, e)) if (this.delegate && this.delegate.onMousedown(this, e))
return; return;
if (!e.button) // Only accept the left mouse click.
this.activeIndex = this.items.indexOf(e.currentTarget); if (!e.button) {
// Focus this row if the target is one of the elements in this row.
this.onFocusChange_(e.target);
}
}, },
}; };
......
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