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 /** 6 * @fileoverview This implements a table control. 7 */ 8 9 cr.define('cr.ui', function() { 10 /** @const */ var ListSelectionModel = cr.ui.ListSelectionModel; 11 /** @const */ var ListSelectionController = cr.ui.ListSelectionController; 12 /** @const */ var ArrayDataModel = cr.ui.ArrayDataModel; 13 /** @const */ var TableColumnModel = cr.ui.table.TableColumnModel; 14 /** @const */ var TableList = cr.ui.table.TableList; 15 /** @const */ var TableHeader = cr.ui.table.TableHeader; 16 17 /** 18 * Creates a new table element. 19 * @param {Object=} opt_propertyBag Optional properties. 20 * @constructor 21 * @extends {HTMLDivElement} 22 */ 23 var Table = cr.ui.define('div'); 24 25 Table.prototype = { 26 __proto__: HTMLDivElement.prototype, 27 28 columnModel_: new TableColumnModel([]), 29 30 /** 31 * The table data model. 32 * 33 * @type {cr.ui.ArrayDataModel} 34 */ 35 get dataModel() { 36 return this.list_.dataModel; 37 }, 38 set dataModel(dataModel) { 39 if (this.list_.dataModel != dataModel) { 40 if (this.list_.dataModel) { 41 this.list_.dataModel.removeEventListener('sorted', 42 this.boundHandleSorted_); 43 this.list_.dataModel.removeEventListener('change', 44 this.boundHandleChangeList_); 45 this.list_.dataModel.removeEventListener('splice', 46 this.boundHandleChangeList_); 47 } 48 this.list_.dataModel = dataModel; 49 if (this.list_.dataModel) { 50 this.list_.dataModel.addEventListener('sorted', 51 this.boundHandleSorted_); 52 this.list_.dataModel.addEventListener('change', 53 this.boundHandleChangeList_); 54 this.list_.dataModel.addEventListener('splice', 55 this.boundHandleChangeList_); 56 } 57 this.header_.redraw(); 58 } 59 }, 60 61 /** 62 * The list of table. 63 * 64 * @type {cr.ui.list} 65 */ 66 get list() { 67 return this.list_; 68 }, 69 70 /** 71 * The table column model. 72 * 73 * @type {cr.ui.table.TableColumnModel} 74 */ 75 get columnModel() { 76 return this.columnModel_; 77 }, 78 set columnModel(columnModel) { 79 if (this.columnModel_ != columnModel) { 80 if (this.columnModel_) 81 this.columnModel_.removeEventListener('resize', this.boundResize_); 82 this.columnModel_ = columnModel; 83 84 if (this.columnModel_) 85 this.columnModel_.addEventListener('resize', this.boundResize_); 86 this.list_.invalidate(); 87 this.redraw(); 88 } 89 }, 90 91 /** 92 * The table selection model. 93 * 94 * @type 95 * {cr.ui.ListSelectionModel|cr.ui.table.ListSingleSelectionModel} 96 */ 97 get selectionModel() { 98 return this.list_.selectionModel; 99 }, 100 set selectionModel(selectionModel) { 101 if (this.list_.selectionModel != selectionModel) { 102 if (this.dataModel) 103 selectionModel.adjustLength(this.dataModel.length); 104 this.list_.selectionModel = selectionModel; 105 } 106 }, 107 108 /** 109 * The accessor to "autoExpands" property of the list. 110 * 111 * @type {boolean} 112 */ 113 get autoExpands() { 114 return this.list_.autoExpands; 115 }, 116 set autoExpands(autoExpands) { 117 this.list_.autoExpands = autoExpands; 118 }, 119 120 get fixedHeight() { 121 return this.list_.fixedHeight; 122 }, 123 set fixedHeight(fixedHeight) { 124 this.list_.fixedHeight = fixedHeight; 125 }, 126 127 /** 128 * Returns render function for row. 129 * @return {Function(*, cr.ui.Table): HTMLElement} Render function. 130 */ 131 getRenderFunction: function() { 132 return this.list_.renderFunction_; 133 }, 134 135 /** 136 * Sets render function for row. 137 * @param {Function(*, cr.ui.Table): HTMLElement} Render function. 138 */ 139 setRenderFunction: function(renderFunction) { 140 if (renderFunction === this.list_.renderFunction_) 141 return; 142 143 this.list_.renderFunction_ = renderFunction; 144 cr.dispatchSimpleEvent(this, 'change'); 145 }, 146 147 /** 148 * The header of the table. 149 * 150 * @type {cr.ui.table.TableColumnModel} 151 */ 152 get header() { 153 return this.header_; 154 }, 155 156 /** 157 * Initializes the element. 158 */ 159 decorate: function() { 160 this.header_ = this.ownerDocument.createElement('div'); 161 this.list_ = this.ownerDocument.createElement('list'); 162 163 this.appendChild(this.header_); 164 this.appendChild(this.list_); 165 166 TableList.decorate(this.list_); 167 this.list_.selectionModel = new ListSelectionModel(this); 168 this.list_.table = this; 169 this.list_.addEventListener('scroll', this.handleScroll_.bind(this)); 170 171 TableHeader.decorate(this.header_); 172 this.header_.table = this; 173 174 this.classList.add('table'); 175 176 this.boundResize_ = this.resize.bind(this); 177 this.boundHandleSorted_ = this.handleSorted_.bind(this); 178 this.boundHandleChangeList_ = this.handleChangeList_.bind(this); 179 180 // The contained list should be focusable, not the table itself. 181 if (this.hasAttribute('tabindex')) { 182 this.list_.setAttribute('tabindex', this.getAttribute('tabindex')); 183 this.removeAttribute('tabindex'); 184 } 185 186 this.addEventListener('focus', this.handleElementFocus_, true); 187 this.addEventListener('blur', this.handleElementBlur_, true); 188 }, 189 190 /** 191 * Redraws the table. 192 */ 193 redraw: function(index) { 194 this.list_.redraw(); 195 this.header_.redraw(); 196 }, 197 198 startBatchUpdates: function() { 199 this.list_.startBatchUpdates(); 200 this.header_.startBatchUpdates(); 201 }, 202 203 endBatchUpdates: function() { 204 this.list_.endBatchUpdates(); 205 this.header_.endBatchUpdates(); 206 }, 207 208 /** 209 * Resize the table columns. 210 */ 211 resize: function() { 212 // We resize columns only instead of full redraw. 213 this.list_.resize(); 214 this.header_.resize(); 215 }, 216 217 /** 218 * Ensures that a given index is inside the viewport. 219 * @param {number} i The index of the item to scroll into view. 220 * @return {boolean} Whether any scrolling was needed. 221 */ 222 scrollIndexIntoView: function(i) { 223 this.list_.scrollIndexIntoView(i); 224 }, 225 226 /** 227 * Find the list item element at the given index. 228 * @param {number} index The index of the list item to get. 229 * @return {ListItem} The found list item or null if not found. 230 */ 231 getListItemByIndex: function(index) { 232 return this.list_.getListItemByIndex(index); 233 }, 234 235 /** 236 * This handles data model 'sorted' event. 237 * After sorting we need to redraw header 238 * @param {Event} e The 'sorted' event. 239 */ 240 handleSorted_: function(e) { 241 this.header_.redraw(); 242 }, 243 244 /** 245 * This handles data model 'change' and 'splice' events. 246 * Since they may change the visibility of scrollbar, table may need to 247 * re-calculation the width of column headers. 248 * @param {Event} e The 'change' or 'splice' event. 249 */ 250 handleChangeList_: function(e) { 251 requestAnimationFrame(this.header_.updateWidth.bind(this.header_)); 252 }, 253 254 /** 255 * This handles list 'scroll' events. Scrolls the header accordingly. 256 * @param {Event} e Scroll event. 257 */ 258 handleScroll_: function(e) { 259 this.header_.style.marginLeft = -this.list_.scrollLeft + 'px'; 260 }, 261 262 /** 263 * Sort data by the given column. 264 * @param {number} i The index of the column to sort by. 265 */ 266 sort: function(i) { 267 var cm = this.columnModel_; 268 var sortStatus = this.list_.dataModel.sortStatus; 269 if (sortStatus.field == cm.getId(i)) { 270 var sortDirection = sortStatus.direction == 'desc' ? 'asc' : 'desc'; 271 this.list_.dataModel.sort(sortStatus.field, sortDirection); 272 } else { 273 this.list_.dataModel.sort(cm.getId(i), cm.getDefaultOrder(i)); 274 } 275 if (this.selectionModel.selectedIndex == -1) 276 this.list_.scrollTop = 0; 277 }, 278 279 /** 280 * Called when an element in the table is focused. Marks the table as having 281 * a focused element, and dispatches an event if it didn't have focus. 282 * @param {Event} e The focus event. 283 * @private 284 */ 285 handleElementFocus_: function(e) { 286 if (!this.hasElementFocus) { 287 this.hasElementFocus = true; 288 // Force styles based on hasElementFocus to take effect. 289 this.list_.redraw(); 290 } 291 }, 292 293 /** 294 * Called when an element in the table is blurred. If focus moves outside 295 * the table, marks the table as no longer having focus and dispatches an 296 * event. 297 * @param {Event} e The blur event. 298 * @private 299 */ 300 handleElementBlur_: function(e) { 301 // When the blur event happens we do not know who is getting focus so we 302 // delay this a bit until we know if the new focus node is outside the 303 // table. 304 var table = this; 305 var list = this.list_; 306 var doc = e.target.ownerDocument; 307 window.setTimeout(function() { 308 var activeElement = doc.activeElement; 309 if (!table.contains(activeElement)) { 310 table.hasElementFocus = false; 311 // Force styles based on hasElementFocus to take effect. 312 list.redraw(); 313 } 314 }); 315 }, 316 317 /** 318 * Adjust column width to fit its content. 319 * @param {number} index Index of the column to adjust width. 320 */ 321 fitColumn: function(index) { 322 var list = this.list_; 323 var listHeight = list.clientHeight; 324 325 var cm = this.columnModel_; 326 var dm = this.dataModel; 327 var columnId = cm.getId(index); 328 var doc = this.ownerDocument; 329 var render = cm.getRenderFunction(index); 330 var table = this; 331 var MAXIMUM_ROWS_TO_MEASURE = 1000; 332 333 // Create a temporaty list item, put all cells into it and measure its 334 // width. Then remove the item. It fits "list > *" CSS rules. 335 var container = doc.createElement('li'); 336 container.style.display = 'inline-block'; 337 container.style.textAlign = 'start'; 338 // The container will have width of the longest cell. 339 container.style.webkitBoxOrient = 'vertical'; 340 341 // Ensure all needed data available. 342 dm.prepareSort(columnId, function() { 343 // Select at most MAXIMUM_ROWS_TO_MEASURE items around visible area. 344 var items = list.getItemsInViewPort(list.scrollTop, listHeight); 345 var firstIndex = Math.floor(Math.max(0, 346 (items.last + items.first - MAXIMUM_ROWS_TO_MEASURE) / 2)); 347 var lastIndex = Math.min(dm.length, 348 firstIndex + MAXIMUM_ROWS_TO_MEASURE); 349 for (var i = firstIndex; i < lastIndex; i++) { 350 var item = dm.item(i); 351 var div = doc.createElement('div'); 352 div.className = 'table-row-cell'; 353 div.appendChild(render(item, columnId, table)); 354 container.appendChild(div); 355 } 356 list.appendChild(container); 357 var width = parseFloat(window.getComputedStyle(container).width); 358 list.removeChild(container); 359 cm.setWidth(index, width); 360 }); 361 }, 362 363 normalizeColumns: function() { 364 this.columnModel.normalizeWidths(this.clientWidth); 365 } 366 }; 367 368 /** 369 * Whether the table or one of its descendents has focus. This is necessary 370 * because table contents can contain controls that can be focused, and for 371 * some purposes (e.g., styling), the table can still be conceptually focused 372 * at that point even though it doesn't actually have the page focus. 373 */ 374 cr.defineProperty(Table, 'hasElementFocus', cr.PropertyKind.BOOL_ATTR); 375 376 return { 377 Table: Table 378 }; 379 }); 380