• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.apply(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(new:cr.ui.GridItem, Object)}
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      if (!size)
94        return 0;
95
96      // We should uncollapse margin, since margin isn't collapsed for
97      // inline-block elements according to css spec which are thumbnail items.
98
99      var width = size.width + Math.min(size.marginLeft, size.marginRight);
100      var height = size.height + Math.min(size.marginTop, size.marginBottom);
101
102      if (!width || !height)
103        return 0;
104
105      var itemCount = this.dataModel ? this.dataModel.length : 0;
106      if (!itemCount)
107        return 0;
108
109      var columns = Math.floor(this.clientWidthWithoutScrollbar_ / width);
110      if (!columns)
111        return 0;
112
113      var rows = Math.ceil(itemCount / columns);
114      if (rows * height <= this.clientHeight_)
115        return columns;
116
117      return Math.floor(this.clientWidthWithScrollbar_ / width);
118    },
119
120    /**
121     * Measure and cache client width and height with and without scrollbar.
122     * Must be updated when offsetWidth and/or offsetHeight changed.
123     */
124    updateMetrics_: function() {
125      // Check changings that may affect number of columns.
126      var offsetWidth = this.offsetWidth;
127      var offsetHeight = this.offsetHeight;
128      var overflowY = window.getComputedStyle(this).overflowY;
129
130      if (this.lastOffsetWidth_ == offsetWidth &&
131          this.lastOverflowY == overflowY) {
132        this.lastOffsetHeight_ = offsetHeight;
133        return;
134      }
135
136      this.lastOffsetWidth_ = offsetWidth;
137      this.lastOffsetHeight_ = offsetHeight;
138      this.lastOverflowY = overflowY;
139      this.columns_ = 0;
140
141      if (overflowY == 'auto' && offsetWidth > 0) {
142        // Column number may depend on whether scrollbar is present or not.
143        var originalClientWidth = this.clientWidth;
144        // At first make sure there is no scrollbar and calculate clientWidth
145        // (triggers reflow).
146        this.style.overflowY = 'hidden';
147        this.clientWidthWithoutScrollbar_ = this.clientWidth;
148        this.clientHeight_ = this.clientHeight;
149        if (this.clientWidth != originalClientWidth) {
150          // If clientWidth changed then previously scrollbar was shown.
151          this.clientWidthWithScrollbar_ = originalClientWidth;
152        } else {
153          // Show scrollbar and recalculate clientWidth (triggers reflow).
154          this.style.overflowY = 'scroll';
155          this.clientWidthWithScrollbar_ = this.clientWidth;
156        }
157        this.style.overflowY = '';
158      } else {
159        this.clientWidthWithoutScrollbar_ = this.clientWidthWithScrollbar_ =
160            this.clientWidth;
161        this.clientHeight_ = this.clientHeight;
162      }
163    },
164
165    /**
166     * The number of columns in the grid. If not set, determined automatically
167     * as the maximum number of items fitting in the grid width.
168     * @type {number}
169     */
170    get columns() {
171      if (!this.columns_) {
172        this.columns_ = this.getColumnCount_();
173      }
174      return this.columns_ || 1;
175    },
176    set columns(value) {
177      if (value >= 0 && value != this.columns_) {
178        this.columns_ = value;
179        this.redraw();
180      }
181    },
182
183    /**
184     * @param {number} index The index of the item.
185     * @return {number} The top position of the item inside the list, not taking
186     *     into account lead item. May vary in the case of multiple columns.
187     * @override
188     */
189    getItemTop: function(index) {
190      return Math.floor(index / this.columns) * this.getDefaultItemHeight_();
191    },
192
193    /**
194     * @param {number} index The index of the item.
195     * @return {number} The row of the item. May vary in the case
196     *     of multiple columns.
197     * @override
198     */
199    getItemRow: function(index) {
200      return Math.floor(index / this.columns);
201    },
202
203    /**
204     * @param {number} row The row.
205     * @return {number} The index of the first item in the row.
206     * @override
207     */
208    getFirstItemInRow: function(row) {
209      return row * this.columns;
210    },
211
212    /**
213     * Creates the selection controller to use internally.
214     * @param {cr.ui.ListSelectionModel} sm The underlying selection model.
215     * @return {!cr.ui.ListSelectionController} The newly created selection
216     *     controller.
217     * @override
218     */
219    createSelectionController: function(sm) {
220      return new GridSelectionController(sm, this);
221    },
222
223    /**
224     * Calculates the number of items fitting in the given viewport.
225     * @param {number} scrollTop The scroll top position.
226     * @param {number} clientHeight The height of viewport.
227     * @return {{first: number, length: number, last: number}} The index of
228     *     first item in view port, The number of items, The item past the last.
229     * @override
230     */
231    getItemsInViewPort: function(scrollTop, clientHeight) {
232      var itemHeight = this.getDefaultItemHeight_();
233      var firstIndex =
234          this.autoExpands ? 0 : this.getIndexForListOffset_(scrollTop);
235      var columns = this.columns;
236      var count = this.autoExpands_ ? this.dataModel.length : Math.max(
237          columns * (Math.ceil(clientHeight / itemHeight) + 1),
238          this.countItemsInRange_(firstIndex, scrollTop + clientHeight));
239      count = columns * Math.ceil(count / columns);
240      count = Math.min(count, this.dataModel.length - firstIndex);
241      return {
242        first: firstIndex,
243        length: count,
244        last: firstIndex + count - 1
245      };
246    },
247
248    /**
249     * Merges list items. Calls the base class implementation and then
250     * puts spacers on the right places.
251     * @param {number} firstIndex The index of first item, inclusively.
252     * @param {number} lastIndex The index of last item, exclusively.
253     * @param {Object.<string, cr.ui.ListItem>} cachedItems Old items cache.
254     * @param {Object.<string, cr.ui.ListItem>} newCachedItems New items cache.
255     * @override
256     */
257    mergeItems: function(firstIndex, lastIndex, cachedItems, newCachedItems) {
258      List.prototype.mergeItems.call(this, firstIndex, lastIndex);
259
260      var afterFiller = this.afterFiller_;
261      var columns = this.columns;
262
263      for (var item = this.beforeFiller_.nextSibling; item != afterFiller;) {
264        var next = item.nextSibling;
265        if (isSpacer(item)) {
266          // Spacer found on a place it mustn't be.
267          this.removeChild(item);
268          item = next;
269          continue;
270        }
271        var index = item.listIndex;
272        var nextIndex = index + 1;
273
274        // Invisible pinned item could be outside of the
275        // [firstIndex, lastIndex). Ignore it.
276        if (index >= firstIndex && nextIndex < lastIndex &&
277            nextIndex % columns == 0) {
278          if (isSpacer(next)) {
279            // Leave the spacer on its place.
280            item = next.nextSibling;
281          } else {
282            // Insert spacer.
283            var spacer = this.ownerDocument.createElement('div');
284            spacer.className = 'spacer';
285            this.insertBefore(spacer, next);
286            item = next;
287          }
288        } else
289          item = next;
290      }
291
292      function isSpacer(child) {
293        return child.classList.contains('spacer') &&
294               child != afterFiller;  // Must not be removed.
295      }
296    },
297
298    /**
299     * Returns the height of after filler in the list.
300     * @param {number} lastIndex The index of item past the last in viewport.
301     * @return {number} The height of after filler.
302     * @override
303     */
304    getAfterFillerHeight: function(lastIndex) {
305      var columns = this.columns;
306      var itemHeight = this.getDefaultItemHeight_();
307      // We calculate the row of last item, and the row of last shown item.
308      // The difference is the number of rows not shown.
309      var afterRows = Math.floor((this.dataModel.length - 1) / columns) -
310          Math.floor((lastIndex - 1) / columns);
311      return afterRows * itemHeight;
312    },
313
314    /**
315     * Returns true if the child is a list item.
316     * @param {Node} child Child of the list.
317     * @return {boolean} True if a list item.
318     */
319    isItem: function(child) {
320      // Non-items are before-, afterFiller and spacers added in mergeItems.
321      return child.nodeType == Node.ELEMENT_NODE &&
322             !child.classList.contains('spacer');
323    },
324
325    redraw: function() {
326      this.updateMetrics_();
327      var itemCount = this.dataModel ? this.dataModel.length : 0;
328      if (this.lastItemCount_ != itemCount) {
329        this.lastItemCount_ = itemCount;
330        // Force recalculation.
331        this.columns_ = 0;
332      }
333
334      List.prototype.redraw.call(this);
335    }
336  };
337
338  /**
339   * Creates a selection controller that is to be used with grids.
340   * @param {cr.ui.ListSelectionModel} selectionModel The selection model to
341   *     interact with.
342   * @param {cr.ui.Grid} grid The grid to interact with.
343   * @constructor
344   * @extends {cr.ui.ListSelectionController}
345   */
346  function GridSelectionController(selectionModel, grid) {
347    this.selectionModel_ = selectionModel;
348    this.grid_ = grid;
349  }
350
351  GridSelectionController.prototype = {
352    __proto__: ListSelectionController.prototype,
353
354    /**
355     * Check if accessibility is enabled: if ChromeVox is running
356     * (which provides spoken feedback for accessibility), make up/down
357     * behave the same as left/right. That's because the 2-dimensional
358     * structure of the grid isn't exposed, so it makes more sense to a
359     * user who is relying on spoken feedback to flatten it.
360     * @return {boolean} True if accessibility is enabled.
361     */
362    isAccessibilityEnabled: function() {
363      return window.cvox && window.cvox.Api &&
364             window.cvox.Api.isChromeVoxActive &&
365             window.cvox.Api.isChromeVoxActive();
366    },
367
368    /**
369     * Returns the index below (y axis) the given element.
370     * @param {number} index The index to get the index below.
371     * @return {number} The index below or -1 if not found.
372     * @override
373     */
374    getIndexBelow: function(index) {
375      if (this.isAccessibilityEnabled())
376        return this.getIndexAfter(index);
377      var last = this.getLastIndex();
378      if (index == last)
379        return -1;
380      index += this.grid_.columns;
381      return Math.min(index, last);
382    },
383
384    /**
385     * Returns the index above (y axis) the given element.
386     * @param {number} index The index to get the index above.
387     * @return {number} The index below or -1 if not found.
388     * @override
389     */
390    getIndexAbove: function(index) {
391      if (this.isAccessibilityEnabled())
392        return this.getIndexBefore(index);
393      if (index == 0)
394        return -1;
395      index -= this.grid_.columns;
396      return Math.max(index, 0);
397    },
398
399    /**
400     * Returns the index before (x axis) the given element.
401     * @param {number} index The index to get the index before.
402     * @return {number} The index before or -1 if not found.
403     * @override
404     */
405    getIndexBefore: function(index) {
406      return index - 1;
407    },
408
409    /**
410     * Returns the index after (x axis) the given element.
411     * @param {number} index The index to get the index after.
412     * @return {number} The index after or -1 if not found.
413     * @override
414     */
415    getIndexAfter: function(index) {
416      if (index == this.getLastIndex()) {
417        return -1;
418      }
419      return index + 1;
420    }
421  };
422
423  return {
424    Grid: Grid,
425    GridItem: GridItem,
426    GridSelectionController: GridSelectionController
427  };
428});
429