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 |