| // Copyright (c) 2012 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // require: list_selection_model.js |
| // require: list_selection_controller.js |
| // require: list.js |
| |
| /** |
| * @fileoverview This implements a grid control. Grid contains a bunch of |
| * similar elements placed in multiple columns. It's pretty similar to the list, |
| * except the multiple columns layout. |
| */ |
| |
| cr.define('cr.ui', function() { |
| /** @const */ var ListSelectionController = cr.ui.ListSelectionController; |
| /** @const */ var List = cr.ui.List; |
| /** @const */ var ListItem = cr.ui.ListItem; |
| |
| /** |
| * Creates a new grid item element. |
| * @param {*} dataItem The data item. |
| * @constructor |
| * @extends {cr.ui.ListItem} |
| */ |
| function GridItem(dataItem) { |
| var el = cr.doc.createElement('li'); |
| el.dataItem = dataItem; |
| el.__proto__ = GridItem.prototype; |
| return el; |
| } |
| |
| GridItem.prototype = { |
| __proto__: ListItem.prototype, |
| |
| /** |
| * Called when an element is decorated as a grid item. |
| */ |
| decorate: function() { |
| ListItem.prototype.decorate.apply(this, arguments); |
| this.textContent = this.dataItem; |
| } |
| }; |
| |
| /** |
| * Creates a new grid element. |
| * @param {Object=} opt_propertyBag Optional properties. |
| * @constructor |
| * @extends {cr.ui.List} |
| */ |
| var Grid = cr.ui.define('grid'); |
| |
| Grid.prototype = { |
| __proto__: List.prototype, |
| |
| /** |
| * The number of columns in the grid. Either set by the user, or lazy |
| * calculated as the maximum number of items fitting in the grid width. |
| * @type {number} |
| * @private |
| */ |
| columns_: 0, |
| |
| /** |
| * Function used to create grid items. |
| * @type {function(new:cr.ui.GridItem, *)} |
| * @override |
| */ |
| itemConstructor_: GridItem, |
| |
| /** |
| * Whether or not the rows on list have various heights. |
| * Shows a warning at the setter because cr.ui.Grid does not support this. |
| * @type {boolean} |
| */ |
| get fixedHeight() { |
| return true; |
| }, |
| set fixedHeight(fixedHeight) { |
| if (!fixedHeight) |
| console.warn('cr.ui.Grid does not support fixedHeight = false'); |
| }, |
| |
| /** |
| * @return {number} The number of columns determined by width of the grid |
| * and width of the items. |
| * @private |
| */ |
| getColumnCount_: function() { |
| // Size comes here with margin already collapsed. |
| var size = this.getDefaultItemSize_(); |
| |
| if (!size) |
| return 0; |
| |
| // We should uncollapse margin, since margin isn't collapsed for |
| // inline-block elements according to css spec which are thumbnail items. |
| |
| var width = size.width + Math.min(size.marginLeft, size.marginRight); |
| var height = size.height + Math.min(size.marginTop, size.marginBottom); |
| |
| if (!width || !height) |
| return 0; |
| |
| var itemCount = this.dataModel ? this.dataModel.length : 0; |
| if (!itemCount) |
| return 0; |
| |
| var columns = Math.floor( |
| (this.clientWidthWithoutScrollbar_ - this.horizontalPadding_) / |
| width); |
| if (!columns) |
| return 0; |
| |
| var rows = Math.ceil(itemCount / columns); |
| if (rows * height <= this.clientHeight_) { |
| // Content fits within the client area (no scrollbar required). |
| return columns; |
| } |
| |
| // If the content doesn't fit within the client area, the number of |
| // columns should be calculated with consideration for scrollbar's width. |
| return Math.floor( |
| (this.clientWidthWithScrollbar_ - this.horizontalPadding_) / width); |
| }, |
| |
| /** |
| * Measure and cache client width and height with and without scrollbar. |
| * Must be updated when offsetWidth and/or offsetHeight changed. |
| */ |
| updateMetrics_: function() { |
| // Check changings that may affect number of columns. |
| var offsetWidth = this.offsetWidth; |
| var offsetHeight = this.offsetHeight; |
| var style = window.getComputedStyle(this); |
| var overflowY = style.overflowY; |
| var horizontalPadding = |
| parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); |
| |
| if (this.lastOffsetWidth_ == offsetWidth && |
| this.lastOverflowY == overflowY && |
| this.horizontalPadding_ == horizontalPadding) { |
| this.lastOffsetHeight_ = offsetHeight; |
| return; |
| } |
| |
| this.lastOffsetWidth_ = offsetWidth; |
| this.lastOffsetHeight_ = offsetHeight; |
| this.lastOverflowY = overflowY; |
| this.horizontalPadding_ = horizontalPadding; |
| this.columns_ = 0; |
| |
| if (overflowY == 'auto' && offsetWidth > 0) { |
| // Column number may depend on whether scrollbar is present or not. |
| var originalClientWidth = this.clientWidth; |
| // At first make sure there is no scrollbar and calculate clientWidth |
| // (triggers reflow). |
| this.style.overflowY = 'hidden'; |
| this.clientWidthWithoutScrollbar_ = this.clientWidth; |
| this.clientHeight_ = this.clientHeight; |
| if (this.clientWidth != originalClientWidth) { |
| // If clientWidth changed then previously scrollbar was shown. |
| this.clientWidthWithScrollbar_ = originalClientWidth; |
| } else { |
| // Show scrollbar and recalculate clientWidth (triggers reflow). |
| this.style.overflowY = 'scroll'; |
| this.clientWidthWithScrollbar_ = this.clientWidth; |
| } |
| this.style.overflowY = ''; |
| } else { |
| this.clientWidthWithoutScrollbar_ = this.clientWidthWithScrollbar_ = |
| this.clientWidth; |
| this.clientHeight_ = this.clientHeight; |
| } |
| }, |
| |
| /** |
| * The number of columns in the grid. If not set, determined automatically |
| * as the maximum number of items fitting in the grid width. |
| * @type {number} |
| */ |
| get columns() { |
| if (!this.columns_) { |
| this.columns_ = this.getColumnCount_(); |
| } |
| return this.columns_ || 1; |
| }, |
| set columns(value) { |
| if (value >= 0 && value != this.columns_) { |
| this.columns_ = value; |
| this.redraw(); |
| } |
| }, |
| |
| /** |
| * @param {number} index The index of the item. |
| * @return {number} The top position of the item inside the list, not taking |
| * into account lead item. May vary in the case of multiple columns. |
| * @override |
| */ |
| getItemTop: function(index) { |
| return Math.floor(index / this.columns) * this.getDefaultItemHeight_(); |
| }, |
| |
| /** |
| * @param {number} index The index of the item. |
| * @return {number} The row of the item. May vary in the case |
| * of multiple columns. |
| * @override |
| */ |
| getItemRow: function(index) { |
| return Math.floor(index / this.columns); |
| }, |
| |
| /** |
| * @param {number} row The row. |
| * @return {number} The index of the first item in the row. |
| * @override |
| */ |
| getFirstItemInRow: function(row) { |
| return row * this.columns; |
| }, |
| |
| /** |
| * Creates the selection controller to use internally. |
| * @param {cr.ui.ListSelectionModel} sm The underlying selection model. |
| * @return {!cr.ui.ListSelectionController} The newly created selection |
| * controller. |
| * @override |
| */ |
| createSelectionController: function(sm) { |
| return new GridSelectionController(sm, this); |
| }, |
| |
| /** |
| * Calculates the number of items fitting in the given viewport. |
| * @param {number} scrollTop The scroll top position. |
| * @param {number} clientHeight The height of viewport. |
| * @return {{first: number, length: number, last: number}} The index of |
| * first item in view port, The number of items, The item past the last. |
| * @override |
| */ |
| getItemsInViewPort: function(scrollTop, clientHeight) { |
| var itemHeight = this.getDefaultItemHeight_(); |
| var firstIndex = |
| this.autoExpands ? 0 : this.getIndexForListOffset_(scrollTop); |
| var columns = this.columns; |
| var count = this.autoExpands_ ? |
| this.dataModel.length : |
| Math.max( |
| columns * (Math.ceil(clientHeight / itemHeight) + 1), |
| this.countItemsInRange_(firstIndex, scrollTop + clientHeight)); |
| count = columns * Math.ceil(count / columns); |
| count = Math.min(count, this.dataModel.length - firstIndex); |
| return {first: firstIndex, length: count, last: firstIndex + count - 1}; |
| }, |
| |
| /** |
| * Merges list items. Calls the base class implementation and then |
| * puts spacers on the right places. |
| * @param {number} firstIndex The index of first item, inclusively. |
| * @param {number} lastIndex The index of last item, exclusively. |
| * @override |
| */ |
| mergeItems: function(firstIndex, lastIndex) { |
| List.prototype.mergeItems.call(this, firstIndex, lastIndex); |
| |
| var afterFiller = this.afterFiller_; |
| var columns = this.columns; |
| |
| for (var item = this.beforeFiller_.nextSibling; item != afterFiller;) { |
| var next = item.nextSibling; |
| if (isSpacer(item)) { |
| // Spacer found on a place it mustn't be. |
| this.removeChild(item); |
| item = next; |
| continue; |
| } |
| var index = item.listIndex; |
| var nextIndex = index + 1; |
| |
| // Invisible pinned item could be outside of the |
| // [firstIndex, lastIndex). Ignore it. |
| if (index >= firstIndex && nextIndex < lastIndex && |
| nextIndex % columns == 0) { |
| if (isSpacer(next)) { |
| // Leave the spacer on its place. |
| item = next.nextSibling; |
| } else { |
| // Insert spacer. |
| var spacer = this.ownerDocument.createElement('div'); |
| spacer.className = 'spacer'; |
| this.insertBefore(spacer, next); |
| item = next; |
| } |
| } else |
| item = next; |
| } |
| |
| function isSpacer(child) { |
| return child.classList.contains('spacer') && |
| child != afterFiller; // Must not be removed. |
| } |
| }, |
| |
| /** |
| * Returns the height of after filler in the list. |
| * @param {number} lastIndex The index of item past the last in viewport. |
| * @return {number} The height of after filler. |
| * @override |
| */ |
| getAfterFillerHeight: function(lastIndex) { |
| var columns = this.columns; |
| var itemHeight = this.getDefaultItemHeight_(); |
| // We calculate the row of last item, and the row of last shown item. |
| // The difference is the number of rows not shown. |
| var afterRows = Math.floor((this.dataModel.length - 1) / columns) - |
| Math.floor((lastIndex - 1) / columns); |
| return afterRows * itemHeight; |
| }, |
| |
| /** |
| * Returns true if the child is a list item. |
| * @param {Node} child Child of the list. |
| * @return {boolean} True if a list item. |
| */ |
| isItem: function(child) { |
| // Non-items are before-, afterFiller and spacers added in mergeItems. |
| return child.nodeType == Node.ELEMENT_NODE && |
| !child.classList.contains('spacer'); |
| }, |
| |
| redraw: function() { |
| this.updateMetrics_(); |
| var itemCount = this.dataModel ? this.dataModel.length : 0; |
| if (this.lastItemCount_ != itemCount) { |
| this.lastItemCount_ = itemCount; |
| // Force recalculation. |
| this.columns_ = 0; |
| } |
| |
| List.prototype.redraw.call(this); |
| } |
| }; |
| |
| /** |
| * Creates a selection controller that is to be used with grids. |
| * @param {cr.ui.ListSelectionModel} selectionModel The selection model to |
| * interact with. |
| * @param {cr.ui.Grid} grid The grid to interact with. |
| * @constructor |
| * @extends {cr.ui.ListSelectionController} |
| */ |
| function GridSelectionController(selectionModel, grid) { |
| this.selectionModel_ = selectionModel; |
| this.grid_ = grid; |
| } |
| |
| GridSelectionController.prototype = { |
| __proto__: ListSelectionController.prototype, |
| |
| /** |
| * Check if accessibility is enabled: if ChromeVox is running |
| * (which provides spoken feedback for accessibility), make up/down |
| * behave the same as left/right. That's because the 2-dimensional |
| * structure of the grid isn't exposed, so it makes more sense to a |
| * user who is relying on spoken feedback to flatten it. |
| * @return {boolean} True if accessibility is enabled. |
| */ |
| isAccessibilityEnabled: function() { |
| return window.cvox && window.cvox.Api && |
| window.cvox.Api.isChromeVoxActive && |
| window.cvox.Api.isChromeVoxActive(); |
| }, |
| |
| /** |
| * Returns the index below (y axis) the given element. |
| * @param {number} index The index to get the index below. |
| * @return {number} The index below or -1 if not found. |
| * @override |
| */ |
| getIndexBelow: function(index) { |
| if (this.isAccessibilityEnabled()) |
| return this.getIndexAfter(index); |
| var last = this.getLastIndex(); |
| if (index == last) |
| return -1; |
| index += this.grid_.columns; |
| return Math.min(index, last); |
| }, |
| |
| /** |
| * Returns the index above (y axis) the given element. |
| * @param {number} index The index to get the index above. |
| * @return {number} The index below or -1 if not found. |
| * @override |
| */ |
| getIndexAbove: function(index) { |
| if (this.isAccessibilityEnabled()) |
| return this.getIndexBefore(index); |
| if (index == 0) |
| return -1; |
| index -= this.grid_.columns; |
| return Math.max(index, 0); |
| }, |
| |
| /** |
| * Returns the index before (x axis) the given element. |
| * @param {number} index The index to get the index before. |
| * @return {number} The index before or -1 if not found. |
| * @override |
| */ |
| getIndexBefore: function(index) { |
| return index - 1; |
| }, |
| |
| /** |
| * Returns the index after (x axis) the given element. |
| * @param {number} index The index to get the index after. |
| * @return {number} The index after or -1 if not found. |
| * @override |
| */ |
| getIndexAfter: function(index) { |
| if (index == this.getLastIndex()) { |
| return -1; |
| } |
| return index + 1; |
| } |
| }; |
| |
| return { |
| Grid: Grid, |
| GridItem: GridItem, |
| GridSelectionController: GridSelectionController |
| }; |
| }); |