<!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();
            }
        };
    }]);