├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── angular-scrollable-table.js ├── angular-scrollable-table.min.js ├── bower.json ├── demo ├── app.js └── index.html ├── package.json └── scrollable-table.css /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | node_modules 19 | 20 | # External tool builders 21 | .externalToolBuilders/ 22 | 23 | # Locally stored "Eclipse launch configurations" 24 | *.launch 25 | 26 | # CDT-specific 27 | .cproject 28 | 29 | # PDT-specific 30 | .buildpath 31 | 32 | 33 | ################# 34 | ## Visual Studio 35 | ################# 36 | 37 | ## Ignore Visual Studio temporary files, build results, and 38 | ## files generated by popular Visual Studio add-ons. 39 | 40 | # User-specific files 41 | *.suo 42 | *.user 43 | *.sln.docstates 44 | 45 | # Build results 46 | 47 | [Dd]ebug/ 48 | [Rr]elease/ 49 | x64/ 50 | build/ 51 | [Bb]in/ 52 | [Oo]bj/ 53 | 54 | # MSTest test Results 55 | [Tt]est[Rr]esult*/ 56 | [Bb]uild[Ll]og.* 57 | 58 | *_i.c 59 | *_p.c 60 | *.ilk 61 | *.meta 62 | *.obj 63 | *.pch 64 | *.pdb 65 | *.pgc 66 | *.pgd 67 | *.rsp 68 | *.sbr 69 | *.tlb 70 | *.tli 71 | *.tlh 72 | *.tmp 73 | *.tmp_proj 74 | *.log 75 | *.vspscc 76 | *.vssscc 77 | .builds 78 | *.pidb 79 | *.log 80 | *.scc 81 | 82 | # Visual C++ cache files 83 | ipch/ 84 | *.aps 85 | *.ncb 86 | *.opensdf 87 | *.sdf 88 | *.cachefile 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | 95 | # Guidance Automation Toolkit 96 | *.gpState 97 | 98 | # ReSharper is a .NET coding add-in 99 | _ReSharper*/ 100 | *.[Rr]e[Ss]harper 101 | 102 | # TeamCity is a build add-in 103 | _TeamCity* 104 | 105 | # DotCover is a Code Coverage Tool 106 | *.dotCover 107 | 108 | # NCrunch 109 | *.ncrunch* 110 | .*crunch*.local.xml 111 | 112 | # Installshield output folder 113 | [Ee]xpress/ 114 | 115 | # DocProject is a documentation generator add-in 116 | DocProject/buildhelp/ 117 | DocProject/Help/*.HxT 118 | DocProject/Help/*.HxC 119 | DocProject/Help/*.hhc 120 | DocProject/Help/*.hhk 121 | DocProject/Help/*.hhp 122 | DocProject/Help/Html2 123 | DocProject/Help/html 124 | 125 | # Click-Once directory 126 | publish/ 127 | 128 | # Publish Web Output 129 | *.Publish.xml 130 | *.pubxml 131 | 132 | # NuGet Packages Directory 133 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 134 | #packages/ 135 | 136 | # Windows Azure Build Output 137 | csx 138 | *.build.csdef 139 | 140 | # Windows Store app package directory 141 | AppPackages/ 142 | 143 | # Others 144 | sql/ 145 | *.Cache 146 | ClientBin/ 147 | [Ss]tyle[Cc]op.* 148 | ~$* 149 | *~ 150 | *.dbmdl 151 | *.[Pp]ublish.xml 152 | *.pfx 153 | *.publishsettings 154 | 155 | # RIA/Silverlight projects 156 | Generated_Code/ 157 | 158 | # Backup & report files from converting an old project file to a newer 159 | # Visual Studio version. Backup files are not needed, because we have git ;-) 160 | _UpgradeReport_Files/ 161 | Backup*/ 162 | UpgradeLog*.XML 163 | UpgradeLog*.htm 164 | 165 | # SQL Server files 166 | App_Data/*.mdf 167 | App_Data/*.ldf 168 | 169 | ############# 170 | ## Windows detritus 171 | ############# 172 | 173 | # Windows image file caches 174 | Thumbs.db 175 | ehthumbs.db 176 | 177 | # Folder config file 178 | Desktop.ini 179 | 180 | # Recycle Bin used on file shares 181 | $RECYCLE.BIN/ 182 | 183 | # Mac crap 184 | .DS_Store 185 | 186 | 187 | ############# 188 | ## Python 189 | ############# 190 | 191 | *.py[co] 192 | 193 | # Packages 194 | *.egg 195 | *.egg-info 196 | dist/ 197 | build/ 198 | eggs/ 199 | parts/ 200 | var/ 201 | sdist/ 202 | develop-eggs/ 203 | .installed.cfg 204 | 205 | # Installer logs 206 | pip-log.txt 207 | 208 | # Unit test / coverage reports 209 | .coverage 210 | .tox 211 | 212 | #Translations 213 | *.mo 214 | 215 | #Mr Developer 216 | .mr.developer.cfg 217 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Alec LaLonde and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | angular-scrollable-table 2 | ======================== 3 | 4 | Yet another table directive for AngularJS. 5 | 6 | This one features a fixed header that elegantly handles overly-long column header names. 7 | 8 | Other features: 9 | * Scroll to row 10 | * Sortable header with custom comparator functions 11 | * Resizable columns 12 | 13 | ## Installation 14 | ``` 15 | npm install angular-scrollable-table 16 | ``` 17 | OR 18 | ``` 19 | bower install angular-scrollable-table 20 | ``` 21 | 22 | ## Usage 23 | 24 | ```js 25 | angular.module('myApp', ['scrollable-table', ...]); 26 | ``` 27 | 28 | ## Example 29 | 30 | ```html 31 | 32 | 33 | 34 | 35 | 36 | ... 37 | 38 | 39 | 40 | 42 | 43 | ... 44 | 45 | 46 |
Facility
{{ proj.facility }}
47 |
48 | ``` 49 | 50 | where the controller contains 51 | 52 | ```js 53 | $scope.visibleProjects = [{ 54 | facility: "Atlanta", 55 | code: "C-RD34", 56 | cost: 540000, 57 | conditionRating: 52, 58 | extent: 100, 59 | planYear: 2014 60 | }, ...]; 61 | 62 | $scope.$watch('selected', function(fac) { 63 | $scope.$broadcast("rowSelected", fac); 64 | }); 65 | }) 66 | ``` 67 | 68 | Third-party dependencies: 69 | * jQuery 70 | * Bootstrap 3 CSS (for styling, optional. See the 'bootstrap2' branch also) 71 | 72 | Demo here: https://jsfiddle.net/alalonde/BrTzg/ 73 | 74 | More infomation here: http://blog.boxelderweb.com/2013/12/19/angularjs-fixed-header-scrollable-table/ 75 | 76 | License: MIT 77 | 78 | ## FAQ 79 | 80 | 1. How do I change the height of the table? 81 | 82 | See here: https://jsfiddle.net/alalonde/qgc2gp7d/2/ 83 | -------------------------------------------------------------------------------- /angular-scrollable-table.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 'use strict'; 3 | 4 | var isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; 5 | angular.module('scrollable-table', []) 6 | .directive('scrollableTable', ['$timeout', '$q', '$parse', function ($timeout, $q, $parse) { 7 | return { 8 | transclude: true, 9 | restrict: 'E', 10 | scope: { 11 | rows: '=watch', 12 | sortFn: '=' 13 | }, 14 | template: '
' + 15 | '
' + 16 | '
' + 17 | '
', 18 | controller: ['$scope', '$element', '$attrs', function ($scope, $element, $attrs) { 19 | // define an API for child directives to view and modify sorting parameters 20 | this.getSortExpr = function () { 21 | return $scope.sortExpr; 22 | }; 23 | this.isAsc = function () { 24 | return $scope.asc; 25 | }; 26 | this.setSortExpr = function (exp) { 27 | $scope.asc = true; 28 | $scope.sortExpr = exp; 29 | }; 30 | this.toggleSort = function () { 31 | $scope.asc = !$scope.asc; 32 | }; 33 | 34 | this.doSort = function (comparatorFn) { 35 | if (comparatorFn) { 36 | $scope.rows.sort(function (r1, r2) { 37 | var compared = comparatorFn(r1, r2); 38 | return $scope.asc ? compared : compared * -1; 39 | }); 40 | } else { 41 | $scope.rows.sort(function (r1, r2) { 42 | var compared = defaultCompare(r1, r2); 43 | return $scope.asc ? compared : compared * -1; 44 | }); 45 | } 46 | }; 47 | 48 | this.renderTalble = function (){ 49 | return waitForRender().then(fixHeaderWidths); 50 | }; 51 | 52 | this.getTableElement = function (){ 53 | return $element; 54 | }; 55 | 56 | /** 57 | * append handle function to execute after table header resize. 58 | */ 59 | this.appendTableResizingHandler = function (handler){ 60 | var handlerSequence = $scope.headerResizeHanlers || []; 61 | for(var i = 0;i < handlerSequence.length;i++){ 62 | if(handlerSequence[i].name === handler.name){ 63 | return; 64 | } 65 | } 66 | handlerSequence.push(handler); 67 | $scope.headerResizeHanlers = handlerSequence; 68 | }; 69 | 70 | function defaultCompare(row1, row2) { 71 | var exprParts = $scope.sortExpr.match(/(.+)\s+as\s+(.+)/); 72 | var scope = {}; 73 | scope[exprParts[1]] = row1; 74 | var x = $parse(exprParts[2])(scope); 75 | 76 | scope[exprParts[1]] = row2; 77 | var y = $parse(exprParts[2])(scope); 78 | 79 | if (x === y) return 0; 80 | return x > y ? 1 : -1; 81 | } 82 | 83 | function scrollToRow(row) { 84 | var offset = $element.find(".headerSpacer").height(); 85 | var currentScrollTop = $element.find(".scrollArea").scrollTop(); 86 | $element.find(".scrollArea").scrollTop(currentScrollTop + row.position().top - offset); 87 | } 88 | 89 | $scope.$on('rowSelected', function (event, rowId) { 90 | var row = $element.find(".scrollArea table tr[row-id='" + rowId + "']"); 91 | if (row.length === 1) { 92 | // Ensure that the headers have been fixed before scrolling, to ensure accurate 93 | // position calculations 94 | $q.all([waitForRender(), headersAreFixed.promise]).then(function () { 95 | scrollToRow(row); 96 | }); 97 | } 98 | }); 99 | 100 | // Set fixed widths for the table headers in case the text overflows. 101 | // There's no callback for when rendering is complete, so check the visibility of the table 102 | // periodically -- see http://stackoverflow.com/questions/11125078 103 | function waitForRender() { 104 | var deferredRender = $q.defer(); 105 | function wait() { 106 | if ($element.find("table:visible").length === 0) { 107 | $timeout(wait, 100); 108 | } else { 109 | deferredRender.resolve(); 110 | } 111 | } 112 | 113 | $timeout(wait); 114 | return deferredRender.promise; 115 | } 116 | 117 | var headersAreFixed = $q.defer(); 118 | 119 | function fixHeaderWidths() { 120 | if (!$element.find("thead th .th-inner").length) { 121 | $element.find("thead th").wrapInner('
'); 122 | } 123 | if($element.find("thead th .th-inner:not(:has(.box))").length) { 124 | $element.find("thead th .th-inner:not(:has(.box))").wrapInner('
'); 125 | } 126 | 127 | $element.find("table th .th-inner:visible").each(function (index, el) { 128 | el = angular.element(el); 129 | var width = el.parent().width(), 130 | lastCol = $element.find("table th:visible:last"), 131 | headerWidth = width; 132 | if (lastCol.css("text-align") !== "center") { 133 | var hasScrollbar = $element.find(".scrollArea").height() < $element.find("table").height(); 134 | if (lastCol[0] == el.parent()[0] && hasScrollbar) { 135 | headerWidth += $element.find(".scrollArea").width() - $element.find("tbody tr").width(); 136 | headerWidth = Math.max(headerWidth, width); 137 | } 138 | } 139 | var minWidth = _getScale(el.parent().css('min-width')), 140 | title = el.parent().attr("title"); 141 | headerWidth = Math.max(minWidth, headerWidth); 142 | el.css("width", headerWidth); 143 | if (!title) { 144 | // ordinary column(not sortableHeader) has box child div element that contained title string. 145 | title = el.find(".title .ng-scope").text() || el.find(".box").text(); 146 | } 147 | el.attr("title", title.trim()); 148 | }); 149 | headersAreFixed.resolve(); 150 | } 151 | 152 | // when the data model changes, fix the header widths. See the comments here: 153 | // http://docs.angularjs.org/api/ng.$timeout 154 | $scope.$watch('rows', function (newValue, oldValue) { 155 | if (newValue) { 156 | renderChains($element.find('.scrollArea').width()); 157 | // clean sort status and scroll to top of table once records replaced. 158 | $scope.sortExpr = null; 159 | // FIXME what is the reason here must scroll to top? This may cause confusing if using scrolling to implement pagination. 160 | $element.find('.scrollArea').scrollTop(0); 161 | } 162 | }); 163 | 164 | $scope.asc = !$attrs.hasOwnProperty("desc"); 165 | $scope.sortAttr = $attrs.sortAttr; 166 | 167 | var headerElementToFakeScroll = isFirefox ? "thead" : "thead th .th-inner"; 168 | $element.find(".scrollArea").on("scroll", function (event) { 169 | $element.find(headerElementToFakeScroll).css('margin-left', 0 - event.target.scrollLeft); 170 | }); 171 | 172 | $scope.$on("renderScrollableTable", function() { 173 | renderChains($element.find('.scrollArea').width()); 174 | }); 175 | 176 | var onResizeCallback = function(){ 177 | $timeout(function(){ 178 | $scope.$apply(); 179 | }); 180 | }; 181 | angular.element(window).on('resize', onResizeCallback); 182 | $scope.$on('$destroy', function() { 183 | angular.element(window).off('resize', onResizeCallback); 184 | }); 185 | $scope.$watch(function(){ 186 | return $element.find('.scrollArea').width(); 187 | }, function(newWidth, oldWidth){ 188 | if(newWidth * oldWidth <= 0){ 189 | return; 190 | } 191 | renderChains(); 192 | }); 193 | 194 | function renderChains(){ 195 | var resizeQueue = waitForRender().then(fixHeaderWidths), 196 | customHandlers = $scope.headerResizeHanlers || []; 197 | for(var i = 0;i < customHandlers.length;i++){ 198 | resizeQueue = resizeQueue.then(customHandlers[i]); 199 | } 200 | return resizeQueue; 201 | } 202 | }] 203 | }; 204 | }]) 205 | .directive('sortableHeader', [function () { 206 | return { 207 | transclude: true, 208 | scope: true, 209 | require: '^scrollableTable', 210 | template: 211 | '
' + 212 | '
' + 213 | '
' + 214 | '' + 215 | '' + 216 | '' + 217 | '' + 218 | '' + 219 | '' + 220 | '
' + 221 | '
', 222 | link: function (scope, elm, attrs, tableController) { 223 | var expr = attrs.on || "a as a." + attrs.col; 224 | scope.element = angular.element(elm); 225 | scope.isActive = function () { 226 | return tableController.getSortExpr() === expr; 227 | }; 228 | scope.toggleSort = function (e) { 229 | if (scope.isActive()) { 230 | tableController.toggleSort(); 231 | } else { 232 | tableController.setSortExpr(expr); 233 | } 234 | tableController.doSort(scope[attrs.comparatorFn]); 235 | e.preventDefault(); 236 | }; 237 | scope.isAscending = function () { 238 | if (scope.focused && !scope.isActive()) { 239 | return true; 240 | } else { 241 | return tableController.isAsc(); 242 | } 243 | }; 244 | 245 | scope.enter = function () { 246 | scope.focused = true; 247 | }; 248 | scope.leave = function () { 249 | scope.focused = false; 250 | }; 251 | 252 | scope.isLastCol = function() { 253 | return elm.parent().find("th:last-child").get(0) === elm.get(0); 254 | }; 255 | } 256 | }; 257 | }]) 258 | .directive('resizable', ['$compile', function($compile){ 259 | return { 260 | restrict: 'A', 261 | priority: 0, 262 | scope: false, 263 | require: 'scrollableTable', 264 | link: function postLink(scope, elm, attrs, tableController){ 265 | tableController.appendTableResizingHandler(function(){ 266 | _init(); 267 | }); 268 | 269 | tableController.appendTableResizingHandler(function relayoutHeaders(){ 270 | var tableElement = tableController.getTableElement().find('.scrollArea table'); 271 | if(tableElement.css('table-layout') === 'auto'){ 272 | initRodPos(); 273 | }else{ 274 | _resetColumnsSize(tableElement.parent().width()); 275 | } 276 | }); 277 | 278 | scope.resizing = function(e){ 279 | var screenOffset = tableController.getTableElement().find('.scrollArea').scrollLeft(), 280 | thInnerElm = angular.element(e.target).parent(), 281 | thElm = thInnerElm.parent(), 282 | startPoint = _getScale(thInnerElm.css('left')) + thInnerElm.width() - screenOffset, 283 | movingPos = e.pageX, 284 | _document = angular.element(document), 285 | _body = angular.element('body'), 286 | coverPanel = angular.element('.scrollableContainer .resizing-cover'), 287 | scaler = angular.element('
'); 288 | 289 | _body.addClass('scrollable-resizing'); 290 | coverPanel.addClass('active'); 291 | angular.element('.scrollableContainer').append(scaler); 292 | scaler.css('left', startPoint); 293 | 294 | _document.bind('mousemove', function (e){ 295 | var offsetX = e.pageX - movingPos, 296 | movedOffset = _getScale(scaler.css('left')) - startPoint, 297 | widthOfActiveCol = thElm.width(), 298 | nextElm = thElm.nextAll('th:visible').first(), 299 | minWidthOfActiveCol = _getScale(thElm.css('min-width')), 300 | widthOfNextColOfActive = nextElm.width(), 301 | minWidthOfNextColOfActive = _getScale(nextElm.css('min-width')); 302 | movingPos = e.pageX; 303 | e.preventDefault(); 304 | if((offsetX > 0 && widthOfNextColOfActive - movedOffset <= minWidthOfNextColOfActive) 305 | || (offsetX < 0 && widthOfActiveCol + movedOffset <= minWidthOfActiveCol)){ 306 | //stopping resize if user trying to extension and the active/next column already minimised. 307 | return; 308 | } 309 | scaler.css('left', _getScale(scaler.css('left')) + offsetX); 310 | }); 311 | _document.bind('mouseup', function (e) { 312 | e.preventDefault(); 313 | scaler.remove(); 314 | _body.removeClass('scrollable-resizing'); 315 | coverPanel.removeClass('active'); 316 | _document.unbind('mousemove'); 317 | _document.unbind('mouseup'); 318 | 319 | var offsetX = _getScale(scaler.css('left')) - startPoint, 320 | newWidth = thElm.width(), 321 | minWidth = _getScale(thElm.css('min-width')), 322 | nextElm = thElm.nextAll('th:visible').first(), 323 | widthOfNextColOfActive = nextElm.width(), 324 | minWidthOfNextColOfActive = _getScale(nextElm.css('min-width')), 325 | tableElement = tableController.getTableElement().find('.scrollArea table'); 326 | 327 | //hold original width of cells, to display cells as their original width after turn table-layout to fixed. 328 | if(tableElement.css('table-layout') === 'auto'){ 329 | tableElement.find("th .th-inner").each(function (index, el) { 330 | el = angular.element(el); 331 | var width = el.parent().width(); 332 | el.parent().css('width', width); 333 | }); 334 | } 335 | 336 | tableElement.css('table-layout', 'fixed'); 337 | 338 | if(offsetX > 0 && widthOfNextColOfActive - offsetX <= minWidthOfNextColOfActive){ 339 | offsetX = widthOfNextColOfActive - minWidthOfNextColOfActive; 340 | } 341 | nextElm.removeAttr('style'); 342 | newWidth += offsetX; 343 | thElm.css('width', Math.max(minWidth, newWidth)); 344 | nextElm.css('width', widthOfNextColOfActive - offsetX); 345 | tableController.renderTalble().then(resizeHeaderWidth()); 346 | }); 347 | }; 348 | 349 | function _init(){ 350 | var thInnerElms = elm.find('table th:not(:last-child) .th-inner'); 351 | if(thInnerElms.find('.resize-rod').length == 0){ 352 | tableController.getTableElement().find('.scrollArea table').css('table-layout', 'auto'); 353 | var resizeRod = angular.element('
'); 354 | thInnerElms.append($compile(resizeRod)(scope)); 355 | } 356 | } 357 | 358 | function initRodPos(){ 359 | var tableElement = tableController.getTableElement(); 360 | var headerPos = 1;// 1 is the width of right border; 361 | tableElement.find("table th .th-inner:visible").each(function (index, el) { 362 | el = angular.element(el); 363 | var width = el.parent().width(), //to made header consistent with its parent. 364 | // if it's the last header, add space for the scrollbar equivalent unless it's centered 365 | minWidth = _getScale(el.parent().css('min-width')); 366 | width = Math.max(minWidth, width); 367 | el.css("left", headerPos); 368 | headerPos += width; 369 | }); 370 | } 371 | 372 | function resizeHeaderWidth(){ 373 | var headerPos = 1,// 1 is the width of right border; 374 | tableElement = tableController.getTableElement(); 375 | tableController.getTableElement().find("table th .th-inner:visible").each(function (index, el) { 376 | el = angular.element(el); 377 | var width = el.parent().width(), //to made header consistent with its parent. 378 | // if it's the last header, add space for the scrollbar equivalent unless it's centered 379 | lastCol = tableElement.find("table th:visible:last"), 380 | minWidth = _getScale(el.parent().css('min-width')); 381 | width = Math.max(minWidth, width); 382 | //following are resize stuff, to made th-inner position correct. 383 | //last column's width should be automatically, to avoid horizontal scroll. 384 | if (lastCol[0] != el.parent()[0]){ 385 | el.parent().css('width', width); 386 | } 387 | el.css("left", headerPos); 388 | headerPos += width; 389 | }); 390 | } 391 | 392 | function _resetColumnsSize(tableWidth){ 393 | var tableElement = tableController.getTableElement(), 394 | columnLength = tableElement.find("table th:visible").length, 395 | lastCol = tableElement.find("table th:visible:last"); 396 | tableElement.find("table th:visible").each(function (index, el) { 397 | el = angular.element(el); 398 | if(lastCol.get(0) == el.get(0)){ 399 | //last column's width should be automaically, to avoid horizontal scroll. 400 | el.css('width', 'auto'); 401 | return; 402 | } 403 | var _width = el.data('width'); 404 | if(/\d+%$/.test(_width)){ //percentage 405 | _width = Math.ceil(tableWidth * _getScale(_width) / 100); 406 | } else { 407 | // if data-width not exist, use average width for each columns. 408 | _width = tableWidth / columnLength; 409 | } 410 | el.css('width', _width + 'px'); 411 | }); 412 | tableController.renderTalble().then(resizeHeaderWidth()); 413 | } 414 | } 415 | } 416 | }]); 417 | 418 | function _getScale(sizeCss){ 419 | return parseInt(sizeCss.replace(/px|%/, ''), 10); 420 | } 421 | })(angular); 422 | -------------------------------------------------------------------------------- /angular-scrollable-table.min.js: -------------------------------------------------------------------------------- 1 | !function(e){"use strict";function t(e){return parseInt(e.replace(/px|%/,""),10)}var n=navigator.userAgent.toLowerCase().indexOf("firefox")>-1;e.module("scrollable-table",[]).directive("scrollableTable",["$timeout","$q","$parse",function(i,r,s){return{transclude:!0,restrict:"E",scope:{rows:"=watch",sortFn:"="},template:'
',controller:["$scope","$element","$attrs",function(a,l,o){function c(e,t){var n=a.sortExpr.match(/(.+)\s+as\s+(.+)/),i={};i[n[1]]=e;var r=s(n[2])(i);i[n[1]]=t;var l=s(n[2])(i);return r===l?0:r>l?1:-1}function d(e){var t=l.find(".headerSpacer").height(),n=l.find(".scrollArea").scrollTop();l.find(".scrollArea").scrollTop(n+e.position().top-t)}function h(){function e(){0===l.find("table:visible").length?i(e,100):t.resolve()}var t=r.defer();return i(e),t.promise}function f(){l.find("thead th .th-inner").length||l.find("thead th").wrapInner('
'),l.find("thead th .th-inner:not(:has(.box))").length&&l.find("thead th .th-inner:not(:has(.box))").wrapInner('
'),l.find("table th .th-inner:visible").each(function(n,i){var r=(i=e.element(i)).parent().width(),s=l.find("table th:visible:last"),a=r;if("center"!==s.css("text-align")){var o=l.find(".scrollArea").height()
',link:function(t,n,i,r){var s=i.on||"a as a."+i.col;t.element=e.element(n),t.isActive=function(){return r.getSortExpr()===s},t.toggleSort=function(e){t.isActive()?r.toggleSort():r.setSortExpr(s),r.doSort(t[i.comparatorFn]),e.preventDefault()},t.isAscending=function(){return!(!t.focused||t.isActive())||r.isAsc()},t.enter=function(){t.focused=!0},t.leave=function(){t.focused=!1},t.isLastCol=function(){return n.parent().find("th:last-child").get(0)===n.get(0)}}}}]).directive("resizable",["$compile",function(n){return{restrict:"A",priority:0,scope:!1,require:"scrollableTable",link:function(i,r,s,a){function l(){var t=r.find("table th:not(:last-child) .th-inner");if(0==t.find(".resize-rod").length){a.getTableElement().find(".scrollArea table").css("table-layout","auto");var s=e.element('
');t.append(n(s)(i))}}function o(){var n=1;a.getTableElement().find("table th .th-inner:visible").each(function(i,r){var s=(r=e.element(r)).parent().width(),a=t(r.parent().css("min-width"));s=Math.max(a,s),r.css("left",n),n+=s})}function c(){var n=1,i=a.getTableElement();a.getTableElement().find("table th .th-inner:visible").each(function(r,s){var a=(s=e.element(s)).parent().width(),l=i.find("table th:visible:last"),o=t(s.parent().css("min-width"));a=Math.max(o,a),l[0]!=s.parent()[0]&&s.parent().css("width",a),s.css("left",n),n+=a})}function d(n){var i=a.getTableElement(),r=i.find("table th:visible").length,s=i.find("table th:visible:last");i.find("table th:visible").each(function(i,a){if(a=e.element(a),s.get(0)!=a.get(0)){var l=a.data("width");l=/\d+%$/.test(l)?Math.ceil(n*t(l)/100):n/r,a.css("width",l+"px")}else a.css("width","auto")}),a.renderTalble().then(c())}a.appendTableResizingHandler(function(){l()}),a.appendTableResizingHandler(function(){var e=a.getTableElement().find(".scrollArea table");"auto"===e.css("table-layout")?o():d(e.parent().width())}),i.resizing=function(n){var i=a.getTableElement().find(".scrollArea").scrollLeft(),r=e.element(n.target).parent(),s=r.parent(),l=t(r.css("left"))+r.width()-i,o=n.pageX,d=e.element(document),h=e.element("body"),f=e.element(".scrollableContainer .resizing-cover"),u=e.element('
');h.addClass("scrollable-resizing"),f.addClass("active"),e.element(".scrollableContainer").append(u),u.css("left",l),d.bind("mousemove",function(e){var n=e.pageX-o,i=t(u.css("left"))-l,r=s.width(),a=s.nextAll("th:visible").first(),c=t(s.css("min-width")),d=a.width(),h=t(a.css("min-width"));o=e.pageX,e.preventDefault(),n>0&&d-i<=h||n<0&&r+i<=c||u.css("left",t(u.css("left"))+n)}),d.bind("mouseup",function(n){n.preventDefault(),u.remove(),h.removeClass("scrollable-resizing"),f.removeClass("active"),d.unbind("mousemove"),d.unbind("mouseup");var i=t(u.css("left"))-l,r=s.width(),o=t(s.css("min-width")),v=s.nextAll("th:visible").first(),p=v.width(),b=t(v.css("min-width")),g=a.getTableElement().find(".scrollArea table");"auto"===g.css("table-layout")&&g.find("th .th-inner").each(function(t,n){var i=(n=e.element(n)).parent().width();n.parent().css("width",i)}),g.css("table-layout","fixed"),i>0&&p-i<=b&&(i=p-b),v.removeAttr("style"),r+=i,s.css("width",Math.max(o,r)),v.css("width",p-i),a.renderTalble().then(c())})}}}}])}(angular); -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-scrollable-table", 3 | "main": [ 4 | "angular-scrollable-table.js", 5 | "scrollable-table.css" 6 | ], 7 | "homepage": "https://github.com/alalonde/angular-scrollable-table", 8 | "authors": [ 9 | "Alec LaLonde " 10 | ], 11 | "description": "A fixed-header scrollable table for AngularJS, featuring resizable columns and sorting", 12 | "keywords": [ 13 | "scrollable-table" 14 | ], 15 | "license": "MIT", 16 | "ignore": [ 17 | "**/.*", 18 | "node_modules", 19 | "bower_components", 20 | "test", 21 | "tests" 22 | ], 23 | "dependencies": { 24 | "angular": "~1.3.9" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /demo/app.js: -------------------------------------------------------------------------------- 1 | /* global angular:false */ 2 | 'use strict'; 3 | 4 | var myApp = angular.module('myApp',['scrollable-table']) 5 | .service('Data', function() { 6 | this.get = function() { 7 | return [{ 8 | facility: "Atlanta", 9 | code: "C-RD34", 10 | cost: 540000, 11 | conditionRating: 52, 12 | extent: 100, 13 | planYear: 2014 14 | }, { 15 | facility: "Seattle", 16 | code: "CRDm-4", 17 | cost: 23000, 18 | conditionRating: 40, 19 | extent: 88, 20 | planYear: 2014 21 | }, { 22 | facility: "Austin", 23 | code: "GR-5", 24 | cost: 1200000, 25 | conditionRating: 92, 26 | extent: 90, 27 | planYear: 2014 28 | }, { 29 | facility: "Dayton", 30 | code: "LY-7", 31 | cost: 123000, 32 | conditionRating: 71, 33 | extent: 98, 34 | planYear: 2014 35 | }, { 36 | facility: "Portland", 37 | code: "Dm-4", 38 | cost: 149000, 39 | conditionRating: 89, 40 | extent: 77, 41 | planYear: 2014 42 | }, { 43 | facility: "Dallas", 44 | code: "AW-3", 45 | cost: 14000, 46 | conditionRating: 89, 47 | extent: 79, 48 | planYear: 2014 49 | }, { 50 | facility: "Houston", 51 | code: "Dm-4", 52 | cost: 1100000, 53 | conditionRating: 93, 54 | extent: 79, 55 | planYear: 2014 56 | }, { 57 | facility: "Boston", 58 | code: "DD3", 59 | cost: 1940000, 60 | conditionRating: 86, 61 | extent: 80, 62 | planYear: 2015 63 | }, { 64 | facility: "New York", 65 | code: "ER1", 66 | cost: 910000, 67 | conditionRating: 87, 68 | extent: 82, 69 | planYear: 2015 70 | }]; 71 | }; 72 | }) 73 | // when sorting by year, sort by year and then replace % 74 | .service("Comparators", function() { 75 | this.year = function(r1, r2) { 76 | if(r1.planYear === r2.planYear) { 77 | if (r1.extent === r2.extent) return 0; 78 | return r1.extent > r2.extent ? 1 : -1; 79 | } else if(!r1.planYear || !r2.planYear) { 80 | return !r1.planYear && !r2.planYear ? 0 : (!r1.planYear ? 1 : -1); 81 | } 82 | return r1.planYear > r2.planYear ? 1 : -1; 83 | }; 84 | }) 85 | .controller('MyCtrl', function($scope, $timeout, $window, Data, Comparators) { 86 | $scope.visibleProjects = Data.get(); 87 | $scope.comparator = Comparators.year; 88 | $scope.facilities = []; 89 | for(var i = 0; i < $scope.visibleProjects.length; i++) { 90 | $scope.facilities.push($scope.visibleProjects[i].facility); 91 | } 92 | 93 | $scope.$watch('selected', function(fac) { 94 | $scope.$broadcast("rowSelected", fac); 95 | }); 96 | 97 | $scope.changeRecord = function(){ 98 | $scope.visibleProjects[3].code = 'aaabbbccc'; 99 | $scope.$broadcast("renderScrollableTable"); 100 | }; 101 | 102 | $scope.replaceRecords = function(){ 103 | $scope.visibleProjects = Data.get(); 104 | }; 105 | 106 | $scope.toggleCol = function() { 107 | $scope.toggleColumn = !$scope.toggleColumn; 108 | $scope.$broadcast("renderScrollableTable"); 109 | }; 110 | }) 111 | ; -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Angular-scrollable-table directive demo 7 | 8 | 10 | 11 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |
FacilityUnit codeCostCondition scoreNot sortablePlan year
{{ response.facility }}{{ response.code }}{{ response.cost }}{{ response.conditionRating }}{{ response.extent }}{{ response.planYear }}
59 |
60 | 64 |
65 | 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-scrollable-table", 3 | "keywords": [ 4 | "angular", 5 | "table", 6 | "scrollable", 7 | "fixed-header" 8 | ], 9 | "author": "Alec LaLonde", 10 | "description": "A fixed-header scrollable table which auto-truncates overly-long headers, has resizable columns and sortable headers", 11 | "version": "1.1.2", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/alalonde/angular-scrollable-table" 16 | }, 17 | "scripts": { 18 | "compress": "uglifyjs --compress --mangle -o angular-scrollable-table.min.js -- angular-scrollable-table.js" 19 | }, 20 | "dependencies": {}, 21 | "devDependencies": { 22 | "uglify-js": "^2.7.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /scrollable-table.css: -------------------------------------------------------------------------------- 1 | .scrollableContainer { 2 | height: 310px; 3 | position: relative; 4 | padding-top: 35px; 5 | overflow: hidden; 6 | } 7 | .scrollableContainer .headerSpacer { 8 | border: 1px solid #d5d5d5; 9 | border-bottom-color: #bbb; 10 | position: absolute; 11 | height: 36px; 12 | top: 0; 13 | right: 0; 14 | left: 0; 15 | } 16 | .scrollableContainer th .orderWrapper { 17 | position: absolute; 18 | top: 0; 19 | right: 2px; 20 | cursor: pointer; 21 | } 22 | .scrollableContainer th .orderWrapper .order { 23 | font-size: 8pt; 24 | color: #BDBDBD; 25 | } 26 | .scrollableContainer th .orderWrapper .active { 27 | color: #464646; 28 | } 29 | 30 | .scrollArea { 31 | height: 100%; 32 | overflow-x: auto; 33 | overflow-y: auto; 34 | border: 1px solid #d5d5d5; 35 | /* the implementation of this is still quite buggy; specifically, it doesn't like the 36 | absolutely positioned headers within 37 | -webkit-overflow-scrolling: touch; */ 38 | -webkit-overflow-scrolling: auto; 39 | } 40 | .scrollArea table { 41 | overflow-x: auto; 42 | overflow-y: auto; 43 | margin-bottom: 0; 44 | width: 100%; 45 | border: none; 46 | /*border-collapse: separate;*/ 47 | } 48 | .scrollArea table th { 49 | padding: 0 !important; 50 | border: none !important; 51 | min-width: 40px; 52 | } 53 | .scrollArea table .th-inner { 54 | overflow: hidden; 55 | text-overflow: ellipsis; 56 | white-space: nowrap; 57 | position: absolute; 58 | top: 0; 59 | height: 36px; 60 | line-height: 36px; 61 | } 62 | 63 | .scrollArea table th .box { 64 | padding: 0 8px; 65 | padding-right: 11px; /* order icon width*/ 66 | border-left: 1px solid #ddd; 67 | } 68 | 69 | /* to hack fix firefox border issue */ 70 | @-moz-document url-prefix() { 71 | .scrollArea table th .box{ 72 | border-right: 1px solid #ddd; 73 | border-left: none; 74 | } 75 | } 76 | 77 | .scrollArea table .th-inner .ng-scope { 78 | display: block; 79 | overflow: hidden; 80 | text-overflow: ellipsis; 81 | } 82 | .scrollArea table tr th:first-child th .box { 83 | border-left: none; 84 | } 85 | .scrollArea table .th-inner.condensed { 86 | padding: 0 3px; 87 | } 88 | .scrollArea table tbody tr td:first-child { 89 | border-left: none; 90 | } 91 | .scrollArea table tbody tr td:last-child { 92 | border-right: none; 93 | } 94 | .scrollArea table tbody tr:first-child td { 95 | border-top: none; 96 | } 97 | .scrollArea table tbody tr:last-child td { 98 | border-bottom: 2px solid #ddd; 99 | } 100 | .scrollArea table tbody tr td { 101 | border-bottom: 1px solid #ddd; 102 | overflow: hidden; 103 | text-overflow: ellipsis; 104 | } 105 | 106 | .scrollableContainer .scaler { 107 | position: absolute; 108 | top: 0px; 109 | width: 2px; 110 | height: 100%; 111 | background-color: #CFCFCF; 112 | } 113 | 114 | .scrollableContainer th .resize-rod { 115 | position: absolute; 116 | top: 0; 117 | right: 0; 118 | cursor: col-resize; 119 | width: 4px; 120 | height: 100%; 121 | } 122 | 123 | .scrollable-resizing .scrollableContainer { 124 | cursor: col-resize; 125 | -moz-user-select: none; 126 | -webkit-user-select: none; 127 | -ms-user-select: none; 128 | } --------------------------------------------------------------------------------