<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Virtual scroll with filtering and custom scrollbar</title>
<script data-semver="1.3.16" src="https://code.angularjs.org/1.3.16/angular.js" data-require="angular.js@1.3.x"></script>
<link href="vsscrollbar.css" rel="stylesheet" type="text/css">
<script src="vsscrollbar.js"></script>
<link href="sampleapp.css" rel="stylesheet" type="text/css">
<script src="sampleapp.js"></script>
</head>
<body ng-app="vssampleapp">
<div id="pagemargin" ng-controller="vsScrollbarCtrl1">
<h3>Virtual scroll with filtering and custom scrollbar - AngularJS reusable UI component</h3>
<p>Homepage of the component is <a href="http://kekeh.github.io/vsscrollbar" target="_blank">here</a>. One component which is using this directive is <a href="http://embed.plnkr.co/a9U6nX/preview" target="_blank">here</a></p>
<hr/>
<h5>Example 1. Items as a string array</h5>
<div id="filter">
<input type="text" placeholder="Type filter and wait one second..." ng-model="filterText" ng-model-options="{ debounce: 1000 }">
</div>
<vsscrollbar items="allItems" items-in-page="6"
on-scroll-change-fn="onScrollChange(topIndex, maxIndex, topPos, maxPos, filteredPageCount, filteredItemCount, visibleItems)">
<div id="item" ng-repeat="item in visibleItems track by $index" ng-click="itemClicked($index, item);">
<div id="itemtext">{{item}}</div>
</div>
</vsscrollbar>
<hr/>
<div>
<div ng-include="'response.html'"></div>
<hr/>
<div id="clickedtext">{{clickedText}}</div>
</div>
</div>
<div id="pagemargin" ng-controller="vsScrollbarCtrl2">
<h5>Example 2. Items as an object array</h5>
<div id="checkbox">
<label><input type="radio" ng-model="showObject" ng-value="1">Show all (id, code and name) properties</label>
<label><input type="radio" ng-model="showObject" ng-value="2">Show name property</label>
</div>
<div id="filter">
<input type="text" placeholder="Filter value from all properties..." ng-model="filterText" ng-model-options="{ debounce: 1000 }">
</div>
<vsscrollbar items="allItems" items-in-page="5" height="129" ng-model="responseData">
<div id="item" ng-repeat="item in visibleItems track by $index" ng-click="itemClicked($index, item);">
<div id="itemtext" ng-show="showObject===1">
<div id="col">{{item.id}}</div><div id="col">{{item.code}}</div><div id="col">{{item.name}}</div>
</div>
<div id="itemtext" ng-show="showObject===2">
{{item.name}}
</div>
</div>
</vsscrollbar>
<hr/>
<div>
<div ng-include="'response.html'"></div>
<hr/>
<div id="clickedtext">{{clickedText}}</div>
</div>
</div>
</body>
<script type="text/ng-template" id="response.html">
<h5>Callback data from the directive</h5>
<table id="callbacktbl">
<tr>
<td>Top index</td>
<td>{{topIndex}}</td>
</tr>
<tr>
<td>Max top index</td>
<td>{{maxIndex}}</td>
</tr>
<tr>
<td>Top scroll pos</td>
<td>{{topPos}}</td>
</tr>
<tr>
<td>Max scroll pos</td>
<td>{{maxPos}}</td>
</tr>
<tr>
<td>Filtered page count</td>
<td>{{filteredPageCount}}</td>
</tr>
<tr>
<td>Filtered item count</td>
<td>{{filteredItemCount}}</td>
</tr>
<tr>
<td>Visible items</td>
<td>{{visibleItems}}</td>
</tr>
</table>
</script>
</html>
# vsscrollbar
**Virtual scroll with filtering and custom scrollbar - AngularJS reusable UI component**
## Description
AngularJS directive which implements the virtual scroll, filtering of the items and the customizable scrollbar
### 1. virtual scroll
* good performance even millions of the items in the list
* only visible items are rendered in the browser
### 2. filtering
* if scrollbar items are array of strings - filtering by string value
* if scrollbar items are array of objects - filtering by string from all properties of the object
### 3. customizable scrollbar
* custom scrollbar is used instead of native scrollbar of the browser
* scrollbar can be easily customised using the CSS
* scrollbar is looking similar in all browsers
Source code is available [here](https://github.com/kekeh/vsscrollbar)
#pagemargin {
margin: 0px 15px 0px 15px;
}
#item {
border: 1px solid #BCE8F1;
height: 25px;
margin-bottom: 1px;
border-radius: 2px;
background-color: #FAFAFA;
color: #3A87AD;
font-size: 0.8em;
padding-left: 2px;
cursor: pointer;
}
#item:last-child {
margin-bottom: 0px;
}
#item:hover {
background-color: lightgreen;
color: darkblue;
}
#itemtext {
line-height:25px;
}
#col {
display: inline-block;
width: 33.3%;
}
#filter input, h3, h5 {
width: 100%;
min-width: 150px;
border: 1px solid #CCC;
border-radius: 2px;
padding: 4px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 8px;
}
h3, h5 {
background-color: #90EE90;
}
#checkbox input {
margin-bottom: 10px;
}
#checkbox label, p {
font-size: 0.8em;
}
#callbacktbl {
color: blue;
width:100%;
min-width: 150px;
font-size: 0.8em;
border-collapse: collapse;
border-spacing: 0px;
border: 1px solid #AAA;
}
#callbacktbl td {
padding: 4px;
border: 1px solid #AAA;
}
#callbacktbl td:first-child {
color: black;
width: 130px;
}
#clickedtext {
color: green;
font-size: 0.8em;
}
var sampleapp = angular.module('vssampleapp', ['vsscrollbar']);
// Example 1
sampleapp.controller('vsScrollbarCtrl1', function ($scope, vsscrollbarEvent) {
$scope.visibleItems = [];
$scope.filterText = '';
$scope.topIndex = 0;
$scope.maxIndex = 0;
$scope.topPos = 0;
$scope.maxPos = 0;
$scope.filteredPageCount = 0;
$scope.filteredItemCount = 0;
// Scroll change callback
$scope.onScrollChange = function (topIndex, maxIndex, topPos, maxPos, filteredPageCount, filteredItemCount, visibleItems) {
$scope.topIndex = topIndex;
$scope.maxIndex = maxIndex;
$scope.topPos = topPos;
$scope.maxPos = maxPos;
$scope.filteredPageCount = filteredPageCount;
$scope.filteredItemCount = filteredItemCount;
$scope.visibleItems = visibleItems;
};
// Example function: list item clicked
$scope.itemClicked = function (index, item) {
var text = 'Clicked item: \"' + item + '\". Its index in the page is: \"' + index + '\" and the total index is: \"' + ($scope.topIndex + index) + '\".';
$scope.clickedText = text;
};
// Filtering
$scope.$watch('filterText', function (newValue, oldValue) {
if (newValue !== oldValue) {
vsscrollbarEvent.filter($scope, newValue);
}
});
// Generate test items (array of strings)
$scope.allItems = [];
for (var i = 0; i < 1000002; i++) {
$scope.allItems.push('Item #' + (i + 1));
}
});
// Example 2
sampleapp.controller('vsScrollbarCtrl2', function ($scope, vsscrollbarEvent) {
$scope.visibleItems = [];
$scope.filterText = '';
$scope.showObject = 1;
$scope.topIndex = 0;
$scope.maxIndex = 0;
$scope.topPos = 0;
$scope.maxPos = 0;
$scope.filteredPageCount = 0;
$scope.filteredItemCount = 0;
// Scroll change response data
$scope.responseData = [];
$scope.$watchCollection('responseData', function (response) {
$scope.topIndex = response.topIndex;
$scope.maxIndex = response.maxIndex;
$scope.topPos = response.topPos;
$scope.maxPos = response.maxPos;
$scope.filteredPageCount = response.filteredPageCount;
$scope.filteredItemCount = response.filteredItemCount;
$scope.visibleItems = response.visibleItems;
});
// Example function: list item clicked
$scope.itemClicked = function (index, item) {
var text = 'Clicked item: \"' + item.name + '\". Its index in the page is: \"' + index + '\" and the total index is: \"' + ($scope.topIndex + index) + '\".';
$scope.clickedText = text;
};
// Filtering
$scope.$watch('filterText', function (newValue, oldValue) {
if (newValue !== oldValue) {
// Filter value from all properties (id, code and name) because item is an object
vsscrollbarEvent.filter($scope, newValue);
}
});
// Generate test items (array of objects)
var chars = "ABCDEFGHIJKLMNOPQURSTUVWXYZ";
$scope.allItems = [];
for (var i = 0; i < 1000; i++) {
var rndcode = chars.substr(Math.floor(Math.random() * 27), 1) + chars.substr(Math.floor(Math.random() * 27), 1);
$scope.allItems.push({id: i, code: rndcode, name: 'Item #' + (i + 1)});
}
});
.vsscrollbarcontainer {
position: relative;
min-width: 150px;
width: 100%;
border: 1px solid #CCC;
background-color: #FFF;
border-radius: 2px;
}
.vsscrollbarcontainer * {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.vsscrollbarcontainer .vsscrollbarcontent {
}
.vsscrollbarcontainer .vsscrollbar {
width: 18px;
background-color: #EEE;
}
.vsscrollbarcontainer .vsscrollbox {
background-color: #BBB;
cursor: pointer;
}
.vsscrollbarcontainer .vsscrollbox:hover,
.vsscrollbarcontainer .vsscrollbox:focus {
background-color: #ADD8E6;
}
.vsscrollbarcontainer .vsscrollbar,
.vsscrollbarcontainer .vsscrollbox {
border-radius: 2px;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
/*
* Name: vsscrollbar
* Description: Virtual scroll with filtering and custom scrollbar - AngularJS reusable UI component
* Homepage: http://kekeh.github.io/vsscrollbar
* Version: 0.1.2
* Author: kekeh
* License: MIT
* Date: 2015-06-25
*/
angular.module('template-vsscrollbar-0.1.2.html', ['templates/vsscrollbar.html']);
angular.module("templates/vsscrollbar.html", []).run(["$templateCache", function($templateCache) {
$templateCache.put("templates/vsscrollbar.html",
"<table class=\"vsscrollbarcontainer\" ng-show=\"filteredItems.length > 0\" style=\"border-collapse:separate; border-spacing:0; padding:0; height:100%;\">\n" +
" <tr>\n" +
" <td style=\"width:100%; padding:0; vertical-align: top;\">\n" +
" <div class=\"vsscrollbarcontent\" ng-style=\"{'margin': scrollbarVisible ? '1px 0 1px 1px' : '1px'}\" style=\"overflow-y:hidden; padding:0; outline:0;\" ng-transclude></div>\n" +
" </td>\n" +
" <td style=\"padding:0; height:100%;\">\n" +
" <div class=\"vsscrollbar\" ng-show=\"scrollbarVisible\" style=\"float: right; height:100%; padding:0; margin:1px;\">\n" +
" <div class=\"vsscrollbox\" tabindex=\"0\" ng-style=\"{'height': boxHeight + 'px'}\" ng-click=\"$event.stopPropagation();\" style=\"position:relative; padding:0; outline:0;\"></div>\n" +
" </div>\n" +
" </td>\n" +
" </tr>\n" +
"</table> \n" +
"");
}]);
angular.module('vsscrollbar', ["template-vsscrollbar-0.1.2.html"])
.constant('vsscrollbarConfig', {
ITEMS_IN_PAGE: 6,
SCROLLBAR_HEIGHT: 0,
SCROLLBOX_MIN_HEIGHT: 18
})
.factory('vsscrollbarEvent', function () {
var factory = {};
factory.setIndex = function ($scope, index) {
broadcast($scope, 'setIndex', index);
};
factory.setPosition = function ($scope, position) {
broadcast($scope, 'setPosition', position);
};
factory.filter = function ($scope, filterStr) {
broadcast($scope, 'filter', filterStr);
};
factory.addItem = function ($scope, index, item) {
broadcast($scope, 'addItem', {index: index, item: item});
};
factory.updateItem = function ($scope, index, item) {
broadcast($scope, 'updateItem', {index: index, item: item});
};
factory.deleteItem = function ($scope, index) {
broadcast($scope, 'deleteItem', index);
};
function broadcast($scope, type, data) {
$scope.$broadcast('vsmessage', {type: type, value: data});
};
return factory;
})
.service('vsscrollbarService', function () {
this.calcIndex = function (pos, maxIndex, maxPos) {
var idx = 0;
if (this.checkIsMaxPos(pos, maxPos)) {
idx = maxIndex;
}
else if (pos > 0) {
idx = this.validateIndex(Math.round(pos / maxPos * maxIndex), maxIndex);
}
return idx;
};
this.calcScrollPos = function (index, maxIndex, maxPos) {
var pos = 0;
if (index > 0) {
if (this.checkIsMaxIndex(index, maxIndex)) {
pos = maxPos;
}
else {
pos = Math.round(index / maxIndex * maxPos);
}
}
return this.validatePos(pos, maxPos, index, maxIndex);
};
this.validateIndex = function (index, maxIndex) {
return index <= 0 ? 0 : this.checkIsMaxIndex(index, maxIndex) ? maxIndex : index;
};
this.validatePos = function (pos, maxPos, index, maxIndex) {
if (angular.isUndefined(index) || angular.isUndefined(maxIndex)) {
return pos <= 0 ? 0 : pos >= maxPos ? maxPos : pos;
}
return pos <= 0 && index > 0 ? 1 : pos >= maxPos && index < maxIndex ? maxPos - 1 : pos;
};
this.checkIsMaxIndex = function (index, maxIndex) {
return index >= maxIndex;
};
this.checkIsMaxPos = function (pos, maxPos) {
return pos >= maxPos;
};
})
.directive('vsscrollbar', ['$filter', '$timeout', '$document', 'vsscrollbarService', 'vsscrollbarConfig', function ($filter, $timeout, $document, vsscrollbarService, vsscrollbarConfig) {
return {
restrict: 'AE',
scope: {
ngModel: '=?',
items: '=items',
onScrollChangeFn: '&'
},
transclude: true,
templateUrl: 'templates/vsscrollbar.html',
link: function (scope, element, attrs) {
scope.filteredItems = [];
var scrollbarContent = angular.element(element[0].querySelector('.vsscrollbarcontent'));
var scrollbar = angular.element(element[0].querySelector('.vsscrollbar'));
var scrollbox = scrollbar.children();
var itemsInPage = !angular.isUndefined(attrs.itemsInPage) ? scope.$eval(attrs.itemsInPage) : vsscrollbarConfig.ITEMS_IN_PAGE;
var scrollbarHeight = !angular.isUndefined(attrs.height) ? scope.$eval(attrs.height) : vsscrollbarConfig.SCROLLBAR_HEIGHT;
var scrollStart = 0, index = 0, maxIdx = 0, position = 0, maxPos = 0;
var filterStr = '';
scope.boxHeight = vsscrollbarConfig.SCROLLBOX_MIN_HEIGHT;
scope.scrollbarVisible = true;
scrollbox.on('mousedown touchstart', onScrollMoveStart);
function onScrollMoveStart(event) {
event.preventDefault();
scrollStart = angular.isUndefined(event.changedTouches) ? event.clientY - position : event.changedTouches[0].clientY - position;
$document.on(angular.isUndefined(event.changedTouches) ? 'mousemove' : 'touchmove', onScrollMove);
$document.on(angular.isUndefined(event.changedTouches) ? 'mouseup' : 'touchend', onScrollMoveEnd)
};
function onScrollMove(event) {
var pos = angular.isUndefined(event.changedTouches) ? event.clientY - scrollStart : event.changedTouches[0].clientY - scrollStart;
setScrollPos(vsscrollbarService.validatePos(pos, maxPos));
scope.$apply();
};
function onScrollMoveEnd(event) {
$document.off(angular.isUndefined(event.changedTouches) ? 'mousemove' : 'touchmove', onScrollMove);
$document.off(angular.isUndefined(event.changedTouches) ? 'mouseup' : 'touchend', onScrollMoveEnd);
};
scrollbarContent.on('touchstart', onTouchStartList);
function onTouchStartList(event) {
scrollStart = event.changedTouches[0].clientY;
$document.on('touchmove', onTouchMoveList);
$document.on('touchend', onTouchEndList);
};
function onTouchMoveList(event) {
event.preventDefault();
var pos = event.changedTouches[0].clientY;
indexChange(pos < scrollStart ? itemsInPage : -itemsInPage);
scrollStart = pos;
scope.$apply();
};
function onTouchEndList() {
$document.off('touchmove', onTouchMoveList);
$document.off('touchend', onTouchEndList);
};
scrollbar.on('click', onScrollbarClick);
function onScrollbarClick(event) {
var value = event.offsetY || event.layerY;
setScrollPos(vsscrollbarService.validatePos(value < scope.boxHeight ? 0 : value, maxPos));
scope.$apply();
}
scrollbox.on('click', onScrollboxClick);
function onScrollboxClick() {
scrollbox[0].focus();
}
scrollbarContent.on('mousewheel DOMMouseScroll', onScrollMouseWheel);
scrollbar.on('mousewheel DOMMouseScroll', onScrollMouseWheel);
function onScrollMouseWheel(evt) {
var event = window.event || evt;
event.preventDefault();
var isDown = (event.wheelDelta || -event.detail) <= 0;
indexChange(isDown ? itemsInPage : -itemsInPage);
}
scrollbox.on('keydown', onKeydown);
function onKeydown(event) {
if (event.which === 38 || event.which === 40) {
event.preventDefault();
indexChange(event.which === 38 ? -itemsInPage : itemsInPage);
}
}
scope.$on('vsmessage', onScrollbarMessage);
function onScrollbarMessage(event, data) {
if (data.type === 'setIndex' && data.value !== index && data.value >= 0) {
setIndex(Math.round(data.value), true);
}
else if (data.type === 'setPosition' && data.value !== position && data.value >= 0) {
setScrollPos(vsscrollbarService.validatePos(Math.round(data.value), maxPos));
}
else if (data.type === 'filter') {
filterStr = data.value;
filterItems(filterStr, 0);
}
else if (data.type === 'addItem' && data.value.index >= 0 && data.value.index <= scope.items.length) {
scope.items.splice(data.value.index, 0, data.value.item);
filterItems(filterStr, index);
}
else if (data.type === 'updateItem' && data.value.index >= 0 && data.value.index < scope.items.length) {
scope.items[data.value.index] = data.value.item;
filterItems(filterStr, index);
}
else if (data.type === 'deleteItem' && data.value >= 0 && data.value < scope.items.length) {
scope.items.splice(data.value, 1);
filterItems(filterStr, index);
}
}
scope.$on('$destroy', function () {
scrollbox.off('mousedown touchstart', onScrollMoveStart);
scrollbarContent.off('touchstart', onTouchStartList);
scrollbar.off('click', onScrollbarClick);
scrollbox.off('click', onScrollboxClick);
scrollbarContent.off('mousewheel DOMMouseScroll', onScrollMouseWheel);
scrollbar.off('mousewheel DOMMouseScroll', onScrollMouseWheel);
scrollbox.off('keydown', onKeydown);
scope.$off('vsmessage', onScrollbarMessage);
});
function filterItems(filter, idx) {
scope.filteredItems = (filter === '') ? scope.items : $filter('filter')(scope.items, filter);
scope.scrollbarVisible = scope.filteredItems.length > itemsInPage;
initScrollValues();
setIndex(idx, false);
}
function initScrollValues() {
var height = Math.floor(scrollbarHeight / (scope.filteredItems.length / itemsInPage));
scope.boxHeight = height < vsscrollbarConfig.SCROLLBOX_MIN_HEIGHT ? vsscrollbarConfig.SCROLLBOX_MIN_HEIGHT : height;
maxIdx = scope.filteredItems.length - itemsInPage < 0 ? 0 : scope.filteredItems.length - itemsInPage;
maxPos = scrollbarHeight - scope.boxHeight < 0 ? 0 : scrollbarHeight - scope.boxHeight;
}
function setScrollPos(pos) {
if ((pos = Math.round(pos)) !== position) {
position = pos;
index = vsscrollbarService.calcIndex(position, maxIdx, maxPos);
moveScrollBox();
}
}
function setIndex(idx, verifyChange) {
if ((idx = vsscrollbarService.validateIndex(idx, maxIdx)) !== index || !verifyChange) {
index = idx;
position = vsscrollbarService.calcScrollPos(index, maxIdx, maxPos);
moveScrollBox();
}
}
function indexChange(idx) {
setIndex(index + idx, true);
scope.$apply();
}
function moveScrollBox() {
scrollbox.css('top', position + 'px');
onScrollChange();
}
function onScrollChange() {
var responseData = {
topIndex: index,
maxIndex: maxIdx,
topPos: position,
maxPos: maxPos,
filteredPageCount: scope.filteredItems.length / itemsInPage,
filteredItemCount: scope.filteredItems.length,
visibleItems: slice()
};
scope.onScrollChangeFn(responseData);
scope.ngModel = responseData;
}
function slice() {
return scope.filteredItems.slice(index, index + itemsInPage);
}
function init() {
scope.filteredItems = scope.items;
if (scrollbarHeight === 0) {
$timeout(setHeight, 0);
}
else {
scrollbar.css('height', scrollbarHeight + 'px');
initScrollValues();
}
setIndex(0, false);
}
function setHeight() {
scrollbarHeight = scrollbarContent.prop('offsetHeight');
scrollbar.css('height', scrollbarHeight + 'px');
initScrollValues();
}
init();
}
};
}]);