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 @@
position: relative;
}
.focus-row-active {
background-color: rgba(0, 0, 0, .05);
}
.download,
#no-downloads-or-results {
margin-top: 6px;
......
......@@ -12,6 +12,10 @@
<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/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>
</head>
<body>
......
......@@ -3,6 +3,7 @@
// found in the LICENSE file.
// 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
......@@ -76,6 +77,103 @@ function createButton(onclick, value) {
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
/**
......@@ -91,6 +189,7 @@ function Downloads() {
this.node_ = $('downloads-display');
this.summary_ = $('downloads-summary-text');
this.searchText_ = '';
this.focusGrid_ = new cr.ui.FocusGrid();
// Keep track of the dates of the newest and oldest downloads so that we
// know where to insert them.
......@@ -176,6 +275,29 @@ Downloads.prototype.updateResults = function() {
if (loadTimeData.getBoolean('allow_deleting_history'))
$('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) {
e.preventDefault();
}.bind(this));
}
if (focusless)
bookmarkSection.tabIndex = -1;
entryBox.appendChild(bookmarkSection);
var visitEntryWrapper = /** @type {HTMLElement} */(
......@@ -884,69 +888,87 @@ HistoryModel.prototype.getGroupByDomain = function() {
};
///////////////////////////////////////////////////////////////////////////////
// HistoryFocusObserver:
// HistoryFocusRow:
/**
* Provides an implementation for a single column grid.
* @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 */
onActivate: function(row) {
this.getActiveRowElement_(row).classList.add('active');
onActiveStateChanged: function(state) {
this.classList.toggle('active', state);
},
/** @override */
onDeactivate: function(row) {
this.getActiveRowElement_(row).classList.remove('active');
getEquivalentElement: function(element) {
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.
* @return {Element} |row|'s "active" element.
* @param {string} type The type of column to return.
* @return {?Element} The column matching the type.
* @private
*/
getActiveRowElement_: function(row) {
return findAncestorByClass(row.items[0], 'entry') ||
findAncestorByClass(row.items[0], 'site-domain-wrapper');
getColumn_: function(type) {
return this.querySelector('[column-type=' + type + ']');
},
};
///////////////////////////////////////////////////////////////////////////////
// HistoryFocusGrid:
/**
* @param {Node=} opt_boundary
* @param {cr.ui.FocusRow.Observer=} opt_observer
* @constructor
* @extends {cr.ui.FocusGrid}
*/
function HistoryFocusGrid(opt_boundary, opt_observer) {
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);
/**
* @param {string} query A query to select the appropriate element.
* @param {string} type The type to use for the element.
* @private
*/
addElementIfPresent_: function(query, type) {
var element = this.querySelector(query);
if (element) {
this.addFocusableElement(element);
element.setAttribute('column-type', type);
}
return !!menuButton;
},
};
......@@ -964,8 +986,7 @@ function HistoryView(model) {
this.editButtonTd_ = $('edit-button');
this.editingControlsDiv_ = $('editing-controls');
this.resultDiv_ = $('results-display');
this.focusGrid_ = new HistoryFocusGrid(this.resultDiv_,
new HistoryFocusObserver);
this.focusGrid_ = new cr.ui.FocusGrid();
this.pageDiv_ = $('results-pagination');
this.model_ = model;
this.pageIndex_ = 0;
......@@ -1197,14 +1218,14 @@ HistoryView.prototype.showNotification = function(innerHTML, isWarning) {
HistoryView.prototype.onBeforeRemove = function(visit) {
assert(this.currentVisits_.indexOf(visit) >= 0);
var pos = this.focusGrid_.getPositionForTarget(document.activeElement);
if (!pos)
var rowIndex = this.focusGrid_.getRowIndexForTarget(document.activeElement);
if (rowIndex == -1)
return;
var row = this.focusGrid_.rows[pos.row + 1] ||
this.focusGrid_.rows[pos.row - 1];
if (row)
row.focusIndex(Math.min(pos.col, row.items.length - 1));
var rowToFocus = this.focusGrid_.rows[rowIndex + 1] ||
this.focusGrid_.rows[rowIndex - 1];
if (rowToFocus)
rowToFocus.getEquivalentElement(document.activeElement).focus();
};
/** @param {Visit} visit The visit about to be unstarred. */
......@@ -1212,9 +1233,12 @@ HistoryView.prototype.onBeforeUnstarred = function(visit) {
assert(this.currentVisits_.indexOf(visit) >= 0);
assert(visit.bookmarkStar == document.activeElement);
var pos = this.focusGrid_.getPositionForTarget(document.activeElement);
var row = this.focusGrid_.rows[pos.row];
row.focusIndex(Math.min(pos.col + 1, row.items.length - 1));
var rowIndex = this.focusGrid_.getRowIndexForTarget(document.activeElement);
var row = this.focusGrid_.rows[rowIndex];
// 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. */
......@@ -1312,7 +1336,7 @@ HistoryView.prototype.positionNotificationBar = function() {
* @return {boolean} Whether |el| is in |this.focusGrid_|.
*/
HistoryView.prototype.isInFocusGrid = function(el) {
return !!this.focusGrid_.getPositionForTarget(el);
return this.focusGrid_.getRowIndexForTarget(el) != -1;
};
// HistoryView, private: ------------------------------------------------------
......@@ -1674,26 +1698,16 @@ var focusGridRowSelector = [
'.site-domain-wrapper'
].join(', ');
var focusGridColumnSelector = [
'.entry-box input',
'.bookmark-section.starred',
'.title a',
'.drop-down',
'.domain-checkbox',
'[is="action-link"]',
].join(', ');
/** @private */
HistoryView.prototype.updateFocusGrid_ = function() {
var rows = this.resultDiv_.querySelectorAll(focusGridRowSelector);
var grid = [];
this.focusGrid_.destroy();
for (var i = 0; i < rows.length; ++i) {
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) {
// 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.
* @private
......@@ -479,16 +506,17 @@ DevicesView.prototype.displayResults_ = function() {
this.focusGrids_.forEach(function(grid) { grid.destroy(); });
this.focusGrids_.length = 0;
var singleColumn = function(e) { return [e]; };
var devices = this.resultDiv_.querySelectorAll('.device-contents');
for (var i = 0; i < devices.length; ++i) {
var rows = devices[i].querySelectorAll('.device-tab-entry, button');
if (!rows.length)
continue;
var grid = new cr.ui.FocusGrid(devices[i]);
grid.setGrid(Array.prototype.map.call(rows, singleColumn));
var grid = new cr.ui.FocusGrid();
for (var i = 0; i < rows.length; ++i) {
DevicesViewFocusRow.decorate(rows[i], devices[i]);
grid.addRow(rows[i]);
}
this.focusGrids_.push(grid);
}
};
......
......@@ -787,7 +787,7 @@ TEST_F('HistoryWebUIRealBackendTest', 'basic', function() {
TEST_F('HistoryWebUIRealBackendTest', 'atLeastOneFocusable', function() {
var results = document.querySelectorAll('#results-display [tabindex="0"]');
expectEquals(1, results.length);
expectGE(results.length, 1);
testDone();
});
......@@ -1004,7 +1004,7 @@ TEST_F('HistoryWebUIDeleteProhibitedTest', 'deleteProhibited', function() {
TEST_F('HistoryWebUIDeleteProhibitedTest', 'atLeastOneFocusable', function() {
var results = document.querySelectorAll('#results-display [tabindex="0"]');
expectEquals(1, results.length);
expectGE(results.length, 1);
testDone();
});
......
......@@ -18,28 +18,58 @@ cr.define('cr.ui', function() {
* focusable [focused] focusable (row: 1, col: 1)
* 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 [focused] (row: 1, col: 2)
* 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
*/
function FocusGrid(opt_boundary, opt_observer) {
/** @type {Node|undefined} */
this.boundary_ = opt_boundary;
/** @private {cr.ui.FocusRow.Observer|undefined} */
this.observer_ = opt_observer;
function FocusGrid() {
/** @type {!Array.<!cr.ui.FocusRow>} */
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 = {
/**
* Unregisters event handlers and removes all |this.rows|.
......@@ -51,22 +81,25 @@ cr.define('cr.ui', function() {
/**
* @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 j = 0; j < this.rows[i].items.length; ++j) {
if (target == this.rows[i].items[j])
return {row: i, col: j};
}
if (this.rows[i].contains(target))
return i;
}
return null;
return -1;
},
/** @override */
onKeydown: function(keyRow, e) {
var rowIndex = this.rows.indexOf(keyRow);
assert(rowIndex >= 0);
/**
* Handles keyboard shortcuts to move up/down in the grid.
* @param {Event} e The key event.
* @private
*/
onKeydown_: function(e) {
var rowIndex = this.getRowIndexForTarget(e.target);
if (rowIndex == -1)
return;
var row = -1;
......@@ -79,35 +112,53 @@ cr.define('cr.ui', function() {
else if (e.keyIdentifier == 'PageDown')
row = this.rows.length - 1;
if (!this.rows[row])
return false;
var colIndex = keyRow.items.indexOf(e.target);
var col = Math.min(colIndex, this.rows[row].items.length - 1);
this.rows[row].focusIndex(col);
e.preventDefault();
return true;
var rowToFocus = this.rows[row];
if (rowToFocus) {
this.ignoreFocusChange_ = true;
rowToFocus.getEquivalentElement(this.lastFocused).focus();
e.preventDefault();
}
},
/** @override */
onMousedown: function(row, e) {
return false;
/**
* Keep track of the last column that the user manually focused.
* @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) {
this.destroy();
this.rows = grid.map(function(row) {
return new cr.ui.FocusRow(row, this.boundary_, this, this.observer_);
}, this);
addRow: function(row) {
row.delegate = row.delegate || this.delegate_;
if (this.rows.length == 0) {
// The first row should be active if no other row is focused.
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])
this.rows[0].activeIndex = 0;
// Add the row after its initial focus is set.
this.rows.push(row);
},
};
......
......@@ -11,11 +11,13 @@ cr.define('cr.ui', function() {
*
* 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
* order. Pressing Home goes to the beginning of the list and End goes to the
......@@ -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 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
* specified, any focus change deactivates the row.
* changes to a node inside |this.boundary_|. If |boundary| isn't specified,
* 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
*/
function FocusRow(items, opt_boundary, opt_delegate, opt_observer) {
/** @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);
}
function FocusRow() {}
/** @interface */
FocusRow.Delegate = function() {};
FocusRow.Delegate.prototype = {
/**
* Called when a key is pressed while an item in |this.items| is focused. If
* |e|'s default is prevented, further processing is skipped.
* Called when a key is pressed while an item in |this.focusableElements| is
* focused. If |e|'s default is prevented, further processing is skipped.
* @param {cr.ui.FocusRow} row The row that detected a keydown.
* @param {Event} e
* @return {boolean} Whether the event was handled.
......@@ -88,58 +53,89 @@ cr.define('cr.ui', function() {
onMousedown: assertNotReached,
};
/** @interface */
FocusRow.Observer = function() {};
FocusRow.prototype = {
__proto__: HTMLDivElement.prototype,
FocusRow.Observer.prototype = {
/**
* Called when the row is activated (added to the focus order).
* @param {cr.ui.FocusRow} row The row added to the focus order.
* Should be called in the constructor to decorate |this|.
* @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;
/**
* Called when the row is deactivated (removed from the focus order).
* @param {cr.ui.FocusRow} row The row removed from the focus order.
*/
onDeactivate: assertNotReached,
};
/** @type {cr.ui.FocusRow.Delegate|undefined} */
this.delegate = opt_delegate;
FocusRow.prototype = {
get activeIndex() {
return this.activeIndex_;
/** @type {Array<Element>} */
this.focusableElements = [];
/** @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;
var isActive = index >= 0 && index < this.items.length;
if (isActive == !!wasActive)
return;
assert(this.focusableElements.indexOf(element) == -1);
assert(this.contains(element));
if (isActive)
this.observer_.onActivate(this);
else
this.observer_.onDeactivate(this);
this.focusableElements.push(element);
this.eventTracker_.add(element, 'mousedown',
this.onMousedown_.bind(this));
},
/**
* Focuses the item at |index|.
* @param {number} index An index to focus. Must be between 0 and
* this.items.length - 1.
* Called when focus changes to activate/deactivate the row. Focus is
* removed from the row when |element| is not in the FocusRow.
* @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) {
this.items[index].focus();
makeRowActive: function(active) {
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. */
......@@ -153,37 +149,38 @@ cr.define('cr.ui', function() {
*/
onFocusin_: function(e) {
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
*/
onKeydown_: function(e) {
var item = this.items.indexOf(e.target);
if (item < 0)
if (!this.contains(e.target))
return;
if (this.delegate_ && this.delegate_.onKeydown(this, e))
if (this.delegate && this.delegate.onKeydown(this, e))
return;
var elementIndex = this.focusableElements.indexOf(e.target);
var index = -1;
if (e.keyIdentifier == 'Left')
index = item + (isRTL() ? 1 : -1);
index = elementIndex + (isRTL() ? 1 : -1);
else if (e.keyIdentifier == 'Right')
index = item + (isRTL() ? -1 : 1);
index = elementIndex + (isRTL() ? -1 : 1);
else if (e.keyIdentifier == 'Home')
index = 0;
else if (e.keyIdentifier == 'End')
index = this.items.length - 1;
if (!this.items[index])
return;
index = this.focusableElements.length - 1;
this.focusIndex(index);
e.preventDefault();
var elementToFocus = this.focusableElements[index];
if (elementToFocus) {
this.getEquivalentElement(elementToFocus).focus();
e.preventDefault();
}
},
/**
......@@ -191,11 +188,14 @@ cr.define('cr.ui', function() {
* @private
*/
onMousedown_: function(e) {
if (this.delegate_ && this.delegate_.onMousedown(this, e))
if (this.delegate && this.delegate.onMousedown(this, e))
return;
if (!e.button)
this.activeIndex = this.items.indexOf(e.currentTarget);
// Only accept the left mouse click.
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