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
9cr.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