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