1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5// require: list_selection_model.js
6// require: list_selection_controller.js
7// require: list.js
8
9/**
10 * @fileoverview This implements a grid control. Grid contains a bunch of
11 * similar elements placed in multiple columns. It's pretty similar to the list,
12 * except the multiple columns layout.
13 */
14
15cr.define('cr.ui', function() {
16  /** @const */ var ListSelectionController = cr.ui.ListSelectionController;
17  /** @const */ var List = cr.ui.List;
18  /** @const */ var ListItem = cr.ui.ListItem;
19
20  /**
21   * Creates a new grid item element.
22   * @param {*} dataItem The data item.
23   * @constructor
24   * @extends {cr.ui.ListItem}
25   */
26  function GridItem(dataItem) {
27    var el = cr.doc.createElement('li');
28    el.dataItem = dataItem;
29    el.__proto__ = GridItem.prototype;
30    return el;
31  }
32
33  GridItem.prototype = {
34    __proto__: ListItem.prototype,
35
36    /**
37     * Called when an element is decorated as a grid item.
38     */
39    decorate: function() {
40      ListItem.prototype.decorate.call(this, arguments);
41      this.textContent = this.dataItem;
42    }
43  };
44
45  /**
46   * Creates a new grid element.
47   * @param {Object=} opt_propertyBag Optional properties.
48   * @constructor
49   * @extends {cr.ui.List}
50   */
51  var Grid = cr.ui.define('grid');
52
53  Grid.prototype = {
54    __proto__: List.prototype,
55
56    /**
57     * The number of columns in the grid. Either set by the user, or lazy
58     * calculated as the maximum number of items fitting in the grid width.
59     * @type {number}
60     * @private
61     */
62    columns_: 0,
63
64    /**
65     * Function used to create grid items.
66     * @type {function(): !GridItem}
67     * @override
68     */
69    itemConstructor_: GridItem,
70
71    /**
72     * Whether or not the rows on list have various heights.
73     * Shows a warning at the setter because cr.ui.Grid does not support this.
74     * @type {boolean}
75     */
76    get fixedHeight() {
77      return true;
78    },
79    set fixedHeight(fixedHeight) {
80      if (!fixedHeight)
81        console.warn('cr.ui.Grid does not support fixedHeight = false');
82    },
83
84    /**
85     * @return {number} The number of columns determined by width of the grid
86     *     and width of the items.
87     * @private
88     */
89    getColumnCount_: function() {
90      // Size comes here with margin already collapsed.
91      var size = this.getDefaultItemSize_();
92
93      // We should uncollapse margin, since margin isn't collapsed for
94      // inline-block elements according to css spec which are thumbnail items.
95
96      var width = size.width + Math.min(size.marginLeft, size.marginRight);
97      var height = size.height + Math.min(size.marginTop, size.marginBottom);
98
99      if (!width || !height)
100        return 0;
101
102      var itemCount = this.dataModel ? this.dataModel.length : 0;
103      if (!itemCount)
104        return 0;
105
106      var columns = Math.floor(this.clientWidthWithoutScrollbar_ / width);
107      if (!columns)
108        return 0;
109
110      var rows = Math.ceil(itemCount / columns);
111      if (rows * height <= this.clientHeight_)
112        return columns;
113
114      return Math.floor(this.clientWidthWithScrollbar_ / width);
115    },
116
117    /**
118     * Measure and cache client width and height with and without scrollbar.
119     * Must be updated when offsetWidth and/or offsetHeight changed.
120     */
121    updateMetrics_: function() {
122      // Check changings that may affect number of columns.
123      var offsetWidth = this.offsetWidth;
124      var offsetHeight = this.offsetHeight;
125      var overflowY = window.getComputedStyle(this).overflowY;
126
127      if (this.lastOffsetWidth_ == offsetWidth &&
128          this.lastOverflowY == overflowY) {
129        this.lastOffsetHeight_ = offsetHeight;
130        return;
131      }
132
133      this.lastOffsetWidth_ = offsetWidth;
134      this.lastOffsetHeight_ = offsetHeight;
135      this.lastOverflowY = overflowY;
136      this.columns_ = 0;
137
138      if (overflowY == 'auto' && offsetWidth > 0) {
139        // Column number may depend on whether scrollbar is present or not.
140        var originalClientWidth = this.clientWidth;
141        // At first make sure there is no scrollbar and calculate clientWidth
142        // (triggers reflow).
143        this.style.overflowY = 'hidden';
144        this.clientWidthWithoutScrollbar_ = this.clientWidth;
145        this.clientHeight_ = this.clientHeight;
146        if (this.clientWidth != originalClientWidth) {
147          // If clientWidth changed then previously scrollbar was shown.
148          this.clientWidthWithScrollbar_ = originalClientWidth;
149        } else {
150          // Show scrollbar and recalculate clientWidth (triggers reflow).
151          this.style.overflowY = 'scroll';
152          this.clientWidthWithScrollbar_ = this.clientWidth;
153        }
154        this.style.overflowY = '';
155      } else {
156        this.clientWidthWithoutScrollbar_ = this.clientWidthWithScrollbar_ =
157            this.clientWidth;
158        this.clientHeight_ = this.clientHeight;
159      }
160    },
161
162    /**
163     * The number of columns in the grid. If not set, determined automatically
164     * as the maximum number of items fitting in the grid width.
165     * @type {number}
166     */
167    get columns() {
168      if (!this.columns_) {
169        this.columns_ = this.getColumnCount_();
170      }
171      return this.columns_ || 1;
172    },
173    set columns(value) {
174      if (value >= 0 && value != this.columns_) {
175        this.columns_ = value;
176        this.redraw();
177      }
178    },
179
180    /**
181     * @param {number} index The index of the item.
182     * @return {number} The top position of the item inside the list, not taking
183     *     into account lead item. May vary in the case of multiple columns.
184     * @override
185     */
186    getItemTop: function(index) {
187      return Math.floor(index / this.columns) * this.getDefaultItemHeight_();
188    },
189
190    /**
191     * @param {number} index The index of the item.
192     * @return {number} The row of the item. May vary in the case
193     *     of multiple columns.
194     * @override
195     */
196    getItemRow: function(index) {
197      return Math.floor(index / this.columns);
198    },
199
200    /**
201     * @param {number} row The row.
202     * @return {number} The index of the first item in the row.
203     * @override
204     */
205    getFirstItemInRow: function(row) {
206      return row * this.columns;
207    },
208
209    /**
210     * Creates the selection controller to use internally.
211     * @param {cr.ui.ListSelectionModel} sm The underlying selection model.
212     * @return {!cr.ui.ListSelectionController} The newly created selection
213     *     controller.
214     * @override
215     */
216    createSelectionController: function(sm) {
217      return new GridSelectionController(sm, this);
218    },
219
220    /**
221     * Calculates the number of items fitting in the given viewport.
222     * @param {number} scrollTop The scroll top position.
223     * @param {number} clientHeight The height of viewport.
224     * @return {{first: number, length: number, last: number}} The index of
225     *     first item in view port, The number of items, The item past the last.
226     * @override
227     */
228    getItemsInViewPort: function(scrollTop, clientHeight) {
229      var itemHeight = this.getDefaultItemHeight_();
230      var firstIndex =
231          this.autoExpands ? 0 : this.getIndexForListOffset_(scrollTop);
232      var columns = this.columns;
233      var count = this.autoExpands_ ? this.dataModel.length : Math.max(
234          columns * (Math.ceil(clientHeight / itemHeight) + 1),
235          this.countItemsInRange_(firstIndex, scrollTop + clientHeight));
236      count = columns * Math.ceil(count / columns);
237      count = Math.min(count, this.dataModel.length - firstIndex);
238      return {
239        first: firstIndex,
240        length: count,
241        last: firstIndex + count - 1
242      };
243    },
244
245    /**
246     * Merges list items. Calls the base class implementation and then
247     * puts spacers on the right places.
248     * @param {number} firstIndex The index of first item, inclusively.
249     * @param {number} lastIndex The index of last item, exclusively.
250     * @param {Object.<string, ListItem>} cachedItems Old items cache.
251     * @param {Object.<string, ListItem>} newCachedItems New items cache.
252     * @override
253     */
254    mergeItems: function(firstIndex, lastIndex, cachedItems, newCachedItems) {
255      List.prototype.mergeItems.call(this,
256          firstIndex, lastIndex, cachedItems, newCachedItems);
257
258      var afterFiller = this.afterFiller_;
259      var columns = this.columns;
260
261      for (var item = this.beforeFiller_.nextSibling; item != afterFiller;) {
262        var next = item.nextSibling;
263        if (isSpacer(item)) {
264          // Spacer found on a place it mustn't be.
265          this.removeChild(item);
266          item = next;
267          continue;
268        }
269        var index = item.listIndex;
270        var nextIndex = index + 1;
271
272        // Invisible pinned item could be outside of the
273        // [firstIndex, lastIndex). Ignore it.
274        if (index >= firstIndex && nextIndex < lastIndex &&
275            nextIndex % columns == 0) {
276          if (isSpacer(next)) {
277            // Leave the spacer on its place.
278            item = next.nextSibling;
279          } else {
280            // Insert spacer.
281            var spacer = this.ownerDocument.createElement('div');
282            spacer.className = 'spacer';
283            this.insertBefore(spacer, next);
284            item = next;
285          }
286        } else
287          item = next;
288      }
289
290      function isSpacer(child) {
291        return child.classList.contains('spacer') &&
292               child != afterFiller;  // Must not be removed.
293      }
294    },
295
296    /**
297     * Returns the height of after filler in the list.
298     * @param {number} lastIndex The index of item past the last in viewport.
299     * @return {number} The height of after filler.
300     * @override
301     */
302    getAfterFillerHeight: function(lastIndex) {
303      var columns = this.columns;
304      var itemHeight = this.getDefaultItemHeight_();
305      // We calculate the row of last item, and the row of last shown item.
306      // The difference is the number of rows not shown.
307      var afterRows = Math.floor((this.dataModel.length - 1) / columns) -
308          Math.floor((lastIndex - 1) / columns);
309      return afterRows * itemHeight;
310    },
311
312    /**
313     * Returns true if the child is a list item.
314     * @param {Node} child Child of the list.
315     * @return {boolean} True if a list item.
316     */
317    isItem: function(child) {
318      // Non-items are before-, afterFiller and spacers added in mergeItems.
319      return child.nodeType == Node.ELEMENT_NODE &&
320             !child.classList.contains('spacer');
321    },
322
323    redraw: function() {
324      this.updateMetrics_();
325      var itemCount = this.dataModel ? this.dataModel.length : 0;
326      if (this.lastItemCount_ != itemCount) {
327        this.lastItemCount_ = itemCount;
328        // Force recalculation.
329        this.columns_ = 0;
330      }
331
332      List.prototype.redraw.call(this);
333    }
334  };
335
336  /**
337   * Creates a selection controller that is to be used with grids.
338   * @param {cr.ui.ListSelectionModel} selectionModel The selection model to
339   *     interact with.
340   * @param {cr.ui.Grid} grid The grid to interact with.
341   * @constructor
342   * @extends {!cr.ui.ListSelectionController}
343   */
344  function GridSelectionController(selectionModel, grid) {
345    this.selectionModel_ = selectionModel;
346    this.grid_ = grid;
347  }
348
349  GridSelectionController.prototype = {
350    __proto__: ListSelectionController.prototype,
351
352    /**
353     * Check if accessibility is enabled: if ChromeVox is running
354     * (which provides spoken feedback for accessibility), make up/down
355     * behave the same as left/right. That's because the 2-dimensional
356     * structure of the grid isn't exposed, so it makes more sense to a
357     * user who is relying on spoken feedback to flatten it.
358     * @return {boolean} True if accessibility is enabled.
359     */
360    isAccessibilityEnabled: function() {
361      return window.cvox && window.cvox.Api &&
362             window.cvox.Api.isChromeVoxActive &&
363             window.cvox.Api.isChromeVoxActive();
364    },
365
366    /**
367     * Returns the index below (y axis) the given element.
368     * @param {number} index The index to get the index below.
369     * @return {number} The index below or -1 if not found.
370     * @override
371     */
372    getIndexBelow: function(index) {
373      if (this.isAccessibilityEnabled())
374        return this.getIndexAfter(index);
375      var last = this.getLastIndex();
376      if (index == last)
377        return -1;
378      index += this.grid_.columns;
379      return Math.min(index, last);
380    },
381
382    /**
383     * Returns the index above (y axis) the given element.
384     * @param {number} index The index to get the index above.
385     * @return {number} The index below or -1 if not found.
386     * @override
387     */
388    getIndexAbove: function(index) {
389      if (this.isAccessibilityEnabled())
390        return this.getIndexBefore(index);
391      if (index == 0)
392        return -1;
393      index -= this.grid_.columns;
394      return Math.max(index, 0);
395    },
396
397    /**
398     * Returns the index before (x axis) the given element.
399     * @param {number} index The index to get the index before.
400     * @return {number} The index before or -1 if not found.
401     * @override
402     */
403    getIndexBefore: function(index) {
404      return index - 1;
405    },
406
407    /**
408     * Returns the index after (x axis) the given element.
409     * @param {number} index The index to get the index after.
410     * @return {number} The index after or -1 if not found.
411     * @override
412     */
413    getIndexAfter: function(index) {
414      if (index == this.getLastIndex()) {
415        return -1;
416      }
417      return index + 1;
418    }
419  };
420
421  return {
422    Grid: Grid,
423    GridItem: GridItem,
424    GridSelectionController: GridSelectionController
425  };
426});
427