// 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. /** * @fileoverview This implements a table control. */ cr.define('cr.ui', function() { /** @const */ var ListSelectionModel = cr.ui.ListSelectionModel; /** @const */ var ListSelectionController = cr.ui.ListSelectionController; /** @const */ var ArrayDataModel = cr.ui.ArrayDataModel; /** @const */ var TableColumnModel = cr.ui.table.TableColumnModel; /** @const */ var TableList = cr.ui.table.TableList; /** @const */ var TableHeader = cr.ui.table.TableHeader; /** * Creates a new table element. * @param {Object=} opt_propertyBag Optional properties. * @constructor * @extends {HTMLDivElement} */ var Table = cr.ui.define('div'); Table.prototype = { __proto__: HTMLDivElement.prototype, columnModel_: new TableColumnModel([]), /** * The table data model. * * @type {cr.ui.ArrayDataModel} */ get dataModel() { return this.list_.dataModel; }, set dataModel(dataModel) { if (this.list_.dataModel != dataModel) { if (this.list_.dataModel) { this.list_.dataModel.removeEventListener('sorted', this.boundHandleSorted_); this.list_.dataModel.removeEventListener('change', this.boundHandleChangeList_); this.list_.dataModel.removeEventListener('splice', this.boundHandleChangeList_); } this.list_.dataModel = dataModel; if (this.list_.dataModel) { this.list_.dataModel.addEventListener('sorted', this.boundHandleSorted_); this.list_.dataModel.addEventListener('change', this.boundHandleChangeList_); this.list_.dataModel.addEventListener('splice', this.boundHandleChangeList_); } this.header_.redraw(); } }, /** * The list of table. * * @type {cr.ui.list} */ get list() { return this.list_; }, /** * The table column model. * * @type {cr.ui.table.TableColumnModel} */ get columnModel() { return this.columnModel_; }, set columnModel(columnModel) { if (this.columnModel_ != columnModel) { if (this.columnModel_) this.columnModel_.removeEventListener('resize', this.boundResize_); this.columnModel_ = columnModel; if (this.columnModel_) this.columnModel_.addEventListener('resize', this.boundResize_); this.list_.invalidate(); this.redraw(); } }, /** * The table selection model. * * @type * {cr.ui.ListSelectionModel|cr.ui.table.ListSingleSelectionModel} */ get selectionModel() { return this.list_.selectionModel; }, set selectionModel(selectionModel) { if (this.list_.selectionModel != selectionModel) { if (this.dataModel) selectionModel.adjustLength(this.dataModel.length); this.list_.selectionModel = selectionModel; } }, /** * The accessor to "autoExpands" property of the list. * * @type {boolean} */ get autoExpands() { return this.list_.autoExpands; }, set autoExpands(autoExpands) { this.list_.autoExpands = autoExpands; }, get fixedHeight() { return this.list_.fixedHeight; }, set fixedHeight(fixedHeight) { this.list_.fixedHeight = fixedHeight; }, /** * Returns render function for row. * @return {Function(*, cr.ui.Table): HTMLElement} Render function. */ getRenderFunction: function() { return this.list_.renderFunction_; }, /** * Sets render function for row. * @param {Function(*, cr.ui.Table): HTMLElement} Render function. */ setRenderFunction: function(renderFunction) { if (renderFunction === this.list_.renderFunction_) return; this.list_.renderFunction_ = renderFunction; cr.dispatchSimpleEvent(this, 'change'); }, /** * The header of the table. * * @type {cr.ui.table.TableColumnModel} */ get header() { return this.header_; }, /** * Initializes the element. */ decorate: function() { this.header_ = this.ownerDocument.createElement('div'); this.list_ = this.ownerDocument.createElement('list'); this.appendChild(this.header_); this.appendChild(this.list_); TableList.decorate(this.list_); this.list_.selectionModel = new ListSelectionModel(this); this.list_.table = this; this.list_.addEventListener('scroll', this.handleScroll_.bind(this)); TableHeader.decorate(this.header_); this.header_.table = this; this.classList.add('table'); this.boundResize_ = this.resize.bind(this); this.boundHandleSorted_ = this.handleSorted_.bind(this); this.boundHandleChangeList_ = this.handleChangeList_.bind(this); // The contained list should be focusable, not the table itself. if (this.hasAttribute('tabindex')) { this.list_.setAttribute('tabindex', this.getAttribute('tabindex')); this.removeAttribute('tabindex'); } this.addEventListener('focus', this.handleElementFocus_, true); this.addEventListener('blur', this.handleElementBlur_, true); }, /** * Redraws the table. */ redraw: function(index) { this.list_.redraw(); this.header_.redraw(); }, startBatchUpdates: function() { this.list_.startBatchUpdates(); this.header_.startBatchUpdates(); }, endBatchUpdates: function() { this.list_.endBatchUpdates(); this.header_.endBatchUpdates(); }, /** * Resize the table columns. */ resize: function() { // We resize columns only instead of full redraw. this.list_.resize(); this.header_.resize(); }, /** * Ensures that a given index is inside the viewport. * @param {number} index The index of the item to scroll into view. * @return {boolean} Whether any scrolling was needed. */ scrollIndexIntoView: function(i) { this.list_.scrollIndexIntoView(i); }, /** * Find the list item element at the given index. * @param {number} index The index of the list item to get. * @return {ListItem} The found list item or null if not found. */ getListItemByIndex: function(index) { return this.list_.getListItemByIndex(index); }, /** * This handles data model 'sorted' event. * After sorting we need to redraw header * @param {Event} e The 'sorted' event. */ handleSorted_: function(e) { this.header_.redraw(); }, /** * This handles data model 'change' and 'splice' events. * Since they may change the visibility of scrollbar, table may need to * re-calculation the width of column headers. * @param {Event} e The 'change' or 'splice' event. */ handleChangeList_: function(e) { webkitRequestAnimationFrame(this.header_.updateWidth.bind(this.header_)); }, /** * This handles list 'scroll' events. Scrolls the header accordingly. * @param {Event} e Scroll event. */ handleScroll_: function(e) { this.header_.style.marginLeft = -this.list_.scrollLeft + 'px'; }, /** * Sort data by the given column. * @param {number} index The index of the column to sort by. */ sort: function(i) { var cm = this.columnModel_; var sortStatus = this.list_.dataModel.sortStatus; if (sortStatus.field == cm.getId(i)) { var sortDirection = sortStatus.direction == 'desc' ? 'asc' : 'desc'; this.list_.dataModel.sort(sortStatus.field, sortDirection); } else { this.list_.dataModel.sort(cm.getId(i), cm.getDefaultOrder(i)); } if (this.selectionModel.selectedIndex == -1) this.list_.scrollTop = 0; }, /** * Called when an element in the table is focused. Marks the table as having * a focused element, and dispatches an event if it didn't have focus. * @param {Event} e The focus event. * @private */ handleElementFocus_: function(e) { if (!this.hasElementFocus) { this.hasElementFocus = true; // Force styles based on hasElementFocus to take effect. this.list_.redraw(); } }, /** * Called when an element in the table is blurred. If focus moves outside * the table, marks the table as no longer having focus and dispatches an * event. * @param {Event} e The blur event. * @private */ handleElementBlur_: function(e) { // When the blur event happens we do not know who is getting focus so we // delay this a bit until we know if the new focus node is outside the // table. var table = this; var list = this.list_; var doc = e.target.ownerDocument; window.setTimeout(function() { var activeElement = doc.activeElement; if (!table.contains(activeElement)) { table.hasElementFocus = false; // Force styles based on hasElementFocus to take effect. list.redraw(); } }); }, /** * Adjust column width to fit its content. * @param {number} index Index of the column to adjust width. */ fitColumn: function(index) { var list = this.list_; var listHeight = list.clientHeight; var cm = this.columnModel_; var dm = this.dataModel; var columnId = cm.getId(index); var doc = this.ownerDocument; var render = cm.getRenderFunction(index); var table = this; var MAXIMUM_ROWS_TO_MEASURE = 1000; // Create a temporaty list item, put all cells into it and measure its // width. Then remove the item. It fits "list > *" CSS rules. var container = doc.createElement('li'); container.style.display = 'inline-block'; container.style.textAlign = 'start'; // The container will have width of the longest cell. container.style.webkitBoxOrient = 'vertical'; // Ensure all needed data available. dm.prepareSort(columnId, function() { // Select at most MAXIMUM_ROWS_TO_MEASURE items around visible area. var items = list.getItemsInViewPort(list.scrollTop, listHeight); var firstIndex = Math.floor(Math.max(0, (items.last + items.first - MAXIMUM_ROWS_TO_MEASURE) / 2)); var lastIndex = Math.min(dm.length, firstIndex + MAXIMUM_ROWS_TO_MEASURE); for (var i = firstIndex; i < lastIndex; i++) { var item = dm.item(i); var div = doc.createElement('div'); div.className = 'table-row-cell'; div.appendChild(render(item, columnId, table)); container.appendChild(div); } list.appendChild(container); var width = parseFloat(window.getComputedStyle(container).width); list.removeChild(container); cm.setWidth(index, width); }); }, normalizeColumns: function() { this.columnModel.normalizeWidths(this.clientWidth); } }; /** * Whether the table or one of its descendents has focus. This is necessary * because table contents can contain controls that can be focused, and for * some purposes (e.g., styling), the table can still be conceptually focused * at that point even though it doesn't actually have the page focus. */ cr.defineProperty(Table, 'hasElementFocus', cr.PropertyKind.BOOL_ATTR); return { Table: Table }; });