OLD | NEW |
| (Empty) |
1 <!-- | |
2 Copyright (c) 2014 The Polymer Project Authors. All rights reserved. | |
3 This code may only be used under the BSD style license found at http://polymer.g
ithub.io/LICENSE.txt | |
4 The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt | |
5 The complete set of contributors may be found at http://polymer.github.io/CONTRI
BUTORS.txt | |
6 Code distributed by Google as part of the polymer project is also | |
7 subject to an additional IP rights grant found at http://polymer.github.io/PATEN
TS.txt | |
8 --> | |
9 | |
10 <!-- | |
11 `core-list` displays a virtual, 'infinite' list. The template inside the | |
12 `core-list` element represents the dom to create for each list item. The | |
13 `data` property specifies an array of list item data. The `height` property | |
14 represents the fixed height of a list item (variable height list items are | |
15 not yet supported). | |
16 | |
17 `core-list` manages a viewport of data based on the current scroll position. | |
18 For performance reasons, not every item in the list is rendered at once. | |
19 | |
20 List item templates should bind to template models of the following structure | |
21 | |
22 { | |
23 index: 0, // list index for this item | |
24 selected: false, // selection state for this item | |
25 model: { // user data corresponding to data[index] | |
26 /* user data */ | |
27 } | |
28 } | |
29 | |
30 For example, given the following data array: | |
31 | |
32 [ | |
33 {name: 'Bob', checked: true}, | |
34 {name: 'Tim', checked: false}, | |
35 ... | |
36 ] | |
37 | |
38 The following code would render the list (note the `name` and `checked` | |
39 properties are bound from the `model` object provided to the template | |
40 scope): | |
41 | |
42 <core-list data="{{data}}" height="80"> | |
43 <template> | |
44 <div class="{{ {selected: selected} | tokenList }}"> | |
45 List row: {{index}}, User data from model: {{model.name}} | |
46 <input type="checkbox" checked="{{model.checked}}"> | |
47 </div> | |
48 </template> | |
49 </core-list> | |
50 | |
51 By default, the list supports selection via tapping. Styling the selection | |
52 should be done via binding to the `selected` property of each model. | |
53 | |
54 @group Polymer Core Elements | |
55 @element core-list | |
56 --> | |
57 <link rel="import" href="../polymer/polymer.html"> | |
58 <link rel="import" href="../core-selection/core-selection.html"> | |
59 | |
60 <polymer-element name="core-list" on-tap="{{tapHandler}}" tabindex="-1"> | |
61 <template> | |
62 <core-selection id="selection" multi="{{multi}}" on-core-select="{{selectedHan
dler}}"></core-selection> | |
63 <link rel="stylesheet" href="core-list.css"> | |
64 <div id="viewport" class="core-list-viewport"><content></content></div> | |
65 </template> | |
66 <script> | |
67 (function() { | |
68 | |
69 Polymer('core-list', { | |
70 | |
71 publish: { | |
72 /** | |
73 * Fired when an item element is tapped. | |
74 * | |
75 * @event core-activate | |
76 * @param {Object} detail | |
77 * @param {Object} detail.item the item element | |
78 */ | |
79 | |
80 /** | |
81 * | |
82 * An array of source data for the list to display. | |
83 * | |
84 * @attribute data | |
85 * @type array | |
86 * @default null | |
87 */ | |
88 data: null, | |
89 | |
90 /** | |
91 * | |
92 * An optional element on which to listen for scroll events. | |
93 * | |
94 * @attribute scrollTarget | |
95 * @type Element | |
96 * @default core-list | |
97 */ | |
98 scrollTarget: null, | |
99 | |
100 /** | |
101 * | |
102 * The height of a list item. `core-list` currently supports only fixed-he
ight | |
103 * list items. This height must be specified via the height property. | |
104 * | |
105 * @attribute height | |
106 * @type number | |
107 * @default 80 | |
108 */ | |
109 height: 80, | |
110 | |
111 /** | |
112 * | |
113 * The number of extra items rendered above the minimum set required to | |
114 * fill the list's height. | |
115 * | |
116 * @attribute extraItems | |
117 * @type number | |
118 * @default 30 | |
119 */ | |
120 extraItems: 30, | |
121 | |
122 /** | |
123 * | |
124 * When true, tapping a row will select the item, placing its data model | |
125 * in the set of selected items retrievable via the `selection` property. | |
126 * | |
127 * Note that tapping focusable elements within the list item will not | |
128 * result in selection, since they are presumed to have their own action. | |
129 * | |
130 * @attribute selectionEnabled | |
131 * @type {boolean} | |
132 * @default true | |
133 */ | |
134 selectionEnabled: true, | |
135 | |
136 /** | |
137 * | |
138 * Set to true to support multiple selection. Note, existing selection | |
139 * state is maintained only when changing `multi` from `false` to `true`; | |
140 * it is cleared when changing from `true` to `false`. | |
141 * | |
142 * @attribute multi | |
143 * @type boolean | |
144 * @default false | |
145 */ | |
146 multi: false, | |
147 | |
148 /** | |
149 * | |
150 * Data record (or array of records, if `multi: true`) corresponding to | |
151 * the currently selected set of items. | |
152 * | |
153 * @attribute selection | |
154 * @type {any} | |
155 * @default null | |
156 */ | |
157 selection: null | |
158 }, | |
159 | |
160 // Local cache of scrollTop | |
161 _scrollTop: 0, | |
162 | |
163 observe: { | |
164 'data template scrollTarget': 'initialize', | |
165 'multi selectionEnabled': '_resetSelection' | |
166 }, | |
167 | |
168 ready: function() { | |
169 this._boundScrollHandler = this.scrollHandler.bind(this); | |
170 this._oldMulti = this.multi; | |
171 this._oldSelectionEnabled = this.selectionEnabled; | |
172 }, | |
173 | |
174 attached: function() { | |
175 this.template = this.querySelector('template'); | |
176 if (!this.template.bindingDelegate) { | |
177 this.template.bindingDelegate = this.element.syntax; | |
178 } | |
179 }, | |
180 | |
181 _resetSelection: function() { | |
182 if (((this._oldMulti != this.multi) && !this.multi) || | |
183 ((this._oldSelectionEnabled != this.selectionEnabled) && | |
184 !this.selectionEnabled)) { | |
185 this._clearSelection(); | |
186 this.refresh(true); | |
187 } else { | |
188 this.selection = this.$.selection.getSelection(); | |
189 } | |
190 this._oldMulti = this.multi; | |
191 this._oldSelectionEnabled = this.selectionEnabled; | |
192 }, | |
193 | |
194 // TODO(sorvell): it'd be nice to dispense with 'data' and just use | |
195 // template repeat's model. However, we need tighter integration | |
196 // with TemplateBinding for this. | |
197 initialize: function() { | |
198 if (!this.template) { | |
199 return; | |
200 } | |
201 | |
202 // TODO(kschaaf): This is currently the only way to know that the array | |
203 // was mutated as opposed to newly assigned; to be updated with better API | |
204 if (arguments.length == 1) { | |
205 var splices = arguments[0]; | |
206 for (var i=0; i<splices.length; i++) { | |
207 var s = splices[i]; | |
208 for (var j=0; j<s.removed.length; j++) { | |
209 var d = s.removed[j]; | |
210 this.$.selection.setItemSelected(d, false); | |
211 } | |
212 } | |
213 } else { | |
214 this._clearSelection(); | |
215 } | |
216 | |
217 var target = this.scrollTarget || this; | |
218 if (this._target !== target) { | |
219 if (this._target) { | |
220 this._target.removeEventListener('scroll', this._boundScrollHandler, f
alse); | |
221 } | |
222 this._target = target; | |
223 this._target.addEventListener('scroll', this._boundScrollHandler, false)
; | |
224 } | |
225 // Only use -webkit-overflow-touch from iOS8+, where scroll events are fir
ed | |
226 var ios = navigator.userAgent.match(/iP(?:hone|ad;(?: U;)? CPU) OS (\d+)/)
; | |
227 if (ios && ios[1] >= 8) { | |
228 target.style.webkitOverflowScrolling = 'touch'; | |
229 } | |
230 | |
231 this.initializeData(); | |
232 }, | |
233 | |
234 initializeData: function() { | |
235 var currentCount = this._physicalCount || 0; | |
236 var dataLen = this.data && this.data.length || 0; | |
237 this._visibleCount = Math.ceil(this._target.offsetHeight / this.height); | |
238 this._physicalCount = Math.min(this._visibleCount + this.extraItems, dataL
en); | |
239 this._physicalCount = Math.max(currentCount, this._physicalCount); | |
240 this._physicalData = this._physicalData || new Array(this._physicalCount); | |
241 var needItemInit = false; | |
242 while (currentCount < this._physicalCount) { | |
243 this._physicalData[currentCount++] = {}; | |
244 needItemInit = true; | |
245 } | |
246 this.template.model = this._physicalData; | |
247 this.template.setAttribute('repeat', ''); | |
248 if (needItemInit) { | |
249 this.onMutation(this, this.initializeItems); | |
250 } else { | |
251 this.refresh(true); | |
252 } | |
253 }, | |
254 | |
255 initializeItems: function() { | |
256 var currentCount = this._physicalItems && this._physicalItems.length || 0; | |
257 this._physicalItems = this._physicalItems || new Array(this._physicalCount
); | |
258 for (var i = 0, item = this.template.nextElementSibling; | |
259 item && i < this._physicalCount; | |
260 ++i, item = item.nextElementSibling) { | |
261 this._physicalItems[i] = item; | |
262 item._transformValue = 0; | |
263 } | |
264 this.refresh(true); | |
265 }, | |
266 | |
267 updateItem: function(virtualIndex, physicalIndex) { | |
268 var virtualDatum = this.data && this.data[virtualIndex]; | |
269 var physicalDatum = this._physicalData[physicalIndex]; | |
270 physicalDatum.model = virtualDatum; | |
271 physicalDatum.physicalIndex = physicalIndex; | |
272 physicalDatum.index = virtualIndex; | |
273 physicalDatum.selected = this.selectionEnabled && virtualDatum ? | |
274 this._selectedData.get(virtualDatum) : null; | |
275 var physicalItem = this._physicalItems[physicalIndex]; | |
276 physicalItem.hidden = !virtualDatum; | |
277 }, | |
278 | |
279 scrollHandler: function(e, detail) { | |
280 this._scrollTop = e.detail ? e.detail.target.scrollTop : e.target.scrollTo
p; | |
281 this.refresh(false); | |
282 }, | |
283 | |
284 /** | |
285 * Refresh the list at the current scroll position. | |
286 * | |
287 * @method refresh | |
288 */ | |
289 refresh: function(force) { | |
290 // Check that the array hasn't gotten longer since data was initialized | |
291 var dataLen = this.data && this.data.length || 0; | |
292 if (force) { | |
293 if (this._physicalCount < | |
294 Math.min(this._visibleCount + this.extraItems, dataLen)) { | |
295 // Need to add more items; once new data & items are initialized, | |
296 // refresh will be run again | |
297 this.initializeData(); | |
298 return; | |
299 } | |
300 this._physicalHeight = this.height * this._physicalCount; | |
301 this.$.viewport.style.height = this.height * dataLen + 'px'; | |
302 } | |
303 | |
304 var firstVisibleIndex = Math.floor(this._scrollTop / this.height); | |
305 var visibleMidpoint = firstVisibleIndex + this._visibleCount / 2; | |
306 | |
307 var firstReifiedIndex = Math.max(0, Math.floor(visibleMidpoint - | |
308 this._physicalCount / 2)); | |
309 firstReifiedIndex = Math.min(firstReifiedIndex, dataLen - | |
310 this._physicalCount); | |
311 firstReifiedIndex = (firstReifiedIndex < 0) ? 0 : firstReifiedIndex; | |
312 | |
313 var firstPhysicalIndex = firstReifiedIndex % this._physicalCount; | |
314 var baseVirtualIndex = firstReifiedIndex - firstPhysicalIndex; | |
315 | |
316 var baseTransformValue = Math.floor(this.height * baseVirtualIndex); | |
317 var nextTransformValue = Math.floor(baseTransformValue + | |
318 this._physicalHeight); | |
319 | |
320 var baseTransformString = 'translate3d(0,' + baseTransformValue + 'px,0)'; | |
321 var nextTransformString = 'translate3d(0,' + nextTransformValue + 'px,0)'; | |
322 | |
323 this.firstPhysicalIndex = firstPhysicalIndex; | |
324 this.baseVirtualIndex = baseVirtualIndex; | |
325 | |
326 for (var i = 0; i < firstPhysicalIndex; ++i) { | |
327 var item = this._physicalItems[i]; | |
328 if (force || item._transformValue != nextTransformValue) { | |
329 this.updateItem(baseVirtualIndex + this._physicalCount + i, i); | |
330 setTransform(item, nextTransformString, nextTransformValue); | |
331 } | |
332 } | |
333 for (var i = firstPhysicalIndex; i < this._physicalCount; ++i) { | |
334 var item = this._physicalItems[i]; | |
335 if (force || item._transformValue != baseTransformValue) { | |
336 this.updateItem(baseVirtualIndex + i, i); | |
337 setTransform(item, baseTransformString, baseTransformValue); | |
338 } | |
339 } | |
340 }, | |
341 | |
342 // list selection | |
343 tapHandler: function(e) { | |
344 var n = e.target; | |
345 var p = e.path; | |
346 if (!this.selectionEnabled || (n === this)) { | |
347 return; | |
348 } | |
349 requestAnimationFrame(function() { | |
350 // Gambit: only select the item if the tap wasn't on a focusable child | |
351 // of the list (since anything with its own action should be focusable | |
352 // and not result in result in list selection). To check this, we | |
353 // asynchronously check that shadowRoot.activeElement is null, which | |
354 // means the tapped item wasn't focusable. On polyfill where | |
355 // activeElement doesn't follow the data-hinding part of the spec, we | |
356 // can check that document.activeElement is the list itself, which will | |
357 // catch focus in lieu of the tapped item being focusable, as we make | |
358 // the list focusable (tabindex="-1") for this purpose. Note we also | |
359 // allow the list items themselves to be focusable if desired, so those | |
360 // are excluded as well. | |
361 var active = window.ShadowDOMPolyfill ? | |
362 wrap(document.activeElement) : this.shadowRoot.activeElement; | |
363 if (active && (active != this) && (active.parentElement != this) && | |
364 (document.activeElement != document.body)) { | |
365 return; | |
366 } | |
367 // Unfortunately, Safari does not focus certain form controls via mouse, | |
368 // so we also blacklist input, button, & select | |
369 // (https://bugs.webkit.org/show_bug.cgi?id=118043) | |
370 if ((p[0].localName == 'input') || | |
371 (p[0].localName == 'button') || | |
372 (p[0].localName == 'select')) { | |
373 return; | |
374 } | |
375 | |
376 var model = n.templateInstance && n.templateInstance.model; | |
377 if (model) { | |
378 var vi = model.index, pi = model.physicalIndex; | |
379 var data = this.data[vi], item = this._physicalItems[pi]; | |
380 this.$.selection.select(data); | |
381 this.asyncFire('core-activate', {data: data, item: item}); | |
382 } | |
383 }.bind(this)); | |
384 }, | |
385 | |
386 selectedHandler: function(e, detail) { | |
387 this.selection = this.$.selection.getSelection(); | |
388 var i$ = this.indexesForData(detail.item); | |
389 // TODO(sorvell): we should be relying on selection to store the | |
390 // selected data but we want to optimize for lookup. | |
391 this._selectedData.set(detail.item, detail.isSelected); | |
392 if (i$.physical >= 0) { | |
393 this.updateItem(i$.virtual, i$.physical); | |
394 } | |
395 }, | |
396 | |
397 /** | |
398 * Select the list item at the given index. | |
399 * | |
400 * @method selectItem | |
401 * @param {number} index | |
402 */ | |
403 selectItem: function(index) { | |
404 if (!this.selectionEnabled) { | |
405 return; | |
406 } | |
407 var data = this.data[index]; | |
408 if (data) { | |
409 this.$.selection.select(data); | |
410 } | |
411 }, | |
412 | |
413 /** | |
414 * Set the selected state of the list item at the given index. | |
415 * | |
416 * @method setItemSelected | |
417 * @param {number} index | |
418 * @param {boolean} isSelected | |
419 */ | |
420 setItemSelected: function(index, isSelected) { | |
421 var data = this.data[index]; | |
422 if (data) { | |
423 this.$.selection.setItemSelected(data, isSelected); | |
424 } | |
425 }, | |
426 | |
427 indexesForData: function(data) { | |
428 var virtual = this.data.indexOf(data); | |
429 var physical = this.virtualToPhysicalIndex(virtual); | |
430 return { virtual: virtual, physical: physical }; | |
431 }, | |
432 | |
433 virtualToPhysicalIndex: function(index) { | |
434 for (var i=0, l=this._physicalData.length; i<l; i++) { | |
435 if (this._physicalData[i].index === index) { | |
436 return i; | |
437 } | |
438 } | |
439 return -1; | |
440 }, | |
441 | |
442 /** | |
443 * Clears the current selection state of the list. | |
444 * | |
445 * @method clearSelection | |
446 */ | |
447 clearSelection: function() { | |
448 this._clearSelection(); | |
449 this.refresh(true); | |
450 }, | |
451 | |
452 _clearSelection: function() { | |
453 this._selectedData = new WeakMap(); | |
454 this.$.selection.clear(); | |
455 this.selection = this.$.selection.getSelection(); | |
456 }, | |
457 | |
458 scrollToItem: function(index) { | |
459 this.scrollTop = index * this.height; | |
460 } | |
461 | |
462 }); | |
463 | |
464 // determine proper transform mechanizm | |
465 if (document.documentElement.style.transform !== undefined) { | |
466 var setTransform = function(element, string, value) { | |
467 element.style.transform = string; | |
468 element._transformValue = value; | |
469 } | |
470 } else { | |
471 var setTransform = function(element, string, value) { | |
472 element.style.webkitTransform = string; | |
473 element._transformValue = value; | |
474 } | |
475 } | |
476 | |
477 })(); | |
478 </script> | |
479 </polymer-element> | |
OLD | NEW |