Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 // Copyright 2014 The Chromium Authors. All rights reserved. | 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 | 2 // Use of this source code is governed by a BSD-style license that can be |
| 3 // found in the LICENSE file. | 3 // found in the LICENSE file. |
| 4 | 4 |
| 5 cr.define('cr.ui', function() { | 5 cr.define('cr.ui', function() { |
| 6 /** | 6 /** |
| 7 * A class to manage focus between given horizontally arranged elements. | 7 * A class to manage focus between given horizontally arranged elements. |
| 8 * For example, given the page: | 8 * For example, given the page: |
| 9 * | 9 * |
| 10 * <input type="checkbox"> <label>Check me!</label> <button>X</button> | 10 * <input type="checkbox"> <label>Check me!</label> <button>X</button> |
| 11 * | 11 * |
| 12 * One could create a FocusRow by doing: | 12 * One could create a FocusRow by doing: |
| 13 * | 13 * |
| 14 * new cr.ui.FocusRow([checkboxEl, labelEl, buttonEl]) | 14 * new cr.ui.FocusRow([checkboxEl, labelEl, buttonEl]) |
| 15 * | 15 * |
| 16 * if there are references to each node or querying them from the DOM like so: | 16 * if there are references to each node or querying them from the DOM like so: |
| 17 * | 17 * |
| 18 * new cr.ui.FocusRow(dialog.querySelectorAll('list input[type=checkbox]')) | 18 * new cr.ui.FocusRow(dialog.querySelectorAll('list input[type=checkbox]')) |
| 19 * | 19 * |
| 20 * Pressing left cycles backward and pressing right cycles forward in item | 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 | 21 * order. Pressing Home goes to the beginning of the list and End goes to the |
| 22 * end of the list. | 22 * end of the list. |
| 23 * | 23 * |
| 24 * If an item in this row is focused, it'll stay active (accessible via tab). | 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 | 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 | 26 * changes to a node inside |this.boundary_|. If opt_boundary isn't |
| 27 * specified, any focus change deactivates the row. | 27 * specified, any focus change deactivates the row. |
| 28 * | 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. | 29 * @param {Node=} opt_boundary Focus events are ignored outside of this node. |
| 31 * @param {FocusRow.Delegate=} opt_delegate A delegate to handle key events. | 30 * @param {FocusRow.Delegate=} opt_delegate A delegate to handle key events. |
| 32 * @param {FocusRow.Observer=} opt_observer An observer that's notified if | 31 * @param {FocusRow.Observer=} opt_observer An observer that's notified if |
| 33 * this focus row is added to or removed from the focus order. | 32 * this focus row is added to or removed from the focus order. |
| 34 * @constructor | 33 * @constructor |
| 35 */ | 34 */ |
| 36 function FocusRow(items, opt_boundary, opt_delegate, opt_observer) { | 35 function FocusRow(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} */ | 36 /** @type {!Node} */ |
| 42 this.boundary_ = opt_boundary || document; | 37 this.boundary_ = opt_boundary || document; |
| 43 | 38 |
| 44 /** @private {cr.ui.FocusRow.Delegate|undefined} */ | 39 /** @private {cr.ui.FocusRow.Delegate|undefined} */ |
| 45 this.delegate_ = opt_delegate; | 40 this.delegate_ = opt_delegate; |
| 46 | 41 |
| 47 /** @private {cr.ui.FocusRow.Observer|undefined} */ | 42 /** @private {cr.ui.FocusRow.Observer} */ |
| 43 assert(opt_observer); | |
| 48 this.observer_ = opt_observer; | 44 this.observer_ = opt_observer; |
| 49 | 45 |
| 50 /** @private {!EventTracker} */ | 46 /** @private {!EventTracker} */ |
| 51 this.eventTracker_ = new EventTracker; | 47 this.eventTracker_ = new EventTracker; |
| 52 this.eventTracker_.add(cr.doc, 'focusin', this.onFocusin_.bind(this)); | 48 this.eventTracker_.add(cr.doc, 'focusin', this.onFocusin_.bind(this)); |
| 53 this.eventTracker_.add(cr.doc, 'keydown', this.onKeydown_.bind(this)); | 49 this.eventTracker_.add(cr.doc, 'keydown', this.onKeydown_.bind(this)); |
| 54 | 50 |
| 55 this.items.forEach(function(item) { | 51 /** @type {Array<string>} */ |
| 56 if (item != document.activeElement) | 52 this.elementIds = []; |
| 57 item.tabIndex = -1; | |
| 58 | 53 |
| 59 this.eventTracker_.add(item, 'mousedown', this.onMousedown_.bind(this)); | 54 /** @private {Element} */ |
| 60 }, this); | 55 this.rowElement_ = null; |
| 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 } | 56 } |
| 69 | 57 |
| 70 /** @interface */ | 58 /** @interface */ |
| 71 FocusRow.Delegate = function() {}; | 59 FocusRow.Delegate = function() {}; |
| 72 | 60 |
| 73 FocusRow.Delegate.prototype = { | 61 FocusRow.Delegate.prototype = { |
| 74 /** | 62 /** |
| 75 * Called when a key is pressed while an item in |this.items| is focused. If | 63 * Called when a key is pressed while an item in |this.getItems()| is |
| 76 * |e|'s default is prevented, further processing is skipped. | 64 * focused. If |e|'s default is prevented, further processing is skipped. |
| 77 * @param {cr.ui.FocusRow} row The row that detected a keydown. | 65 * @param {cr.ui.FocusRow} row The row that detected a keydown. |
| 78 * @param {Event} e | 66 * @param {Event} e |
| 79 * @return {boolean} Whether the event was handled. | 67 * @return {boolean} Whether the event was handled. |
| 80 */ | 68 */ |
| 81 onKeydown: assertNotReached, | 69 onKeydown: assertNotReached, |
| 82 | 70 |
| 83 /** | 71 /** |
| 84 * @param {cr.ui.FocusRow} row The row that detected the mouse going down. | 72 * @param {cr.ui.FocusRow} row The row that detected the mouse going down. |
| 85 * @param {Event} e | 73 * @param {Event} e |
| 86 * @return {boolean} Whether the event was handled. | 74 * @return {boolean} Whether the event was handled. |
| 87 */ | 75 */ |
| 88 onMousedown: assertNotReached, | 76 onMousedown: assertNotReached, |
| 89 }; | 77 }; |
| 90 | 78 |
| 91 /** @interface */ | 79 /** @interface */ |
| 92 FocusRow.Observer = function() {}; | 80 FocusRow.Observer = function() {}; |
| 93 | 81 |
| 94 FocusRow.Observer.prototype = { | 82 FocusRow.Observer.prototype = { |
| 95 /** | 83 /** |
| 96 * Called when the row is activated (added to the focus order). | 84 * Called when the row is activated (added to the focus order). |
| 97 * @param {cr.ui.FocusRow} row The row added to the focus order. | 85 * @param {cr.ui.FocusRow} row The row added to the focus order. |
| 98 */ | 86 */ |
| 99 onActivate: assertNotReached, | 87 onActivate: assertNotReached, |
| 100 | 88 |
| 101 /** | 89 /** |
| 102 * Called when the row is deactivated (removed from the focus order). | 90 * Called when the row is deactivated (removed from the focus order). |
| 103 * @param {cr.ui.FocusRow} row The row removed from the focus order. | 91 * @param {cr.ui.FocusRow} row The row removed from the focus order. |
| 104 */ | 92 */ |
| 105 onDeactivate: assertNotReached, | 93 onDeactivate: assertNotReached, |
| 94 | |
| 95 /** | |
| 96 * Called when adding rowItems to the FocusRow to determine the element that | |
| 97 * represents the row. This should return the same element regardless of the | |
| 98 * rowItem it it called on for a specific row. | |
| 99 * @param {Element} rowItem The item to find a row element for. | |
| 100 * @return {Element} |rowItem|'s row element. | |
| 101 */ | |
| 102 getRowElement: assertNotReached, | |
| 103 | |
| 104 /** | |
| 105 * Called whenever there is a change in rowElement focus and the elementId | |
| 106 * is not in the FocusRow. | |
| 107 * @param {cr.ui.FocusRow} row The row that is being focused. | |
| 108 * @param {string} expectedId The id that was not found. | |
| 109 * @return {string} The id in |row| that should be focused. | |
| 110 */ | |
| 111 onElementIdMiss: assertNotReached, | |
|
Evan Stade
2015/01/15 23:38:09
this name is super confusing to me. It should be s
hcarmona
2015/01/16 21:39:06
Done
| |
| 106 }; | 112 }; |
| 107 | 113 |
| 108 FocusRow.prototype = { | 114 FocusRow.prototype = { |
| 109 get activeIndex() { | 115 /** |
| 110 return this.activeIndex_; | 116 * @param {Element} element The element whose id is needed. |
| 111 }, | 117 * @return {?string} |element|'s elementId. null if element is not in this |
| 112 set activeIndex(index) { | 118 * FocusRow. |
| 113 var wasActive = this.items[this.activeIndex_]; | 119 */ |
| 114 if (wasActive) | 120 getElementId: function(element) { |
| 115 wasActive.tabIndex = -1; | 121 if (!this.rowElement_.contains(element)) |
| 116 | 122 return null; |
| 117 this.items.forEach(function(item) { assert(item.tabIndex == -1); }); | 123 return element.getAttribute("focus-row-element-id"); |
| 118 this.activeIndex_ = index; | 124 }, |
| 119 | 125 |
| 120 if (this.items[index]) | 126 /** |
| 121 this.items[index].tabIndex = 0; | 127 * @param {string} elementId |
| 122 | 128 * @return {?Element} The element in this FocusRow with elementId. null if |
| 123 if (!this.observer_) | 129 * not in this FocusRow. |
| 130 */ | |
| 131 getElement: function (elementId) { | |
|
Evan Stade
2015/01/15 23:38:09
nit: extra space
hcarmona
2015/01/16 21:39:06
Done.
| |
| 132 var element = this.rowElement_.querySelector('[focus-row-element-id="' + | |
| 133 elementId + '"]'); | |
| 134 | |
| 135 // Special case when the column is the row. | |
| 136 if (!element && elementId && | |
| 137 this.rowElement_.getAttribute('focus-row-element-id') == elementId) | |
| 138 return this.rowElement_; | |
| 139 | |
| 140 return element; | |
| 141 }, | |
| 142 | |
| 143 /** | |
| 144 * @return {Element} The row element that contains all focusable row items. | |
| 145 */ | |
| 146 getRowElement: function() { | |
| 147 return this.rowElement_; | |
| 148 }, | |
| 149 | |
| 150 /** | |
| 151 * @return {[Element]} An array with all row items in this row. Empty array | |
| 152 * if nothing is focusable. | |
| 153 */ | |
| 154 getItems: function() { | |
| 155 assert(this.rowElement_); | |
| 156 var items = this.rowElement_.querySelectorAll('[focus-row-element-id]'); | |
| 157 | |
| 158 // Special case when the column is the row. | |
| 159 if (items.length == 0 && | |
| 160 this.rowElement_.hasAttribute('focus-row-element-id')) | |
| 161 return [ this.rowElement_ ]; | |
| 162 | |
| 163 return items; | |
| 164 }, | |
| 165 | |
| 166 /** | |
| 167 * Add an element to this FocusRow with the given elementId. No-op if either | |
| 168 * |element| or |elementId| is not provided. | |
| 169 * @param {Element} element The element that should be added. | |
| 170 * @param {string} elementId The elementId that should be used to find | |
| 171 * similar elements in the FocusRow. This MUST be unique for each row. | |
| 172 */ | |
| 173 setFocusableElementId: function(element, elementId) { | |
| 174 if (!element || !elementId) | |
| 124 return; | 175 return; |
| 125 | 176 |
| 126 var isActive = index >= 0 && index < this.items.length; | 177 assert(this.elementIds.indexOf(elementId) == -1); |
| 127 if (isActive == !!wasActive) | 178 element.setAttribute('focus-row-element-id', elementId); |
| 179 this.elementIds.push(elementId); | |
| 180 this.eventTracker_.add(element, 'mousedown', | |
| 181 this.onMousedown_.bind(this)); | |
| 182 | |
| 183 if (!this.rowElement_) | |
| 184 this.rowElement_ = this.observer_.getRowElement(element); | |
| 185 else | |
| 186 assert(this.rowElement_ == this.observer_.getRowElement(element)); | |
| 187 }, | |
| 188 | |
| 189 /** | |
| 190 * @param {bool} focused Whether the initial focus for this row is enabled | |
| 191 * or disabled. | |
| 192 */ | |
| 193 setInitialFocus: function(focused) { | |
| 194 if (focused) | |
| 195 this.onFocusIdChange(this.elementIds[0]); | |
| 196 else | |
| 197 this.enableRowTab(false); | |
| 198 }, | |
| 199 | |
| 200 /** | |
| 201 * Called when focus changes to activate/deactivate the row. Focus is | |
| 202 * removed from the row when |elementId| is not in the FocusRow. | |
| 203 * @param {string} elementId The elementId that has focus. | |
| 204 */ | |
| 205 onFocusIdChange: function(elementId) { | |
| 206 var element = this.getElement(elementId); | |
| 207 var rowClasses = this.rowElement_.classList; | |
| 208 var wasActive = rowClasses.contains('focus-row-active'); | |
| 209 | |
| 210 if (element) { | |
| 211 // Verify that the focus hasn't changed. This allows the FocusGrid to go | |
| 212 // back to the same focused element on a miss. | |
| 213 this.focusChanged_ = this.focusChanged_ || | |
| 214 elementId != this.lastFocusedElementId_; | |
| 215 // Keep track of the last elementId that was focused. | |
| 216 this.lastFocusedElementId_ = elementId; | |
| 217 | |
| 218 this.enableRowTab(true); | |
| 219 } else if (wasActive) | |
| 220 this.enableRowTab(false); | |
| 221 | |
| 222 // Only send events if the active state is different for the row. | |
| 223 if (!!element == wasActive) | |
| 128 return; | 224 return; |
| 129 | 225 |
| 130 if (isActive) | 226 if (element) { |
| 227 rowClasses.add('focus-row-active'); | |
| 131 this.observer_.onActivate(this); | 228 this.observer_.onActivate(this); |
| 132 else | 229 } else { |
| 230 rowClasses.remove('focus-row-active'); | |
| 133 this.observer_.onDeactivate(this); | 231 this.observer_.onDeactivate(this); |
| 134 }, | 232 } |
| 135 | 233 }, |
| 136 /** | 234 |
| 137 * Focuses the item at |index|. | 235 /** |
| 138 * @param {number} index An index to focus. Must be between 0 and | 236 * Enables/disables the tabIndex of the focusable elements in the FocusRow. |
| 139 * this.items.length - 1. | 237 * tabIndex can be set properly. |
| 140 */ | 238 * @param {bool} allow True if tab is allowed for this row. |
| 141 focusIndex: function(index) { | 239 */ |
| 142 this.items[index].focus(); | 240 enableRowTab: function(allow) { |
|
dmazzoni
2015/01/15 19:38:19
I'd call this something like makeRowFocusable or a
hcarmona
2015/01/16 21:39:06
Done.
| |
| 241 var items = this.getItems(); | |
| 242 for (var i = 0; i < items.length; ++i) | |
| 243 items[i].tabIndex = allow ? 0 : -1; | |
| 244 }, | |
| 245 | |
| 246 /** | |
| 247 * Will choose an appropriate element to focus. | |
| 248 * @param {string} elementId The element id that should be focused. | |
| 249 * @return {Element} A focusable element that best matches |elementId|. | |
|
Evan Stade
2015/01/15 23:38:09
I am confused how you could "best match" an ID. ID
hcarmona
2015/01/16 21:39:06
Done.
| |
| 250 */ | |
| 251 getFocusableElement: function(elementId) { | |
| 252 if (!elementId) | |
| 253 return null; | |
| 254 | |
| 255 /** Priority for focus is: | |
| 256 * 1. Focusable element with same elementId | |
| 257 * 2. Let the delegate decide what should be focused | |
| 258 * 3. Focus the first focusable element | |
| 259 */ | |
| 260 return this.getElement(elementId) || | |
| 261 this.getElement(this.observer_.onElementIdMiss(this, elementId)) || | |
| 262 this.getElement(this.elementIds[0]); | |
|
dmazzoni
2015/01/15 19:38:19
Just checking, this works fine on an empty list /
hcarmona
2015/01/16 21:39:06
An empty grid would have no rows, so it should be
| |
| 263 }, | |
| 264 | |
| 265 /** | |
| 266 * Called to set focus to a given row item based on the elementId. Will | |
| 267 * choose an appropriate element if |elementId| is not in the FocusRow. | |
| 268 * @param {string} elementId The element that should be focused. | |
| 269 */ | |
| 270 setFocusId: function(elementId) { | |
| 271 var element = this.getFocusableElement(elementId); | |
| 272 if (element) | |
| 273 element.focus(); | |
| 274 }, | |
| 275 | |
| 276 /** @private {string} */ | |
| 277 lastFocusedElementId_: null, | |
| 278 | |
| 279 /** @private {bool} */ | |
| 280 focusChanged_: false, | |
| 281 | |
| 282 /** | |
| 283 * Will reset the private focusChanged_ variable so that a change in focus | |
| 284 * can be tracked. | |
| 285 */ | |
| 286 trackFocus: function() { | |
| 287 this.focusChanged_ = false; | |
| 288 }, | |
| 289 | |
| 290 /** | |
| 291 * @return {bool} Whether the column focus changed in this row since it was | |
| 292 * last asked to trackFocus(). | |
| 293 */ | |
| 294 focusChanged: function() { | |
| 295 return this.focusChanged_; | |
| 143 }, | 296 }, |
| 144 | 297 |
| 145 /** Call this to clean up event handling before dereferencing. */ | 298 /** Call this to clean up event handling before dereferencing. */ |
| 146 destroy: function() { | 299 destroy: function() { |
| 147 this.eventTracker_.removeAll(); | 300 this.eventTracker_.removeAll(); |
| 148 }, | 301 }, |
| 149 | 302 |
| 150 /** | 303 /** |
| 151 * @param {Event} e The focusin event. | 304 * @param {Event} e The focusin event. |
| 152 * @private | 305 * @private |
| 153 */ | 306 */ |
| 154 onFocusin_: function(e) { | 307 onFocusin_: function(e) { |
| 155 if (this.boundary_.contains(assertInstanceof(e.target, Node))) | 308 if (this.boundary_.contains(assertInstanceof(e.target, Node))) |
| 156 this.activeIndex = this.items.indexOf(e.target); | 309 this.onFocusIdChange(this.getElementId(e.target)); |
| 157 }, | 310 }, |
| 158 | 311 |
| 159 /** | 312 /** |
| 160 * @param {Event} e A focus event. | 313 * @param {Event} e A focus event. |
| 161 * @private | 314 * @private |
| 162 */ | 315 */ |
| 163 onKeydown_: function(e) { | 316 onKeydown_: function(e) { |
| 164 var item = this.items.indexOf(e.target); | 317 if (!this.rowElement_.contains(e.target)) |
| 165 if (item < 0) | |
| 166 return; | 318 return; |
| 167 | 319 |
| 168 if (this.delegate_ && this.delegate_.onKeydown(this, e)) | 320 if (this.delegate_ && this.delegate_.onKeydown(this, e)) |
| 169 return; | 321 return; |
| 170 | 322 |
| 323 var focusId = this.getElementId(e.target); | |
| 324 var elementIndex = this.elementIds.indexOf(focusId); | |
| 171 var index = -1; | 325 var index = -1; |
| 172 | 326 |
| 173 if (e.keyIdentifier == 'Left') | 327 if (e.keyIdentifier == 'Left') |
| 174 index = item + (isRTL() ? 1 : -1); | 328 index = elementIndex + (isRTL() ? 1 : -1); |
| 175 else if (e.keyIdentifier == 'Right') | 329 else if (e.keyIdentifier == 'Right') |
| 176 index = item + (isRTL() ? -1 : 1); | 330 index = elementIndex + (isRTL() ? -1 : 1); |
| 177 else if (e.keyIdentifier == 'Home') | 331 else if (e.keyIdentifier == 'Home') |
| 178 index = 0; | 332 index = 0; |
| 179 else if (e.keyIdentifier == 'End') | 333 else if (e.keyIdentifier == 'End') |
| 180 index = this.items.length - 1; | 334 index = this.elementIds.length - 1; |
| 181 | 335 |
| 182 if (!this.items[index]) | 336 focusId = this.elementIds[index]; |
| 183 return; | 337 if (focusId) { |
| 184 | 338 this.setFocusId(focusId); |
| 185 this.focusIndex(index); | 339 e.preventDefault(); |
| 186 e.preventDefault(); | 340 } |
| 187 }, | 341 }, |
| 188 | 342 |
| 189 /** | 343 /** |
| 190 * @param {Event} e A click event. | 344 * @param {Event} e A click event. |
| 191 * @private | 345 * @private |
| 192 */ | 346 */ |
| 193 onMousedown_: function(e) { | 347 onMousedown_: function(e) { |
| 348 if (!this.rowElement_.contains(e.target)) | |
| 349 return; | |
| 350 | |
| 194 if (this.delegate_ && this.delegate_.onMousedown(this, e)) | 351 if (this.delegate_ && this.delegate_.onMousedown(this, e)) |
| 195 return; | 352 return; |
| 196 | 353 |
| 197 if (!e.button) | 354 // Only accept the left mouse click. |
| 198 this.activeIndex = this.items.indexOf(e.currentTarget); | 355 if (!e.button) { |
| 356 // Focus this row if the target is one of the elements in this row. | |
| 357 this.onFocusIdChange(this.getElementId(e.target)); | |
| 358 e.preventDefault(); | |
| 359 } | |
| 199 }, | 360 }, |
| 200 }; | 361 }; |
| 201 | 362 |
| 202 return { | 363 return { |
| 203 FocusRow: FocusRow, | 364 FocusRow: FocusRow, |
| 204 }; | 365 }; |
| 205 }); | 366 }); |
| OLD | NEW |