blob: 26c4c5567e2b673f24b19574c2b35afe6c69263f [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
cr.define('cr.ui', function() {
/**
* A class to manage focus between given horizontally arranged elements.
* For example, given the page:
*
* <input type="checkbox"> <label>Check me!</label> <button>X</button>
*
* One could create a FocusRow by doing:
*
* new cr.ui.FocusRow([checkboxEl, labelEl, buttonEl])
*
* if there are references to each node or querying them from the DOM like so:
*
* new cr.ui.FocusRow(dialog.querySelectorAll('list input[type=checkbox]'))
*
* 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
* end of the list.
*
* 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.
*
* @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);
}
/** @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.
* @param {cr.ui.FocusRow} row The row that detected a keydown.
* @param {Event} e
* @return {boolean} Whether the event was handled.
*/
onKeydown: assertNotReached,
/**
* @param {cr.ui.FocusRow} row The row that detected the mouse going down.
* @param {Event} e
* @return {boolean} Whether the event was handled.
*/
onMousedown: assertNotReached,
};
/** @interface */
FocusRow.Observer = function() {};
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.
*/
onActivate: assertNotReached,
/**
* 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,
};
FocusRow.prototype = {
get activeIndex() {
return this.activeIndex_;
},
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;
if (this.items[index])
this.items[index].tabIndex = 0;
if (!this.observer_)
return;
var isActive = index >= 0 && index < this.items.length;
if (isActive == !!wasActive)
return;
if (isActive)
this.observer_.onActivate(this);
else
this.observer_.onDeactivate(this);
},
/**
* Focuses the item at |index|.
* @param {number} index An index to focus. Must be between 0 and
* this.items.length - 1.
*/
focusIndex: function(index) {
this.items[index].focus();
},
/** Call this to clean up event handling before dereferencing. */
destroy: function() {
this.eventTracker_.removeAll();
},
/**
* @param {Event} e The focusin event.
* @private
*/
onFocusin_: function(e) {
if (this.boundary_.contains(assertInstanceof(e.target, Node)))
this.activeIndex = this.items.indexOf(e.target);
},
/**
* @param {Event} e A focus event.
* @private
*/
onKeydown_: function(e) {
var item = this.items.indexOf(e.target);
if (item < 0)
return;
if (this.delegate_ && this.delegate_.onKeydown(this, e))
return;
var index = -1;
if (e.keyIdentifier == 'Left')
index = item + (isRTL() ? 1 : -1);
else if (e.keyIdentifier == 'Right')
index = item + (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;
this.focusIndex(index);
e.preventDefault();
},
/**
* @param {Event} e A click event.
* @private
*/
onMousedown_: function(e) {
if (this.delegate_ && this.delegate_.onMousedown(this, e))
return;
if (!e.button)
this.activeIndex = this.items.indexOf(e.currentTarget);
},
};
return {
FocusRow: FocusRow,
};
});