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