1// Copyright 2014 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5cr.define('cr.ui', function() { 6 /** 7 * A class to manage focus between given horizontally arranged elements. 8 * For example, given the page: 9 * 10 * <input type="checkbox"> <label>Check me!</label> <button>X</button> 11 * 12 * One could create a FocusRow by doing: 13 * 14 * new cr.ui.FocusRow([checkboxEl, labelEl, buttonEl]) 15 * 16 * if there are references to each node or querying them from the DOM like so: 17 * 18 * new cr.ui.FocusRow(dialog.querySelectorAll('list input[type=checkbox]')) 19 * 20 * Pressing left cycles backward and pressing right cycles forward in item 21 * order. Pressing Home goes to the beginning of the list and End goes to the 22 * end of the list. 23 * 24 * If an item in this row is focused, it'll stay active (accessible via tab). 25 * If no items in this row are focused, the row can stay active until focus 26 * changes to a node inside |this.boundary_|. If opt_boundary isn't 27 * specified, any focus change deactivates the row. 28 * 29 * @param {!Array.<!Element>|!NodeList} items Elements to track focus of. 30 * @param {Node=} opt_boundary Focus events are ignored outside of this node. 31 * @param {FocusRow.Delegate=} opt_delegate A delegate to handle key events. 32 * @param {FocusRow.Observer=} opt_observer An observer that's notified if 33 * this focus row is added to or removed from the focus order. 34 * @constructor 35 */ 36 function FocusRow(items, opt_boundary, opt_delegate, opt_observer) { 37 /** @type {!Array.<!Element>} */ 38 this.items = Array.prototype.slice.call(items); 39 assert(this.items.length > 0); 40 41 /** @type {!Node} */ 42 this.boundary_ = opt_boundary || document; 43 44 /** @private {cr.ui.FocusRow.Delegate|undefined} */ 45 this.delegate_ = opt_delegate; 46 47 /** @private {cr.ui.FocusRow.Observer|undefined} */ 48 this.observer_ = opt_observer; 49 50 /** @private {!EventTracker} */ 51 this.eventTracker_ = new EventTracker; 52 this.eventTracker_.add(cr.doc, 'focusin', this.onFocusin_.bind(this)); 53 this.eventTracker_.add(cr.doc, 'keydown', this.onKeydown_.bind(this)); 54 55 this.items.forEach(function(item) { 56 if (item != document.activeElement) 57 item.tabIndex = -1; 58 59 this.eventTracker_.add(item, 'click', this.onClick_.bind(this)); 60 }, this); 61 62 /** 63 * The index that should be actively participating in the page tab order. 64 * @type {number} 65 * @private 66 */ 67 this.activeIndex_ = this.items.indexOf(document.activeElement); 68 } 69 70 /** @interface */ 71 FocusRow.Delegate = function() {}; 72 73 FocusRow.Delegate.prototype = { 74 /** 75 * Called when a key is pressed while an item in |this.items| is focused. If 76 * |e|'s default is prevented, further processing is skipped. 77 * @param {cr.ui.FocusRow} row The row that detected a keydown. 78 * @param {Event} e The keydown event. 79 */ 80 onKeydown: assertNotReached, 81 }; 82 83 /** @interface */ 84 FocusRow.Observer = function() {}; 85 86 FocusRow.Observer.prototype = { 87 /** 88 * Called when the row is activated (added to the focus order). 89 * @param {cr.ui.FocusRow} row The row added to the focus order. 90 */ 91 onActivate: assertNotReached, 92 93 /** 94 * Called when the row is deactivated (removed from the focus order). 95 * @param {cr.ui.FocusRow} row The row removed from the focus order. 96 */ 97 onDeactivate: assertNotReached, 98 }; 99 100 FocusRow.prototype = { 101 get activeIndex() { 102 return this.activeIndex_; 103 }, 104 set activeIndex(index) { 105 var wasActive = this.items[this.activeIndex_]; 106 if (wasActive) 107 wasActive.tabIndex = -1; 108 109 this.items.forEach(function(item) { assert(item.tabIndex == -1); }); 110 this.activeIndex_ = index; 111 112 if (this.items[index]) 113 this.items[index].tabIndex = 0; 114 115 if (!this.observer_) 116 return; 117 118 var isActive = index >= 0 && index < this.items.length; 119 if (isActive == !!wasActive) 120 return; 121 122 if (isActive) 123 this.observer_.onActivate(this); 124 else 125 this.observer_.onDeactivate(this); 126 }, 127 128 /** 129 * Focuses the item at |index|. 130 * @param {number} index An index to focus. Must be between 0 and 131 * this.items.length - 1. 132 */ 133 focusIndex: function(index) { 134 this.items[index].focus(); 135 }, 136 137 /** Call this to clean up event handling before dereferencing. */ 138 destroy: function() { 139 this.eventTracker_.removeAll(); 140 }, 141 142 /** 143 * @param {Event} e The focusin event. 144 * @private 145 */ 146 onFocusin_: function(e) { 147 if (this.boundary_.contains(e.target)) 148 this.activeIndex = this.items.indexOf(e.target); 149 }, 150 151 /** 152 * @param {Event} e A focus event. 153 * @private 154 */ 155 onKeydown_: function(e) { 156 var item = this.items.indexOf(e.target); 157 if (item < 0) 158 return; 159 160 if (this.delegate_) 161 this.delegate_.onKeydown(this, e); 162 163 if (e.defaultPrevented) 164 return; 165 166 var index = -1; 167 168 if (e.keyIdentifier == 'Left') 169 index = item + (isRTL() ? 1 : -1); 170 else if (e.keyIdentifier == 'Right') 171 index = item + (isRTL() ? -1 : 1); 172 else if (e.keyIdentifier == 'Home') 173 index = 0; 174 else if (e.keyIdentifier == 'End') 175 index = this.items.length - 1; 176 177 if (!this.items[index]) 178 return; 179 180 this.focusIndex(index); 181 e.preventDefault(); 182 }, 183 184 /** 185 * @param {Event} e A click event. 186 * @private 187 */ 188 onClick_: function(e) { 189 if (!e.button) 190 this.activeIndex = this.items.indexOf(e.currentTarget); 191 }, 192 }; 193 194 return { 195 FocusRow: FocusRow, 196 }; 197}); 198