| 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 * var focusRow = new cr.ui.FocusRow(rowBoundary, rowEl); |
| 15 * | 15 * |
| 16 * if there are references to each node or querying them from the DOM like so: | 16 * focusRow.addFocusableElement(checkboxEl); |
| 17 * focusRow.addFocusableElement(labelEl); |
| 18 * focusRow.addFocusableElement(buttonEl); |
| 17 * | 19 * |
| 18 * new cr.ui.FocusRow(dialog.querySelectorAll('list input[type=checkbox]')) | 20 * focusRow.setInitialFocusability(true); |
| 19 * | 21 * |
| 20 * Pressing left cycles backward and pressing right cycles forward in item | 22 * 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 | 23 * order. Pressing Home goes to the beginning of the list and End goes to the |
| 22 * end of the list. | 24 * end of the list. |
| 23 * | 25 * |
| 24 * If an item in this row is focused, it'll stay active (accessible via tab). | 26 * 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 | 27 * 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 | 28 * changes to a node inside |this.boundary_|. If |boundary| isn't specified, |
| 27 * specified, any focus change deactivates the row. | 29 * any focus change deactivates the row. |
| 28 * | 30 * |
| 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 | 31 * @constructor |
| 35 */ | 32 */ |
| 36 function FocusRow(items, opt_boundary, opt_delegate, opt_observer) { | 33 function FocusRow() {} |
| 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, 'mousedown', this.onMousedown_.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 | 34 |
| 70 /** @interface */ | 35 /** @interface */ |
| 71 FocusRow.Delegate = function() {}; | 36 FocusRow.Delegate = function() {}; |
| 72 | 37 |
| 73 FocusRow.Delegate.prototype = { | 38 FocusRow.Delegate.prototype = { |
| 74 /** | 39 /** |
| 75 * Called when a key is pressed while an item in |this.items| is focused. If | 40 * Called when a key is pressed while an item in |this.focusableElements| is |
| 76 * |e|'s default is prevented, further processing is skipped. | 41 * focused. If |e|'s default is prevented, further processing is skipped. |
| 77 * @param {cr.ui.FocusRow} row The row that detected a keydown. | 42 * @param {cr.ui.FocusRow} row The row that detected a keydown. |
| 78 * @param {Event} e | 43 * @param {Event} e |
| 79 * @return {boolean} Whether the event was handled. | 44 * @return {boolean} Whether the event was handled. |
| 80 */ | 45 */ |
| 81 onKeydown: assertNotReached, | 46 onKeydown: assertNotReached, |
| 82 | 47 |
| 83 /** | 48 /** |
| 84 * @param {cr.ui.FocusRow} row The row that detected the mouse going down. | 49 * @param {cr.ui.FocusRow} row The row that detected the mouse going down. |
| 85 * @param {Event} e | 50 * @param {Event} e |
| 86 * @return {boolean} Whether the event was handled. | 51 * @return {boolean} Whether the event was handled. |
| 87 */ | 52 */ |
| 88 onMousedown: assertNotReached, | 53 onMousedown: assertNotReached, |
| 89 }; | 54 }; |
| 90 | 55 |
| 91 /** @interface */ | 56 FocusRow.prototype = { |
| 92 FocusRow.Observer = function() {}; | 57 __proto__: HTMLDivElement.prototype, |
| 93 | |
| 94 FocusRow.Observer.prototype = { | |
| 95 /** | |
| 96 * Called when the row is activated (added to the focus order). | |
| 97 * @param {cr.ui.FocusRow} row The row added to the focus order. | |
| 98 */ | |
| 99 onActivate: assertNotReached, | |
| 100 | 58 |
| 101 /** | 59 /** |
| 102 * Called when the row is deactivated (removed from the focus order). | 60 * Should be called in the constructor to decorate |this|. |
| 103 * @param {cr.ui.FocusRow} row The row removed from the focus order. | 61 * @param {Node} boundary Focus events are ignored outside of this node. |
| 62 * @param {FocusRow.Delegate=} opt_delegate A delegate to handle key events. |
| 104 */ | 63 */ |
| 105 onDeactivate: assertNotReached, | 64 decorate: function(boundary, opt_delegate) { |
| 106 }; | 65 /** @private {!Node} */ |
| 66 this.boundary_ = boundary || document; |
| 107 | 67 |
| 108 FocusRow.prototype = { | 68 /** @type {cr.ui.FocusRow.Delegate|undefined} */ |
| 109 get activeIndex() { | 69 this.delegate = opt_delegate; |
| 110 return this.activeIndex_; | |
| 111 }, | |
| 112 set activeIndex(index) { | |
| 113 var wasActive = this.items[this.activeIndex_]; | |
| 114 if (wasActive) | |
| 115 wasActive.tabIndex = -1; | |
| 116 | 70 |
| 117 this.items.forEach(function(item) { assert(item.tabIndex == -1); }); | 71 /** @type {Array<Element>} */ |
| 118 this.activeIndex_ = index; | 72 this.focusableElements = []; |
| 119 | 73 |
| 120 if (this.items[index]) | 74 /** @private {!EventTracker} */ |
| 121 this.items[index].tabIndex = 0; | 75 this.eventTracker_ = new EventTracker; |
| 122 | 76 this.eventTracker_.add(cr.doc, 'focusin', this.onFocusin_.bind(this)); |
| 123 if (!this.observer_) | 77 this.eventTracker_.add(cr.doc, 'keydown', this.onKeydown_.bind(this)); |
| 124 return; | |
| 125 | |
| 126 var isActive = index >= 0 && index < this.items.length; | |
| 127 if (isActive == !!wasActive) | |
| 128 return; | |
| 129 | |
| 130 if (isActive) | |
| 131 this.observer_.onActivate(this); | |
| 132 else | |
| 133 this.observer_.onDeactivate(this); | |
| 134 }, | 78 }, |
| 135 | 79 |
| 136 /** | 80 /** |
| 137 * Focuses the item at |index|. | 81 * Called when the row's active state changes and it is added/removed from |
| 138 * @param {number} index An index to focus. Must be between 0 and | 82 * the focus order. |
| 139 * this.items.length - 1. | 83 * @param {boolean} state Whether the row has become active or inactive. |
| 140 */ | 84 */ |
| 141 focusIndex: function(index) { | 85 onActiveStateChanged: function(state) {}, |
| 142 this.items[index].focus(); | 86 |
| 87 /** |
| 88 * Find the element that best matches |sampleElement|. |
| 89 * @param {Element} sampleElement An element from a row of the same type |
| 90 * which previously held focus. |
| 91 * @return {!Element} The element that best matches sampleElement. |
| 92 */ |
| 93 getEquivalentElement: assertNotReached, |
| 94 |
| 95 /** |
| 96 * Add an element to this FocusRow. No-op if |element| is not provided. |
| 97 * @param {Element} element The element that should be added. |
| 98 */ |
| 99 addFocusableElement: function(element) { |
| 100 if (!element) |
| 101 return; |
| 102 |
| 103 assert(this.focusableElements.indexOf(element) == -1); |
| 104 assert(this.contains(element)); |
| 105 |
| 106 this.focusableElements.push(element); |
| 107 this.eventTracker_.add(element, 'mousedown', |
| 108 this.onMousedown_.bind(this)); |
| 109 }, |
| 110 |
| 111 /** |
| 112 * Called when focus changes to activate/deactivate the row. Focus is |
| 113 * removed from the row when |element| is not in the FocusRow. |
| 114 * @param {Element} element The element that has focus. null if focus should |
| 115 * be removed. |
| 116 * @private |
| 117 */ |
| 118 onFocusChange_: function(element) { |
| 119 var isActive = this.contains(element); |
| 120 var wasActive = this.classList.contains('focus-row-active'); |
| 121 |
| 122 // Only send events if the active state is different for the row. |
| 123 if (isActive != wasActive) |
| 124 this.makeRowActive(isActive); |
| 125 }, |
| 126 |
| 127 /** |
| 128 * Enables/disables the tabIndex of the focusable elements in the FocusRow. |
| 129 * tabIndex can be set properly. |
| 130 * @param {boolean} active True if tab is allowed for this row. |
| 131 */ |
| 132 makeRowActive: function(active) { |
| 133 this.focusableElements.forEach(function(element) { |
| 134 element.tabIndex = active ? 0 : -1; |
| 135 }); |
| 136 |
| 137 this.classList.toggle('focus-row-active', active); |
| 138 this.onActiveStateChanged(active); |
| 143 }, | 139 }, |
| 144 | 140 |
| 145 /** Call this to clean up event handling before dereferencing. */ | 141 /** Call this to clean up event handling before dereferencing. */ |
| 146 destroy: function() { | 142 destroy: function() { |
| 147 this.eventTracker_.removeAll(); | 143 this.eventTracker_.removeAll(); |
| 148 }, | 144 }, |
| 149 | 145 |
| 150 /** | 146 /** |
| 151 * @param {Event} e The focusin event. | 147 * @param {Event} e The focusin event. |
| 152 * @private | 148 * @private |
| 153 */ | 149 */ |
| 154 onFocusin_: function(e) { | 150 onFocusin_: function(e) { |
| 155 if (this.boundary_.contains(assertInstanceof(e.target, Node))) | 151 if (this.boundary_.contains(assertInstanceof(e.target, Node))) |
| 156 this.activeIndex = this.items.indexOf(e.target); | 152 this.onFocusChange_(e.target); |
| 157 }, | 153 }, |
| 158 | 154 |
| 159 /** | 155 /** |
| 160 * @param {Event} e A focus event. | 156 * Handles a keypress for an element in this FocusRow. |
| 157 * @param {Event} e The keydown event. |
| 161 * @private | 158 * @private |
| 162 */ | 159 */ |
| 163 onKeydown_: function(e) { | 160 onKeydown_: function(e) { |
| 164 var item = this.items.indexOf(e.target); | 161 if (!this.contains(e.target)) |
| 165 if (item < 0) | |
| 166 return; | 162 return; |
| 167 | 163 |
| 168 if (this.delegate_ && this.delegate_.onKeydown(this, e)) | 164 if (this.delegate && this.delegate.onKeydown(this, e)) |
| 169 return; | 165 return; |
| 170 | 166 |
| 167 var elementIndex = this.focusableElements.indexOf(e.target); |
| 171 var index = -1; | 168 var index = -1; |
| 172 | 169 |
| 173 if (e.keyIdentifier == 'Left') | 170 if (e.keyIdentifier == 'Left') |
| 174 index = item + (isRTL() ? 1 : -1); | 171 index = elementIndex + (isRTL() ? 1 : -1); |
| 175 else if (e.keyIdentifier == 'Right') | 172 else if (e.keyIdentifier == 'Right') |
| 176 index = item + (isRTL() ? -1 : 1); | 173 index = elementIndex + (isRTL() ? -1 : 1); |
| 177 else if (e.keyIdentifier == 'Home') | 174 else if (e.keyIdentifier == 'Home') |
| 178 index = 0; | 175 index = 0; |
| 179 else if (e.keyIdentifier == 'End') | 176 else if (e.keyIdentifier == 'End') |
| 180 index = this.items.length - 1; | 177 index = this.focusableElements.length - 1; |
| 181 | 178 |
| 182 if (!this.items[index]) | 179 var elementToFocus = this.focusableElements[index]; |
| 183 return; | 180 if (elementToFocus) { |
| 184 | 181 this.getEquivalentElement(elementToFocus).focus(); |
| 185 this.focusIndex(index); | 182 e.preventDefault(); |
| 186 e.preventDefault(); | 183 } |
| 187 }, | 184 }, |
| 188 | 185 |
| 189 /** | 186 /** |
| 190 * @param {Event} e A click event. | 187 * @param {Event} e A click event. |
| 191 * @private | 188 * @private |
| 192 */ | 189 */ |
| 193 onMousedown_: function(e) { | 190 onMousedown_: function(e) { |
| 194 if (this.delegate_ && this.delegate_.onMousedown(this, e)) | 191 if (this.delegate && this.delegate.onMousedown(this, e)) |
| 195 return; | 192 return; |
| 196 | 193 |
| 197 if (!e.button) | 194 // Only accept the left mouse click. |
| 198 this.activeIndex = this.items.indexOf(e.currentTarget); | 195 if (!e.button) { |
| 196 // Focus this row if the target is one of the elements in this row. |
| 197 this.onFocusChange_(e.target); |
| 198 } |
| 199 }, | 199 }, |
| 200 }; | 200 }; |
| 201 | 201 |
| 202 return { | 202 return { |
| 203 FocusRow: FocusRow, | 203 FocusRow: FocusRow, |
| 204 }; | 204 }; |
| 205 }); | 205 }); |
| OLD | NEW |