1
YUI.add('tablescroll', function (Y, NAME) {
5
* A scrollable datatable based on some of the functionality of
6
* slickgrids but using tables.
11
Y.u1.TableScroll = Y.Base.create("TableScroll", Y.Widget, [], {
12
initializer: function() {
13
// Set width of scrollbars
14
this.set("nScrollbarWidth", this.getScrollbarWidth());
17
var oNodeContainer = this.get("oNodeContainer"),
18
oRowSpec = this.get("oRowSpec");
20
// Set attrs for table pieces
21
this.set("oNodeTable", oNodeContainer.one("table"));
22
this.set("oNodeTbody", oNodeContainer.one("tbody"));
23
this.set("oNodeThead", oNodeContainer.one("thead"));
24
this.set("oNodeTfoot", oNodeContainer.one("tfoot"));
26
// Init a stylenode for us to manipulate to provide column updates.
27
this.styleNode = new Y.u1.StyleNode();
34
this.dataCache = new Y.Cache({max: this.get("nMaxDataCacheItems"), uniqueKeys: true});
36
if (!oRowSpec || !oRowSpec.preDataTemplate || !oRowSpec.postDataTemplate) {
37
window.alert("Please define oRowSpec attribute as an object with 'preDataTemplate' and 'postDataTemplate' template node properties.");
40
this.set('sRowPreDataTemplate', Y.one(oRowSpec.preDataTemplate).getContent());
41
this.set('sRowPostDataTemplate', Y.one(oRowSpec.postDataTemplate).getContent());
45
debugInit: function() {
46
var oNodeDebugCont = Y.Node.create("<div class='isroll-debug'><dl></dl></div>"),
47
oNodeDl = oNodeDebugCont.one("dl"),
48
oNodeNumTrs = Y.Node.create('<dt>Number Trs: </dt><dd id="tablescroll-ntrs"></dd>'),
49
oNodeNumTotalRecords = Y.Node.create('<dt>Total No. of Records: </dt><dd id="tablescroll-totrs"></dd>'),
50
oNodeNumDataPages = Y.Node.create('<dt>Number Data Pages: </dt><dd id="tablescroll-ndps"></dd>'),
51
oNodeCurDataPages = Y.Node.create('<dt>Current Data Page: </dt><dd id="tablescroll-cdp"></dd>'),
52
oNodeLastScrollTop = Y.Node.create('<dt>LastScrollTop: </dt><dd id="tablescroll-lst"></dd>'),
53
oNodeTotalScrollHeight = Y.Node.create('<dt>Total Scroll Height: </dt><dd id="tablescroll-th"></dd>'),
54
oNodeRangeVisibleTop = Y.Node.create('<dt>Range visible Top: </dt><dd id="tablescroll-orv-top"></dd>'),
55
oNodeRangeVisibleMiddle = Y.Node.create('<dt>Range visible Middle: </dt><dd id="tablescroll-orv-middle"></dd>'),
56
oNodeRangeVisibleBottom = Y.Node.create('<dt>Range visible Bottom: </dt><dd id="tablescroll-orv-bottom"></dd>'),
57
toAppend = [oNodeNumTrs, oNodeNumDataPages, oNodeCurDataPages,
58
oNodeLastScrollTop, oNodeNumTotalRecords, oNodeTotalScrollHeight,
59
oNodeRangeVisibleTop, oNodeRangeVisibleMiddle, oNodeRangeVisibleBottom],
63
oNodeDl.append(toAppend[i]);
66
Y.Node.one("body").append(oNodeDebugCont);
67
oNodeDebugCont.setStyles({
76
this.after("nLastScrollTopChange", function(e) {
77
Y.Node.one("#tablescroll-lst").setContent(e.newVal);
79
this.after("aCurrentDataPageChange", function(e) {
80
Y.Node.one("#tablescroll-cdp").setContent(e.newVal);
82
this.after("nNumTrsChange", function(e) {
83
Y.Node.one("#tablescroll-ntrs").setContent(e.newVal);
85
this.after("nTotalDataPagesChange", function(e) {
86
Y.Node.one("#tablescroll-ndps").setContent(e.newVal);
88
this.after("nScrollbarHeightChange", function(e) {
89
Y.Node.one("#tablescroll-th").setContent(e.newVal);
91
this.after("nTotalRecordsChange", function(e) {
92
Y.Node.one("#tablescroll-totrs").setContent(e.newVal);
94
this.after("nRangeVisibleTopChange", function(e) {
95
Y.Node.one("#tablescroll-orv-top").setContent(e.newVal);
97
this.after("nRangeVisibleMiddleChange", function(e) {
98
Y.Node.one("#tablescroll-orv-middle").setContent(e.newVal);
100
this.after("nRangeVisibleBottomChange", function(e) {
101
Y.Node.one("#tablescroll-orv-bottom").setContent(e.newVal);
103
this.set("nNumTrs", this.get("oNodeContainer").all("tbody tr").size());
105
renderUI: function() {
107
var bDebugMode = this.get("bDebugMode"),
109
oNodeContainer = this.get("oNodeContainer"),
112
oNodeTheadCloneTable,
116
if (bDebugMode === true) {
120
oNodeThead = this.get("oNodeThead");
121
oNodeTable = this.get("oNodeTable");
123
// Get an identifier on the table so we can directly attach styles.
124
if (!oNodeContainer.get("id")) {
125
oNodeContainer.set("id", Y.guid());
129
oNodeTheadClone = oNodeThead.cloneNode(true);
130
this.set("oNodeTheadClone", oNodeTheadClone);
132
// Clone our "real" thead and place it above the main table of content
133
// Yeah this feels wrong, but there's little other options and building this
134
// entire thing out of lists feels even more of a violation.
135
nTheadHeight = oNodeThead.get("offsetHeight");
136
oNodeTheadCloneDiv = Y.Node.create("<div class='tablescroll-thead'><table></table></div>");
138
oNodeTheadCloneDiv.setStyle("zoom", 1);
140
oNodeTheadCloneTable = oNodeTheadCloneDiv.one("table");
141
oNodeTheadCloneTable.append(oNodeTheadClone);
142
oNodeContainer.insertBefore(oNodeTheadCloneDiv, oNodeContainer);
145
// Set styles on the Container Node
146
oNodeContainer.setStyles({
147
"height": this.get('nContainerHeight'),
148
"position": "relative",
149
"overflowX": "hidden",
150
"overflowY": "scroll"
153
// Set some initial styles on our table
154
oNodeTable.setStyles({"height": -nTheadHeight,
155
"display": "block"});
158
var oNodeContainer = this.get("oNodeContainer"),
160
// Scroll and mousewheel events - these are checked for actual
161
// scrolling in the callback
164
if (Y.UA.mobile > 0) {
165
scrollFunc = Y.throttle(Y.bind(this.onScroll, this), 100);
167
scrollFunc = Y.bind(this.onScroll, this);
169
Y.on("mousewheel", scrollFunc);
170
oNodeContainer.on("scroll", scrollFunc);
172
// Update the table height when we know what data we are dealing with.
173
// The first time this happens will be after the first data set loads.
174
this.after("nTotalRecordsChange", this.onTotalRecordsChange, this);
175
// Watch for the page change so we can work out what data we need to get
176
this.after("aCurrentDataPageChange", this.onDataPageChange, this);
178
this.after('visibleChange', this.afterVisibleChange, this);
180
Y.on("windowresize", Y.bind(this.syncUI, this));
184
if (this.get("visible") === true) {
186
this.setColumnWidths();
189
setColumnWidths: function() {
190
var aColumnSpec = this.get("aColumnSpec"),
194
nHorizPaddingAndBorder,
197
nFixedWidthTotal = 0,
201
nVertPaddingAndBorder,
203
oNodeTable = this.get("oNodeTable"),
204
nCurrentTableWidth = oNodeTable.get("offsetWidth"),
209
sContainerId = this.get("oNodeContainer").get("id"),
215
for (i=0, j=aColumnSpec.length; i < j; i++) {
216
nPixelWidth = aColumnSpec[i].width;
217
nFlexWidth = aColumnSpec[i].flexwidth;
219
if (typeof nPixelWidth !== "undefined") {
220
nFixedWidthTotal += parseInt(nPixelWidth, 10);
222
if (typeof nFlexWidth !== "undefined") {
223
nPercWidthTotal += parseInt(nFlexWidth, 10);
227
// Get the remaining width we have to play with for the flexible columns.
228
nFlexWidthRemainder = nCurrentTableWidth - nFixedWidthTotal;
230
// Get NodeList containing first row of Tds.
231
oNodeListTds = this.get("oNodeTbody").all("tr:first-child td");
232
if (oNodeListTds.size() === 0) {
236
// Webkit seems to have trouble calculating the padding + border unless something is taking up vertical space
237
// To get around that we drop a div set to the height of the row and then swap it back out.
238
oNodeFirstTd = oNodeListTds.item(0);
239
sOrigContent = oNodeFirstTd.getContent();
240
oNodeFirstTd.setContent(Y.Node.create("<div style='height:" + this.get("nRowHeight") + "px'></div>"));
241
nVertPaddingAndBorder = oNodeFirstTd.get("offsetHeight") - parseInt(oNodeFirstTd.getComputedStyle("height"), 10);
242
oNodeFirstTd.setContent(sOrigContent);
244
// Initial Styles - this truncates the table-cells + ensure the height matches that of the rows.
246
"white-space: nowrap;",
247
"position: absolute;",
249
"height: "+ (this.get("nRowHeight") - nVertPaddingAndBorder)+ "px;"
253
"#" + sContainerId + " table" + "{ table-layout:fixed }",
254
"#" + sContainerId + " tr" + "{ width: " + nCurrentTableWidth +"px; }",
255
"#" + sContainerId + " td { "+ aTdRules.join(" ") +"}"
258
// Some rules for displaying an ellipsis for truncated text.
259
// Only used if ellipsis = true in aColumnSpec
261
"text-overflow: ellipsis;",
262
"-o-text-overflow: ellipsis;",
263
"-ms-text-overflow: ellipsis;"
266
for (i=0, j=aColumnSpec.length; i < j; i++) {
267
nPixelWidth = aColumnSpec[i].width;
268
oNodeCurTd = oNodeListTds.item(i);
270
nFlexWidth = aColumnSpec[i].flexwidth;
271
if (typeof nPixelWidth !== "undefined") {
272
nWidth = nPixelWidth;
274
if (typeof nFlexWidth !== "undefined") {
275
nWidth = (nFlexWidthRemainder / 100) * nFlexWidth;
277
// Allow for padding + border in the width
278
nHorizPaddingAndBorder = (oNodeCurTd.get("offsetWidth") - parseInt(oNodeCurTd.getComputedStyle("width"), 10));
279
nWidth = nWidth - nHorizPaddingAndBorder;
281
if (aColumnSpec[i].ellipsis === true) {
282
sExtraRules = aEllipsisRules.join(" ");
284
aStyleRules.push("#" + sContainerId + " .c" + (i+1) + "{ left: " + nLastWidth +"px; width:" + nWidth +"px; "+sExtraRules+"}");
286
// Allow for padding + border for the next left value
287
nLastWidth += nWidth + nHorizPaddingAndBorder;
290
// Blow away an existing styleNode so we can recreate it.
291
oNodeStyle = this.styleNode.get("styleNode");
292
if (oNodeStyle !== null) {
295
this.styleNode.create(aStyleRules);
296
this.updateThWidths();
298
updateThWidths: function() {
299
var tableCells = this.get("oNodeTbody").all("tr:first-child td"),
300
tableHeadings = this.get("oNodeTheadClone").all("th"),
302
numTds = tableCells.size();
303
tableCells.each(function(elm, i){
304
// Bail on the last th as we want this to fill all space available.
305
if (i+1 === numTds) {
308
elmWidth = elm.get("offsetWidth");
310
tableHeadings.item(i).set("offsetWidth", elmWidth);
314
onTotalRecordsChange: function(e){
315
if (e.newVal === e.prevVal) {
318
var nScrollbarHeight = e.newVal * this.get('nRowHeight');
319
this.get("oNodeTable").setStyle("height", nScrollbarHeight);
320
this.set("nScrollbarHeight", nScrollbarHeight);
321
this.set("nTotalDataPages", Math.ceil(nScrollbarHeight / (this.get("nRowHeight") * this.get("nRecordLimit"))));
322
if (typeof e.prevVal !== "undefined") {
323
this.dataCache.flush();
327
onDataPageChange: function(e){
329
// If prev value matches the new value then bail
330
if (e.prevVal === e.newVal) {
334
var aCurrentDataPage = e.newVal;
336
// If timer is already in progress abort it.
337
if (this.timer !== null) {
338
clearTimeout(this.timer);
341
// Set-up a timer to check we're still on the same page after a delay.
342
// This helps to avoid fetching data unecessarily if we are scrolling fast.
343
this.timer = setTimeout(Y.bind(function(o){
345
nRecordLimit = this.get("nRecordLimit"),
346
aCurrentDataPage = o.currentPage,
351
if (this.get("aCurrentDataPage") === aCurrentDataPage) {
353
// Build a list of what pages we require - checking that the cache value is undefined
354
// It will be null if fetching is already in progress in which case we won't fetch it.
355
for (i=0, j=aCurrentDataPage.length; i < j; i++) {
356
page = aCurrentDataPage[i];
357
// yui3's cache returns null if the key isn't present
358
if (this.dataCache.retrieve("is-" + page) === null) {
363
// If there's nothing to fetch then exit at this point.
364
if (pageList.length === 0){
368
// The first item in the list will always be the earliest page we need.
369
offset = (pageList[0] - 1) * nRecordLimit;
370
limit = (nRecordLimit * pageList.length);
372
// Mark this as pending data - so we don't make subsequent reqs for it inbetween now
373
// and actually receiving the data.
374
for (i=0, j=pageList.length; i<j; i++) {
375
this.dataCache.add("is-" + pageList[i], "pending");
377
// Merge the updated data.
378
this.fetchData(offset, limit);
382
"currentPage": aCurrentDataPage
383
}), this.get("nDataFetchDelay"));
386
/* Called following a successful first data load */
387
onInitialData: function(){
388
// Run onScroll - this will deal with the issue of loading a
389
// non-zero scroll start point.
392
/* Abstracted method to get data - assumes the datasource method supplied
393
* in the config accepts an object with success/failure/offset/limit keys
395
* offset is the record to start from
396
* limit is the limit of the number of records to fetch.
398
fetchData: function(offset, limit) {
399
this.get("oDataSource")[this.get("sDataSourceMethod")]({
400
"success": Y.rbind(this.onIOSuccess, this, offset, limit),
401
"failure": Y.rbind(this.onIOFailure, this, offset, limit),
406
/* following a successful data load */
407
onIOSuccess: function(e, offset, limit) {
408
var nRecordLimit = this.get("nRecordLimit"),
409
records = this.get("fRecordListSchema")(e),
410
startPage = Math.ceil((offset / nRecordLimit) + 1),
413
// We always check for changes to the total number of records.
414
// So we can update for new data as it's added behind the scenes.
415
nTotalRecords = this.get("fRecordTotalSchema")(e);
416
this.set("nTotalRecords", nTotalRecords);
417
this.set("nTotalPages", nTotalRecords / this.get("nRecordLimit"));
419
if (!this.get("bLoadedInitialData")){
420
this.onInitialData(e);
423
// Stash the current dataset away under the page num as a key.
424
// Handle the case where we fetch two pages at once for when
425
// we are straddling data pages.
426
if (limit > nRecordLimit) {
427
this.dataCache.add("is-" + startPage, records.slice(0, nRecordLimit));
428
this.dataCache.add("is-" + (startPage+1), records.slice(nRecordLimit, limit));
430
this.dataCache.add("is-" + startPage, records);
436
if (!this.get("bLoadedInitialData")){
437
this.setColumnWidths();
438
this.set("bLoadedInitialData", true);
442
/* Failure to get data
443
* This could simply mean network is temporarily bad
444
* TODO: This function should allow for a retry
445
* TODO: This needs to clear null values in the cache so retries can happen.
447
onIOFailure: function() {
448
Y.log("oh dear something went wrong here");
450
/* Reset the Data cache */
451
clearDataCache: function(){
452
this.dataCache.flush();
454
// Clear out the rows
455
clearRows: function() {
456
for (var i in this.rows){
460
removeRow: function(index) {
462
this.rows[index].remove(true);
463
// Remove from the cache
464
delete this.rows[index];
466
/* Based on the current data page get the data for this row num */
467
getRowData: function(nRow) {
468
var nRecordLimit = this.get("nRecordLimit"),
469
page = Math.ceil((nRow + 1) / nRecordLimit),
473
page = page > 0 ? page : 1;
475
offsetIndex = Math.floor((nRow - ((page - 1) * nRecordLimit)));
478
// Y.log("rownum: " + (nRow + 1) + " nRow: " + nRow + " page: " + page + " offsetIndex: " + offsetIndex );
480
dataset = this.dataCache.retrieve("is-" + page);
481
if (dataset && dataset.response !== "pending" && dataset.response[offsetIndex]) {
482
return dataset.response[offsetIndex];
485
/* Renders the current view of the visible data */
486
renderWidget: function() {
487
var oNodeContainer = this.get("oNodeContainer"),
488
nRowHeight = this.get("nRowHeight"),
489
nScrollTop = oNodeContainer.get("scrollTop"),
490
nContainerHeight = this.get("nContainerHeight"),
491
nBufferHeight = nContainerHeight,
492
oNodeTbody = this.get("oNodeTbody"),
493
bDebugMode = this.get("bDebugMode"),
494
nTotalRecords = this.get('nTotalRecords'),
495
top = Math.floor((nScrollTop-nBufferHeight)/nRowHeight),
496
bottom = Math.ceil((nScrollTop + nContainerHeight + nBufferHeight)/nRowHeight),
502
this.top = top = Math.max(0, top);
503
this.bottom = bottom = Math.min(this.get("nScrollbarHeight")/nRowHeight, bottom);
504
middle = Math.ceil(top + ((bottom - top)/2));
506
// Work out what data page(s) we are currently accessing
507
// This will work out the data page that the top of the rendered rows
508
// falls in as well as the ones that the bottom of the rendered rows sees.
509
// This way if we load and we're straddling two consecutive pages of data we
510
// make one request for data spanning those two pages.
511
this.set("aCurrentDataPage", this.getCurrentDataPage());
514
this.set("nRangeVisibleTop", top);
515
this.set("nRangeVisibleMiddle", middle);
516
this.set("nRangeVisibleBottom", bottom);
519
// Trash Cached rows above and below
521
for (i in this.rows){
522
if (i < top || i > bottom) {
523
this.fire("preRowDestroyed", {"node": this.rows[i]});
524
this.rows[i].remove(true);
529
// render + append + cache the rows between top and bottom values
530
for (i=top; i<=bottom; i++) {
531
if (i > (nTotalRecords - 1)) {
535
node = this.rows[i] = this.renderRow(i);
539
oNodeTbody.append(node);
543
// Get the number of trs currently in play.
544
this.set("nNumTrs", this.get("oNodeContainer").all("tbody tr").size());
547
/* Renders an individual row */
548
renderRow: function(nRow) {
549
var currentRow = { "tablescroll-rownum": nRow + 1 },
550
nRowHeight = this.get("nRowHeight"),
551
node, oRowData, rowData, fCustomRowRenderer;
553
oRowData = this.getRowData(nRow);
555
rowData = Y.merge(oRowData, currentRow);
556
fCustomRowRenderer = this.get("fCustomRowRenderer");
557
if (fCustomRowRenderer && typeof fCustomRowRenderer === "function") {
558
node = fCustomRowRenderer(rowData, nRow, this);
560
node = Y.Node.create(Y.substitute(this.get("sRowPostDataTemplate"), rowData, function(k,v){return Y.Escape.html(v);}));
562
this.fire("rowDataRendered", { "node": node, "data": oRowData });
564
node = Y.Node.create(Y.substitute(this.get("sRowPreDataTemplate"), currentRow, function(k,v){return Y.Escape.html(v);}));
565
this.fire("rowRendered", { "node": node });
568
"top": nRow * nRowHeight + "px",
569
"height": nRowHeight + "px",
570
"position": "absolute"
574
getCurrentDataPage: function(){
575
var nRowHeight = this.get("nRowHeight"),
576
nRecordLimit = this.get("nRecordLimit"),
577
pxHeightDataPage = (nRecordLimit * nRowHeight),
578
topPage, bottomPage, dataPages;
580
topPage = Math.ceil((this.top * nRowHeight) / pxHeightDataPage);
581
topPage = topPage > 0 ? topPage : 1;
582
bottomPage = Math.ceil((this.bottom * nRowHeight) / pxHeightDataPage);
584
topPage = topPage > 0 ? topPage : 1;
585
bottomPage = bottomPage > 0 ? bottomPage : 1;
587
// Only pass a list of one page if they're the same.
588
if (topPage === bottomPage) {
589
dataPages = [topPage];
591
dataPages = [topPage, bottomPage ];
595
onScroll: function(){
596
if (this.get("visible") === true) {
597
// Handle scroll events but ignoring those fired by viewport scrollig.
598
// To work that out we check for changes in scrollTop on the container.
599
var scrollTop = this.get("oNodeContainer").get("scrollTop");
601
// When initialised nLastScrollTop is null so this will execute
602
// on first load too.
603
if (this.get("nLastScrollTop") !== scrollTop) {
604
// Work out what page of data we are on.
607
this.set("nLastScrollTop", scrollTop);
610
/* A way to work out a scrollbar width for most browser implementations
611
* TODO: This is likely to fail for UAs that don't provide scrollbars until
612
* interactions take place e.g. IOS
614
getScrollbarWidth: function() {
615
var scrollbarWidth = 0,
616
div = Y.Node.create('<div style="width:50px;height:50px;position:absolute;top:-999em;left:999em;"><div style="height:100px; width: 100%"></div></div>'),
618
Y.one('body').append(div);
619
w1 = parseInt(div.one("div").get("offsetWidth"), 10);
620
div.setStyle('overflow-y', 'scroll');
621
w2 = parseInt(div.one("div").get("offsetWidth"), 10);
623
scrollbarWidth = w1 - w2;
624
// Return something for a zero result - this means that for OSX where scrollbars are
625
// invisible until interacted with we will still have some space for it.
626
return (scrollbarWidth !== 0) ? scrollbarWidth : 18;
631
/* The column definition */
632
aColumnSpec: { value: null },
633
oRowSpec: { value: null },
634
bDebugMode: { value: false },
635
bLoadedInitialData: { value: false },
636
/* function that can data the data returned by fGetData and
637
* return the list of records to utilise */
638
fRecordListSchema: { value: null },
639
/* function that can take the data returned by dGetData and
640
* return the total number of records */
641
fRecordTotalSchema: { value: null },
642
/* Custom row renderer */
643
fCustomRowRenderer: { value: null },
644
/* Height of container in pixels */
645
nContainerHeight: { value: 400 },
646
/* The current page of data we are on */
647
aCurrentDataPage: { value: [1,1] },
648
/* A delay to introduce before we fetch data so we
649
* don't fetch things when we're scrolling fast */
650
nDataFetchDelay: { value: 250 },
651
/* The current scrollTop value */
652
nLastScrollTop: { value: null },
653
/* The limit of records to fetch */
654
nRecordLimit: { value: 50 },
655
/* The height of the table rows in px */
656
nRowHeight: { value: 50 },
657
/* The scrollbar width is set here */
658
nScrollbarWidth: { value: null },
659
/* The scrollbar height is set here - this is based on num records + row height */
660
nScrollbarHeight: { value: null },
661
/* The number of tr objects used */
662
nNumTrs: { value: null },
663
/* Number of datasets to keep in the cache */
664
nMaxDataCacheItems: { value: 10 },
665
/* The total num of data pages there are */
666
nTotalDataPages: { value: null },
667
nRangeVisibleTop: { value: null },
668
nRangeVisibleMiddle: { value: null },
669
nRangeVisibleBottom: { value: null },
670
/* Node object of the widget container */
672
setter: function(sel) {
677
/* Table parts as Nodes */
678
oNodeTable: { writeOnce: true },
679
oNodeTbody: { writeOnce: true },
680
oNodeTfoot: { writeOnce: true },
681
oNodeThead: { writeOnce: true },
682
oNodeTheadClone: { writeOnce: true },
684
/* A DataIO instance for getting data for the table */
685
oDataSource: { value: null },
686
/* The method of the dataIO instance to use for fetching data */
687
sDataSourceMethod: { value: null },
688
/* The row template content as a string */
689
sRowPreDataTemplate: { value: null },
690
sRowPostDataTemplate: { value: null }