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 |