├── .gitignore ├── CHANGELOG.md ├── Gruntfile.js ├── LICENSE.md ├── README.md ├── bower.json ├── demo ├── app.js └── index.html ├── dist ├── angular-multi-select-tree-0.1.0.css ├── angular-multi-select-tree-0.1.0.js ├── angular-multi-select-tree-0.1.0.min.css ├── angular-multi-select-tree-0.1.0.min.js └── angular-multi-select-tree-0.1.0.tpl.js ├── karma.conf.js ├── package.json ├── src ├── multi-select-tree-main.js ├── multi-select-tree.js ├── multi-select-tree.less ├── multi-select-tree.tpl.html ├── tree-item.js └── tree-item.tpl.html └── test └── unit └── componentSpec.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | components 4 | bower_components 5 | build 6 | .idea 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a5hik/angular-multi-select-tree/6afbba8fff408404df1776ad1dc5fbcd614cd2e3/CHANGELOG.md -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | 'use strict'; 3 | 4 | require('load-grunt-tasks')(grunt); 5 | var _ = require('lodash'); 6 | 7 | var karmaConfig = function(configFile, customOptions) { 8 | var options = { configFile: configFile, keepalive: true }; 9 | var travisOptions = process.env.TRAVIS && { browsers: ['Firefox'], reporters: 'dots' }; 10 | return _.extend(options, customOptions, travisOptions); 11 | }; 12 | 13 | var mountFolder = function (connect, dir) { 14 | return connect.static(require('path').resolve(dir)); 15 | }; 16 | 17 | grunt.initConfig({ 18 | pkg: grunt.file.readJSON('bower.json'), 19 | meta: { 20 | banner: '/*! <%= pkg.title || pkg.name %> - v<%= pkg.version %> - ' + 21 | '<%= grunt.template.today("yyyy-mm-dd") %>\n' + 22 | '<%= pkg.homepage ? "* " + pkg.homepage + "\n" : "" %>' + 23 | '* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' + 24 | ' Licensed <%= _.pluck(pkg.licenses, "type").join(", ") %> */' 25 | }, 26 | watch: { 27 | scripts: { 28 | files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'], 29 | tasks: ['jshint', 'karma:unit'] 30 | } 31 | }, 32 | jshint: { 33 | all: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'], 34 | options: { 35 | eqeqeq: true, 36 | globals: { 37 | angular: true 38 | } 39 | } 40 | }, 41 | concat: { 42 | js: { 43 | src: ['src/**/*main.js', 44 | 'src/**/*.js'], 45 | dest: 'dist/angular-multi-select-tree-<%= pkg.version %>.js' 46 | }, 47 | css: { 48 | src: ['src/**/*.css', 'src/**/*.less'], 49 | dest: 'dist/angular-multi-select-tree-<%= pkg.version %>.css' 50 | } 51 | }, 52 | ngtemplates: { 53 | 'multi-select-tree': { 54 | src: 'src/**/*.html', 55 | dest: 'dist/angular-multi-select-tree-<%= pkg.version %>.tpl.js' 56 | } 57 | }, 58 | uglify: { 59 | src: { 60 | files: { 61 | 'dist/angular-multi-select-tree-<%= pkg.version %>.min.js': '<%= concat.js.dest %>' 62 | } 63 | } 64 | }, 65 | 66 | // connect 67 | connect: { 68 | options: { 69 | port: 3000, 70 | livereload: 93729, 71 | hostname: '0.0.0.0' 72 | }, 73 | demo: { 74 | options: { 75 | middleware: function (connect) { 76 | return [ 77 | mountFolder(connect, '') 78 | ]; 79 | } 80 | } 81 | } 82 | }, 83 | 84 | // open 85 | open: { 86 | server: { 87 | path: 'http://localhost:<%= connect.options.port %>/demo/' 88 | } 89 | }, 90 | 91 | karma: { 92 | unit: { 93 | options: karmaConfig('karma.conf.js', { 94 | singleRun: true 95 | }) 96 | }, 97 | server: { 98 | options: karmaConfig('karma.conf.js', { 99 | singleRun: false 100 | }) 101 | } 102 | }, 103 | changelog: { 104 | options: { 105 | dest: 'CHANGELOG.md' 106 | } 107 | }, 108 | ngmin: { 109 | src: { 110 | src: '<%= concat.js.dest %>', 111 | dest: '<%= concat.js.dest %>' 112 | } 113 | }, 114 | cssmin: { 115 | css: { 116 | src: '<%= concat.css.dest %>', 117 | dest: 'dist/angular-multi-select-tree-<%= pkg.version %>.min.css' 118 | } 119 | }, 120 | clean: ['dist/*'] 121 | }); 122 | 123 | grunt.registerTask('default', ['jshint', 'karma:unit']); 124 | grunt.registerTask('test', ['karma:unit']); 125 | grunt.registerTask('test-server', ['karma:server']); 126 | grunt.registerTask('server', ['open', 'connect:demo', 'watch']); 127 | grunt.registerTask('build', ['clean', 'jshint', 'concat', 'ngtemplates', 'ngmin', 'cssmin', 'uglify']); 128 | }; 129 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Muhammed Ashik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | angular-multi-select-tree 2 | ============================= 3 | 4 | A native Angular multi select tree. No JQuery. 5 | If you use this module you can give it a thumbs up at [http://ngmodules.org/modules/angular-multi-select-tree](http://ngmodules.org/modules/angular-multi-select-tree). 6 | 7 | #### Demo Page: 8 | 9 | [Demo] (http://a5hik.github.io/angular-multi-select-tree) 10 | 11 | #### Features: 12 | 13 | TBD 14 | #### Implementation Details: 15 | 16 | TBD 17 | #### Design details: 18 | 19 | #### Callbacks: 20 | 21 | TBD 22 | ##### Usage: 23 | 24 | Get the binaries of angular-multi-select-tree with any of the following ways. 25 | 26 | ```sh 27 | bower install angular-multi-select-tree 28 | ``` 29 | Or for yeoman with bower automatic include: 30 | ``` 31 | bower install angular-multi-select-tree -save 32 | ``` 33 | Or bower.json 34 | ``` 35 | { 36 | "dependencies": [..., "multi-select-tree: "latest_version eg - "1.1.0" ", ...], 37 | } 38 | ``` 39 | Make sure to load the scripts in your html. 40 | ```html 41 | 42 | 43 | 44 | 45 | ``` 46 | 47 | And Inject the sortable module as dependency. 48 | 49 | ``` 50 | angular.module('xyzApp', ['multi-select-tree', '....']); 51 | ``` 52 | 53 | ###### Html Structure: 54 | 55 | TBD 56 | Define your callbacks in the invoking controller. 57 | 58 | TBD 59 | That's what all you have to do. 60 | 61 | ##### NG Modules Link: 62 | 63 | If you use this module you can give it a thumbs up at [http://ngmodules.org/modules/angular-multi-select-tree](http://ngmodules.org/modules/angular-multi-select-tree). 64 | 65 | ##### License 66 | 67 | MIT, see [LICENSE.md](./LICENSE.md). 68 | 69 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "a5hik", 3 | "name": "angular-multi-select-tree", 4 | "version": "0.1.0", 5 | "description": "A hierarchical (or tree) selection control for AngularJS", 6 | "main": "index.js", 7 | "homepage": "https://github.com/a5hik/angular-multi-select-tree", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/a5hik/angular-multi-select-tree" 11 | }, 12 | "dependencies": { 13 | "angular": "~1.3.x", 14 | "bootstrap-css-only": "~3.2.0" 15 | }, 16 | "devDependencies": { 17 | "angular-mocks": "~1.3.x" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /demo/app.js: -------------------------------------------------------------------------------- 1 | /*jshint undef: false, unused: false, indent: 2*/ 2 | /*global angular: false */ 3 | 4 | 'use strict'; 5 | 6 | var app = angular.module('demoApp', ['multi-select-tree']); 7 | 8 | app.controller('demoAppCtrl', function ($scope) { 9 | 10 | var data1 = []; 11 | 12 | for (var i = 0; i < 7; i++) { 13 | var obj = { 14 | id: i, 15 | name: 'Node ' + i, 16 | children: [] 17 | }; 18 | 19 | for (var j = 0; j < 3; j++) { 20 | var obj2 = { 21 | id: j, 22 | name: 'Node ' + i + '.' + j, 23 | children: [] 24 | }; 25 | obj.children.push(obj2); 26 | } 27 | 28 | data1.push(obj); 29 | } 30 | 31 | data1[1].children[0].children.push({ 32 | id: j, 33 | name: 'Node sub_sub 1', 34 | children: [], 35 | selected: true 36 | }); 37 | 38 | $scope.data = angular.copy(data1); 39 | 40 | var data3 = []; 41 | 42 | for (var i = 0; i < 7; i++) { 43 | var obj3 = { 44 | id: i, 45 | name: 'Node new view ' + i 46 | }; 47 | data3.push(obj3); 48 | } 49 | 50 | 51 | $scope.selectOnly1Or2 = function(item, selectedItems) { 52 | if (selectedItems !== undefined && selectedItems.length >= 20) { 53 | return false; 54 | } else { 55 | return true; 56 | } 57 | }; 58 | 59 | $scope.switchViewCallback = function(scopeObj) { 60 | 61 | if (scopeObj.switchViewLabel == 'test2') { 62 | scopeObj.switchViewLabel = 'test1'; 63 | scopeObj.inputModel = data1; 64 | scopeObj.selectOnlyLeafs = true; 65 | } else { 66 | scopeObj.switchViewLabel = 'test2'; 67 | scopeObj.inputModel = data3; 68 | scopeObj.selectOnlyLeafs = false; 69 | } 70 | } 71 | }); -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AngularJS Demo UI Component 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |

Demo Page for the Angular Multi Select Tree...

19 |
20 | 21 |
22 |
23 | 24 |
25 |

Multi-select

26 | 27 |
28 | 33 |
Selected items: {{selectedItem2}} 34 |
35 | 36 |
37 |
38 |
39 | 40 | -------------------------------------------------------------------------------- /dist/angular-multi-select-tree-0.1.0.css: -------------------------------------------------------------------------------- 1 | .tree-control .tree-input { 2 | position: relative; 3 | display: inline-block; 4 | text-align: center; 5 | cursor: pointer; 6 | border: 1px solid #c6c6c6; 7 | padding: 1px 8px 1px 8px; 8 | font-size: 14px; 9 | min-height : 38px !important; 10 | border-radius: 4px; 11 | color: #555; 12 | -webkit-user-select: none; 13 | -moz-user-select: none; 14 | -ms-user-select: none; 15 | -o-user-select: none; 16 | user-select: none; 17 | white-space:normal; 18 | background-color: #fff; 19 | background-image: linear-gradient(#fff, #f7f7f7); 20 | } 21 | 22 | .tree-control .tree-input:hover { 23 | background-image: linear-gradient(#fff, #e9e9e9); 24 | } 25 | 26 | /* downward pointing arrow */ 27 | .tree-control .caret { 28 | display: inline-block; 29 | width: 0; 30 | height: 0; 31 | margin: 0px 0px 1px 12px !important; 32 | vertical-align: middle; 33 | border-top: 4px solid #333; 34 | border-right: 4px solid transparent; 35 | border-left: 4px solid transparent; 36 | border-bottom: 0 dotted; 37 | } 38 | 39 | .tree-control .tree-input span.selected-items .selected-item { 40 | background: #f2f2f2; 41 | border: 1px solid darkgray; 42 | border-radius: 3px; 43 | padding: 3px; 44 | cursor: text; 45 | } 46 | 47 | .tree-control .tree-input span.selected-items .selected-item-close { 48 | width: 20px; 49 | cursor: pointer; 50 | font-weight: bold; 51 | display: inline-block; 52 | padding: 2px; 53 | text-align: center; 54 | } 55 | .tree-control .tree-input span.selected-items .selected-item-close:hover { 56 | background-color: #f2f2f2 57 | } 58 | .tree-control .tree-input span.selected-items .selected-item-close:before { 59 | content: 'x'; 60 | } 61 | 62 | .tree-control .tree-view { 63 | background-color: #fff; 64 | position: absolute; 65 | z-index: 999; 66 | border: 1px solid rgba(0, 0, 0, 0.15); 67 | border-radius: 4px; 68 | -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 69 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 70 | min-width:348px; 71 | margin-right: 30px; 72 | max-height: 300px; 73 | overflow: auto; 74 | padding: 10px 5px; 75 | } 76 | .tree-control .tree-view ul { 77 | padding: 0; 78 | margin: 0; 79 | } 80 | .tree-control .tree-view ul .item-details { 81 | display: inline-block; 82 | margin-left: 5px; 83 | } 84 | .tree-control .tree-view ul .tree-checkbox { 85 | margin-right: 3px; 86 | margin-top: 0; 87 | color: #ddd !important; 88 | cursor: pointer; 89 | } 90 | .tree-control .tree-view .active { 91 | background-color: #f2f2f2; 92 | border-radius: 3px; 93 | } 94 | .tree-control .tree-view .selected.active { 95 | background-color: #46b8da; 96 | } 97 | 98 | /* container of helper elements */ 99 | .tree-control .tree-view .helper-container { 100 | border-bottom: 1px solid #ddd; 101 | padding: 8px 8px 0px 8px; 102 | } 103 | 104 | /* container of multi select items */ 105 | .tree-control .tree-view .tree-container { 106 | padding: 8px; 107 | } 108 | 109 | .tree-control .tree-view .item-container { 110 | padding: 3px; 111 | color: #444; 112 | white-space: nowrap; 113 | -webkit-user-select: none; 114 | -moz-user-select: none; 115 | -ms-user-select: none; 116 | -o-user-select: none; 117 | user-select: none; 118 | border: 1px solid transparent; 119 | position: relative; 120 | } 121 | 122 | /* item labels focus on mouse hover */ 123 | .tree-control .tree-view .item-container:hover { 124 | background-image: linear-gradient( #c1c1c1, #999 ) !important; 125 | color: #fff !important; 126 | cursor: pointer; 127 | border: 1px solid #ccc !important; 128 | } 129 | 130 | .tree-control .tree-view .selected { 131 | background-image: linear-gradient( #e9e9e9, #f1f1f1 ); 132 | color: #555; 133 | cursor: pointer; 134 | border-top: 1px solid #e4e4e4; 135 | border-left: 1px solid #e4e4e4; 136 | border-right: 1px solid #d9d9d9; 137 | } 138 | 139 | /* helper buttons (select all, none, reset); */ 140 | .tree-control .tree-view .helper-button { 141 | display: inline; 142 | text-align: center; 143 | cursor: pointer; 144 | border: 1px solid #ccc; 145 | height: 26px; 146 | font-size: 13px; 147 | border-radius: 2px; 148 | color: #666; 149 | background-color: #f1f1f1; 150 | line-height: 1.6; 151 | margin: 0px 0px 8px 0px; 152 | } 153 | 154 | /* clear button */ 155 | .tree-control .tree-view .clear-button { 156 | position: absolute; 157 | display: inline; 158 | text-align: center; 159 | cursor: pointer; 160 | border: 1px solid #ccc; 161 | height: 22px; 162 | width: 22px; 163 | font-size: 13px; 164 | border-radius: 2px; 165 | color: #666; 166 | background-color: #f1f1f1; 167 | line-height: 1.4; 168 | right : 2px; 169 | top: 2px; 170 | } 171 | 172 | /* filter */ 173 | .tree-control .tree-view .input-filter { 174 | border-radius: 2px; 175 | border: 1px solid #ccc; 176 | height: 26px; 177 | font-size: 14px; 178 | width:100%; 179 | padding-left:7px; 180 | -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ 181 | -moz-box-sizing: border-box; /* Firefox, other Gecko */ 182 | box-sizing: border-box; /* Opera/IE 8+ */ 183 | color: #888; 184 | margin: 0px 0px 8px 0px; 185 | } 186 | 187 | /* helper elements on hover & focus */ 188 | .tree-control .tree-view .clear-button:hover, 189 | .tree-control .tree-view .helper-button:hover { 190 | border: 1px solid #ccc; 191 | color: #999; 192 | background-color: #f4f4f4; 193 | } 194 | 195 | .tree-control .tree-view .clear-button:focus, 196 | .tree-control .tree-view .helper-button:focus, 197 | .tree-control .tree-view .input-filter:focus { 198 | border: 1px solid #66AFE9 !important; 199 | box-shadow: inset 0 0px 1px rgba(0,0,0,.035), 0 0 5px rgba(82,168,236,.7) !important; 200 | } 201 | 202 | /* ! create a "row" */ 203 | .tree-control .tree-view .line { 204 | max-height: 34px; 205 | overflow: hidden; 206 | position: relative; 207 | } 208 | 209 | .tree-control .tree-view .item-close { 210 | width: 20px; 211 | cursor: pointer; 212 | font-weight: bold; 213 | padding: 5px; 214 | } 215 | .tree-control .tree-view .item-close:hover { 216 | background-color: #f2f2f2 217 | } 218 | .tree-control .tree-view .item-close:before { 219 | content: 'x'; 220 | } 221 | 222 | .tree-control .tree-view li { 223 | list-style-type: none; 224 | margin-left: 15px; 225 | } 226 | 227 | .tree-control .tree-view li .expand { 228 | display: inline-block; 229 | width: 0; 230 | height: 0; 231 | border-top: 6px solid transparent; 232 | border-bottom: 6px solid transparent; 233 | border-left: 10px solid #525252; 234 | } 235 | .tree-control .tree-view li .expand-opened { 236 | border: none; 237 | border-left: 6px solid transparent; 238 | border-right: 6px solid transparent; 239 | border-top: 10px solid #525252; 240 | } 241 | .tree-control .tree-view li.top-level { 242 | margin: 0; 243 | } 244 | -------------------------------------------------------------------------------- /dist/angular-multi-select-tree-0.1.0.js: -------------------------------------------------------------------------------- 1 | /* 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2014 Muhammed Ashik 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | /*jshint indent: 2 */ 25 | /*global angular: false */ 26 | (function () { 27 | 'use strict'; 28 | angular.module('multi-select-tree', []); 29 | }()); 30 | /*jshint indent: 2 */ 31 | /*global angular: false */ 32 | (function () { 33 | 'use strict'; 34 | var mainModule = angular.module('multi-select-tree'); 35 | /** 36 | * Controller for multi select tree. 37 | */ 38 | mainModule.controller('multiSelectTreeCtrl', [ 39 | '$scope', 40 | '$document', 41 | function ($scope, $document) { 42 | var activeItem; 43 | $scope.showTree = false; 44 | $scope.selectedItems = []; 45 | $scope.multiSelect = $scope.multiSelect || false; 46 | /** 47 | * Clicking on document will hide the tree. 48 | */ 49 | function docClickHide() { 50 | closePopup(); 51 | $scope.$apply(); 52 | } 53 | /** 54 | * Closes the tree popup. 55 | */ 56 | function closePopup() { 57 | $scope.showTree = false; 58 | if (activeItem) { 59 | activeItem.isActive = false; 60 | activeItem = undefined; 61 | } 62 | $document.off('click', docClickHide); 63 | } 64 | /** 65 | * Sets the active item. 66 | * 67 | * @param item the item element. 68 | */ 69 | $scope.onActiveItem = function (item) { 70 | if (activeItem !== item) { 71 | if (activeItem) { 72 | activeItem.isActive = false; 73 | } 74 | activeItem = item; 75 | activeItem.isActive = true; 76 | } 77 | }; 78 | /** 79 | * Copies the selectedItems in to output model. 80 | */ 81 | $scope.refreshOutputModel = function () { 82 | $scope.outputModel = angular.copy($scope.selectedItems); 83 | }; 84 | /** 85 | * Refreshes the selected Items model. 86 | */ 87 | $scope.refreshSelectedItems = function () { 88 | $scope.selectedItems = []; 89 | if ($scope.inputModel) { 90 | setSelectedChildren($scope.inputModel); 91 | } 92 | }; 93 | /** 94 | * Iterates over children and sets the selected items. 95 | * 96 | * @param children the children element. 97 | */ 98 | function setSelectedChildren(children) { 99 | for (var i = 0, len = children.length; i < len; i++) { 100 | if (!isItemSelected(children[i]) && children[i].selected === true) { 101 | $scope.selectedItems.push(children[i]); 102 | } else if (isItemSelected(children[i]) && children[i].selected === false) { 103 | children[i].selected = true; 104 | } 105 | if (children[i] && children[i].children) { 106 | setSelectedChildren(children[i].children); 107 | } 108 | } 109 | } 110 | /** 111 | * Checks of the item is already selected. 112 | * 113 | * @param item the item to be checked. 114 | * @return {boolean} if the item is already selected. 115 | */ 116 | function isItemSelected(item) { 117 | var isSelected = false; 118 | if ($scope.selectedItems) { 119 | for (var i = 0; i < $scope.selectedItems.length; i++) { 120 | if ($scope.selectedItems[i].id === item.id) { 121 | isSelected = true; 122 | break; 123 | } 124 | } 125 | } 126 | return isSelected; 127 | } 128 | /** 129 | * Deselect the item. 130 | * 131 | * @param item the item element 132 | * @param $event 133 | */ 134 | $scope.deselectItem = function (item, $event) { 135 | $event.stopPropagation(); 136 | $scope.selectedItems.splice($scope.selectedItems.indexOf(item), 1); 137 | item.selected = false; 138 | this.refreshOutputModel(); 139 | }; 140 | /** 141 | * Swap the tree popup on control click event. 142 | * 143 | * @param $event the click event. 144 | */ 145 | $scope.onControlClicked = function ($event) { 146 | $event.stopPropagation(); 147 | $scope.showTree = !$scope.showTree; 148 | if ($scope.showTree) { 149 | $document.on('click', docClickHide); 150 | } 151 | }; 152 | /** 153 | * Stop the event on filter clicked. 154 | * 155 | * @param $event the click event 156 | */ 157 | $scope.onFilterClicked = function ($event) { 158 | $event.stopPropagation(); 159 | }; 160 | /** 161 | * Clears the filter text. 162 | * 163 | * @param $event the click event 164 | */ 165 | $scope.clearFilter = function ($event) { 166 | $event.stopPropagation(); 167 | $scope.filterKeyword = ''; 168 | }; 169 | /** 170 | * Wrapper function for can select item callback. 171 | * 172 | * @param item the item 173 | */ 174 | $scope.canSelectItem = function (item) { 175 | return $scope.callback({ 176 | item: item, 177 | selectedItems: $scope.selectedItems 178 | }); 179 | }; 180 | /** 181 | * The callback is used to switch the views. 182 | * based on the view type. 183 | * 184 | * @param $event the event object. 185 | */ 186 | $scope.switchCurrentView = function ($event) { 187 | $event.stopPropagation(); 188 | $scope.switchViewCallback({ scopeObj: $scope }); 189 | }; 190 | /** 191 | * Handles the item select event. 192 | * 193 | * @param item the selected item. 194 | */ 195 | $scope.itemSelected = function (item) { 196 | if ($scope.useCallback && $scope.canSelectItem(item) === false || $scope.selectOnlyLeafs && item.children && item.children.length > 0) { 197 | return; 198 | } 199 | if (!$scope.multiSelect) { 200 | closePopup(); 201 | for (var i = 0; i < $scope.selectedItems.length; i++) { 202 | $scope.selectedItems[i].selected = false; 203 | } 204 | item.selected = true; 205 | $scope.selectedItems = []; 206 | $scope.selectedItems.push(item); 207 | } else { 208 | item.selected = true; 209 | var indexOfItem = $scope.selectedItems.indexOf(item); 210 | if (isItemSelected(item)) { 211 | item.selected = false; 212 | $scope.selectedItems.splice(indexOfItem, 1); 213 | } else { 214 | $scope.selectedItems.push(item); 215 | } 216 | } 217 | this.refreshOutputModel(); 218 | }; 219 | } 220 | ]); 221 | /** 222 | * sortableItem directive. 223 | */ 224 | mainModule.directive('multiSelectTree', function () { 225 | return { 226 | restrict: 'E', 227 | templateUrl: 'src/multi-select-tree.tpl.html', 228 | scope: { 229 | inputModel: '=', 230 | outputModel: '=?', 231 | multiSelect: '=?', 232 | switchView: '=?', 233 | switchViewLabel: '@', 234 | switchViewCallback: '&', 235 | selectOnlyLeafs: '=?', 236 | callback: '&', 237 | defaultLabel: '@' 238 | }, 239 | link: function (scope, element, attrs) { 240 | if (attrs.callback) { 241 | scope.useCallback = true; 242 | } 243 | // watch for changes in input model as a whole 244 | // this on updates the multi-select when a user load a whole new input-model. 245 | scope.$watch('inputModel', function (newVal) { 246 | if (newVal) { 247 | scope.refreshSelectedItems(); 248 | scope.refreshOutputModel(); 249 | } 250 | }); 251 | /** 252 | * Checks whether any of children match the keyword. 253 | * 254 | * @param item the parent item 255 | * @param keyword the filter keyword 256 | * @returns {boolean} false if matches. 257 | */ 258 | function isChildrenFiltered(item, keyword) { 259 | var childNodes = getAllChildNodesFromNode(item, []); 260 | for (var i = 0, len = childNodes.length; i < len; i++) { 261 | if (childNodes[i].name.toLowerCase().indexOf(keyword.toLowerCase()) !== -1) { 262 | return false; 263 | } 264 | } 265 | return true; 266 | } 267 | /** 268 | * Return all childNodes of a given node (as Array of Nodes) 269 | */ 270 | function getAllChildNodesFromNode(node, childNodes) { 271 | for (var i = 0; i < node.children.length; i++) { 272 | childNodes.push(node.children[i]); 273 | // add the childNodes from the children if available 274 | getAllChildNodesFromNode(node.children[i], childNodes); 275 | } 276 | return childNodes; 277 | } 278 | scope.$watch('filterKeyword', function () { 279 | if (scope.filterKeyword !== undefined) { 280 | angular.forEach(scope.inputModel, function (item) { 281 | if (item.name.toLowerCase().indexOf(scope.filterKeyword.toLowerCase()) !== -1) { 282 | item.isFiltered = false; 283 | } else if (!isChildrenFiltered(item, scope.filterKeyword)) { 284 | item.isFiltered = false; 285 | } else { 286 | item.isFiltered = true; 287 | } 288 | }); 289 | } 290 | }); 291 | }, 292 | controller: 'multiSelectTreeCtrl' 293 | }; 294 | }); 295 | }()); 296 | /*jshint indent: 2 */ 297 | /*global angular: false */ 298 | (function () { 299 | 'use strict'; 300 | var mainModule = angular.module('multi-select-tree'); 301 | /** 302 | * Controller for sortable item. 303 | * 304 | * @param $scope - drag item scope 305 | */ 306 | mainModule.controller('treeItemCtrl', [ 307 | '$scope', 308 | function ($scope) { 309 | $scope.item.isExpanded = false; 310 | /** 311 | * Shows the expand option. 312 | * 313 | * @param item the item 314 | * @returns {*|boolean} 315 | */ 316 | $scope.showExpand = function (item) { 317 | return item.children && item.children.length > 0; 318 | }; 319 | /** 320 | * On expand clicked toggle the option. 321 | * 322 | * @param item the item 323 | * @param $event 324 | */ 325 | $scope.onExpandClicked = function (item, $event) { 326 | $event.stopPropagation(); 327 | item.isExpanded = !item.isExpanded; 328 | }; 329 | /** 330 | * Event on click of select item. 331 | * 332 | * @param item the item 333 | * @param $event 334 | */ 335 | $scope.clickSelectItem = function (item, $event) { 336 | $event.stopPropagation(); 337 | if ($scope.itemSelected) { 338 | $scope.itemSelected({ item: item }); 339 | } 340 | }; 341 | /** 342 | * Is leaf selected. 343 | * 344 | * @param item the item 345 | * @param $event 346 | */ 347 | $scope.subItemSelected = function (item, $event) { 348 | if ($scope.itemSelected) { 349 | $scope.itemSelected({ item: item }); 350 | } 351 | }; 352 | /** 353 | * Active sub item. 354 | * 355 | * @param item the item 356 | * @param $event 357 | */ 358 | $scope.activeSubItem = function (item, $event) { 359 | if ($scope.onActiveItem) { 360 | $scope.onActiveItem({ item: item }); 361 | } 362 | }; 363 | /** 364 | * On mouse over event. 365 | * 366 | * @param item the item 367 | * @param $event 368 | */ 369 | $scope.onMouseOver = function (item, $event) { 370 | $event.stopPropagation(); 371 | if ($scope.onActiveItem) { 372 | $scope.onActiveItem({ item: item }); 373 | } 374 | }; 375 | /** 376 | * Can select item. 377 | * 378 | * @returns {*} 379 | */ 380 | $scope.showCheckbox = function () { 381 | if (!$scope.multiSelect) { 382 | return false; 383 | } 384 | if ($scope.selectOnlyLeafs) { 385 | return false; 386 | } 387 | if ($scope.useCallback) { 388 | return $scope.canSelectItem($scope.item); 389 | } 390 | }; 391 | } 392 | ]); 393 | /** 394 | * sortableItem directive. 395 | */ 396 | mainModule.directive('treeItem', [ 397 | '$compile', 398 | function ($compile) { 399 | return { 400 | restrict: 'E', 401 | templateUrl: 'src/tree-item.tpl.html', 402 | scope: { 403 | item: '=', 404 | itemSelected: '&', 405 | onActiveItem: '&', 406 | multiSelect: '=?', 407 | selectOnlyLeafs: '=?', 408 | isActive: '=', 409 | useCallback: '=', 410 | canSelectItem: '=' 411 | }, 412 | controller: 'treeItemCtrl', 413 | compile: function (element, attrs, link) { 414 | // Normalize the link parameter 415 | if (angular.isFunction(link)) { 416 | link = { post: link }; 417 | } 418 | // Break the recursion loop by removing the contents 419 | var contents = element.contents().remove(); 420 | var compiledContents; 421 | return { 422 | pre: link && link.pre ? link.pre : null, 423 | post: function (scope, element, attrs) { 424 | // Compile the contents 425 | if (!compiledContents) { 426 | compiledContents = $compile(contents); 427 | } 428 | // Re-add the compiled contents to the element 429 | compiledContents(scope, function (clone) { 430 | element.append(clone); 431 | }); 432 | // Call the post-linking function, if any 433 | if (link && link.post) { 434 | link.post.apply(null, arguments); 435 | } 436 | } 437 | }; 438 | } 439 | }; 440 | } 441 | ]); 442 | }()); -------------------------------------------------------------------------------- /dist/angular-multi-select-tree-0.1.0.min.css: -------------------------------------------------------------------------------- 1 | .tree-control .tree-input{position:relative;display:inline-block;text-align:center;cursor:pointer;border:1px solid #c6c6c6;padding:1px 8px;font-size:14px;min-height:38px!important;border-radius:4px;color:#555;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none;white-space:normal;background-color:#fff;background-image:linear-gradient(#fff,#f7f7f7)}.tree-control .tree-input:hover{background-image:linear-gradient(#fff,#e9e9e9)}.tree-control .caret{display:inline-block;width:0;height:0;margin:0 0 1px 12px!important;vertical-align:middle;border-top:4px solid #333;border-right:4px solid transparent;border-left:4px solid transparent;border-bottom:0 dotted}.tree-control .tree-input span.selected-items .selected-item{background:#f2f2f2;border:1px solid #a9a9a9;border-radius:3px;padding:3px;cursor:text}.tree-control .tree-input span.selected-items .selected-item-close{width:20px;cursor:pointer;font-weight:700;display:inline-block;padding:2px;text-align:center}.tree-control .tree-input span.selected-items .selected-item-close:hover{background-color:#f2f2f2}.tree-control .tree-input span.selected-items .selected-item-close:before{content:'x'}.tree-control .tree-view{background-color:#fff;position:absolute;z-index:999;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175);min-width:348px;margin-right:30px;max-height:300px;overflow:auto;padding:10px 5px}.tree-control .tree-view ul{padding:0;margin:0}.tree-control .tree-view ul .item-details{display:inline-block;margin-left:5px}.tree-control .tree-view ul .tree-checkbox{margin-right:3px;margin-top:0;color:#ddd!important;cursor:pointer}.tree-control .tree-view .active{background-color:#f2f2f2;border-radius:3px}.tree-control .tree-view .selected.active{background-color:#46b8da}.tree-control .tree-view .helper-container{border-bottom:1px solid #ddd;padding:8px 8px 0}.tree-control .tree-view .tree-container{padding:8px}.tree-control .tree-view .item-container{padding:3px;color:#444;white-space:nowrap;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none;border:1px solid transparent;position:relative}.tree-control .tree-view .item-container:hover{background-image:linear-gradient(#c1c1c1,#999)!important;color:#fff!important;cursor:pointer;border:1px solid #ccc!important}.tree-control .tree-view .selected{background-image:linear-gradient(#e9e9e9,#f1f1f1);color:#555;cursor:pointer;border-top:1px solid #e4e4e4;border-left:1px solid #e4e4e4;border-right:1px solid #d9d9d9}.tree-control .tree-view .helper-button{display:inline;text-align:center;cursor:pointer;border:1px solid #ccc;height:26px;font-size:13px;border-radius:2px;color:#666;background-color:#f1f1f1;line-height:1.6;margin:0 0 8px}.tree-control .tree-view .clear-button{position:absolute;display:inline;text-align:center;cursor:pointer;border:1px solid #ccc;height:22px;width:22px;font-size:13px;border-radius:2px;color:#666;background-color:#f1f1f1;line-height:1.4;right:2px;top:2px}.tree-control .tree-view .input-filter{border-radius:2px;border:1px solid #ccc;height:26px;font-size:14px;width:100%;padding-left:7px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;color:#888;margin:0 0 8px}.tree-control .tree-view .clear-button:hover,.tree-control .tree-view .helper-button:hover{border:1px solid #ccc;color:#999;background-color:#f4f4f4}.tree-control .tree-view .clear-button:focus,.tree-control .tree-view .helper-button:focus,.tree-control .tree-view .input-filter:focus{border:1px solid #66AFE9!important;box-shadow:inset 0 0 1px rgba(0,0,0,.035),0 0 5px rgba(82,168,236,.7)!important}.tree-control .tree-view .line{max-height:34px;overflow:hidden;position:relative}.tree-control .tree-view .item-close{width:20px;cursor:pointer;font-weight:700;padding:5px}.tree-control .tree-view .item-close:hover{background-color:#f2f2f2}.tree-control .tree-view .item-close:before{content:'x'}.tree-control .tree-view li{list-style-type:none;margin-left:15px}.tree-control .tree-view li .expand{display:inline-block;width:0;height:0;border-top:6px solid transparent;border-bottom:6px solid transparent;border-left:10px solid #525252}.tree-control .tree-view li .expand-opened{border:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:10px solid #525252}.tree-control .tree-view li.top-level{margin:0} -------------------------------------------------------------------------------- /dist/angular-multi-select-tree-0.1.0.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";angular.module("multi-select-tree",[])}(),function(){"use strict";var a=angular.module("multi-select-tree");a.controller("multiSelectTreeCtrl",["$scope","$document",function(a,b){function c(){d(),a.$apply()}function d(){a.showTree=!1,g&&(g.isActive=!1,g=void 0),b.off("click",c)}function e(b){for(var c=0,d=b.length;d>c;c++)f(b[c])||b[c].selected!==!0?f(b[c])&&b[c].selected===!1&&(b[c].selected=!0):a.selectedItems.push(b[c]),b[c]&&b[c].children&&e(b[c].children)}function f(b){var c=!1;if(a.selectedItems)for(var d=0;d0)){if(a.multiSelect){b.selected=!0;var c=a.selectedItems.indexOf(b);f(b)?(b.selected=!1,a.selectedItems.splice(c,1)):a.selectedItems.push(b)}else{d();for(var e=0;ed;d++)if(-1!==c[d].name.toLowerCase().indexOf(b.toLowerCase()))return!1;return!0}function e(a,b){for(var c=0;c0},a.onExpandClicked=function(a,b){b.stopPropagation(),a.isExpanded=!a.isExpanded},a.clickSelectItem=function(b,c){c.stopPropagation(),a.itemSelected&&a.itemSelected({item:b})},a.subItemSelected=function(b){a.itemSelected&&a.itemSelected({item:b})},a.activeSubItem=function(b){a.onActiveItem&&a.onActiveItem({item:b})},a.onMouseOver=function(b,c){c.stopPropagation(),a.onActiveItem&&a.onActiveItem({item:b})},a.showCheckbox=function(){return a.multiSelect?a.selectOnlyLeafs?!1:a.useCallback?a.canSelectItem(a.item):void 0:!1}}]),a.directive("treeItem",["$compile",function(a){return{restrict:"E",templateUrl:"src/tree-item.tpl.html",scope:{item:"=",itemSelected:"&",onActiveItem:"&",multiSelect:"=?",selectOnlyLeafs:"=?",isActive:"=",useCallback:"=",canSelectItem:"="},controller:"treeItemCtrl",compile:function(b,c,d){angular.isFunction(d)&&(d={post:d});var e,f=b.contents().remove();return{pre:d&&d.pre?d.pre:null,post:function(b,c){e||(e=a(f)),e(b,function(a){c.append(a)}),d&&d.post&&d.post.apply(null,arguments)}}}}}])}(); -------------------------------------------------------------------------------- /dist/angular-multi-select-tree-0.1.0.tpl.js: -------------------------------------------------------------------------------- 1 | angular.module('multi-select-tree').run(['$templateCache', function($templateCache) { 2 | 'use strict'; 3 | 4 | $templateCache.put('src/multi-select-tree.tpl.html', 5 | "
\n" + 6 | "\n" + 7 | "
\n" + 8 | " \n" + 9 | " \n" + 10 | " \n" + 11 | " 0\" class=\"selected-items\">\n" + 12 | " {{selectedItem.name}} \n" + 14 | " \n" + 15 | " \n" + 16 | " \n" + 17 | "
\n" + 18 | "
\n" + 19 | "
\n" + 20 | "
\n" + 21 | " \n" + 22 | "
\n" + 23 | "
\n" + 24 | " \n" + 26 | " \n" + 27 | "
\n" + 28 | "
\n" + 29 | "
    \n" + 30 | " \n" + 34 | "
\n" + 35 | "
\n" + 36 | "
\n" 37 | ); 38 | 39 | 40 | $templateCache.put('src/tree-item.tpl.html', 41 | "
  • \n" + 42 | "
    \n" + 44 | " \n" + 46 | "\n" + 47 | "
    {{item.name}}\n" + 49 | "
    \n" + 50 | "
    \n" + 51 | "
      \n" + 52 | " \n" + 55 | "
    \n" + 56 | "
  • \n" 57 | ); 58 | 59 | }]); 60 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | 'use strict'; 3 | 4 | config.set({ 5 | 6 | // base path, that will be used to resolve files and exclude 7 | basePath: '', 8 | 9 | 10 | // list of files / patterns to load in the browser 11 | files: [ 12 | 'bower_components/angular/angular.js', 13 | 'bower_components/angular-mocks/angular-mocks.js', 14 | 'src/**/*.js', 15 | 'test/**/*spec.js' 16 | ], 17 | 18 | frameworks: ['jasmine'], 19 | 20 | 21 | // list of files to exclude 22 | exclude: [ 23 | 24 | ], 25 | 26 | 27 | // test results reporter to use 28 | // possible values: 'dots', 'progress', 'junit' 29 | reporters: ['progress'], 30 | 31 | // web server port 32 | port: 9876, 33 | 34 | 35 | // cli runner port 36 | runnerPort: 9100, 37 | 38 | 39 | // enable / disable colors in the output (reporters and logs) 40 | colors: true, 41 | 42 | 43 | // level of logging 44 | // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG 45 | logLevel: config.LOG_INFO, 46 | 47 | 48 | // enable / disable watching file and executing tests whenever any file changes 49 | autoWatch: true, 50 | 51 | 52 | // Start these browsers, currently available: 53 | // - Chrome 54 | // - ChromeCanary 55 | // - Firefox 56 | // - Opera 57 | // - Safari (only Mac) 58 | // - PhantomJS 59 | // - IE (only Windows) 60 | browsers: ['Chrome'], 61 | 62 | 63 | // If browser does not capture in given timeout [ms], kill it 64 | captureTimeout: 60000, 65 | 66 | 67 | // Continuous Integration mode 68 | // if true, it capture browsers, run tests and exit 69 | singleRun: false 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-multi-select-tree", 3 | "version": "0.1.0", 4 | "description": "A hierarchical (or tree) selection control for AngularJS", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/a5hik/angular-multi-select-tree" 15 | }, 16 | "keywords": [ 17 | "tree", 18 | "control", 19 | "hierarchical", 20 | "hierarchy", 21 | "selection", 22 | "angualr", 23 | "angularjs" 24 | ], 25 | "author": "Muhammed Ashik", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/a5hik/angular-multi-select-tree/issues" 29 | }, 30 | "homepage": "https://github.com/a5hik/angular-multi-select-tree", 31 | "devDependencies": { 32 | "grunt": "~0.4.2", 33 | "grunt-contrib-watch": "~0.5.3", 34 | "grunt-contrib-concat": "~0.3.x", 35 | "grunt-contrib-copy": "~0.4.1", 36 | "grunt-contrib-uglify": "~0.2.x", 37 | "grunt-contrib-jshint": "~0.4.x", 38 | "grunt-contrib-cssmin": "~0.7.0", 39 | "grunt-contrib-clean": "~0.5.0", 40 | "grunt-contrib-connect": "~0.5.0", 41 | "grunt-angular-templates": "~0.5.7", 42 | "grunt-open": "~0.2.3", 43 | "grunt-karma": "~0.7.x", 44 | "karma-jasmine": "~0.2.2", 45 | "karma-chrome-launcher": "~0.1", 46 | "grunt-conventional-changelog": "~1.0.x", 47 | "grunt-ngmin": "~0.0.3", 48 | "load-grunt-tasks": "~0.2.0", 49 | "lodash": "~2.4.x" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/multi-select-tree-main.js: -------------------------------------------------------------------------------- 1 | /* 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2014 Muhammed Ashik 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | /*jshint indent: 2 */ 26 | /*global angular: false */ 27 | 28 | (function () { 29 | 'use strict'; 30 | angular.module('multi-select-tree', []); 31 | }()); 32 | -------------------------------------------------------------------------------- /src/multi-select-tree.js: -------------------------------------------------------------------------------- 1 | /*jshint indent: 2 */ 2 | /*global angular: false */ 3 | 4 | (function () { 5 | 6 | 'use strict'; 7 | var mainModule = angular.module('multi-select-tree'); 8 | 9 | /** 10 | * Controller for multi select tree. 11 | */ 12 | mainModule.controller('multiSelectTreeCtrl', ['$scope', '$document', function ($scope, $document) { 13 | 14 | var activeItem; 15 | 16 | $scope.showTree = false; 17 | $scope.selectedItems = []; 18 | $scope.multiSelect = $scope.multiSelect || false; 19 | 20 | /** 21 | * Clicking on document will hide the tree. 22 | */ 23 | function docClickHide() { 24 | closePopup(); 25 | $scope.$apply(); 26 | } 27 | 28 | /** 29 | * Closes the tree popup. 30 | */ 31 | function closePopup() { 32 | $scope.showTree = false; 33 | if (activeItem) { 34 | activeItem.isActive = false; 35 | activeItem = undefined; 36 | } 37 | $document.off('click', docClickHide); 38 | } 39 | 40 | /** 41 | * Sets the active item. 42 | * 43 | * @param item the item element. 44 | */ 45 | $scope.onActiveItem = function (item) { 46 | if (activeItem !== item) { 47 | if (activeItem) { 48 | activeItem.isActive = false; 49 | } 50 | activeItem = item; 51 | activeItem.isActive = true; 52 | } 53 | }; 54 | 55 | /** 56 | * Copies the selectedItems in to output model. 57 | */ 58 | $scope.refreshOutputModel = function () { 59 | $scope.outputModel = angular.copy($scope.selectedItems); 60 | }; 61 | 62 | /** 63 | * Refreshes the selected Items model. 64 | */ 65 | $scope.refreshSelectedItems = function () { 66 | $scope.selectedItems = []; 67 | if ($scope.inputModel) { 68 | setSelectedChildren($scope.inputModel); 69 | } 70 | }; 71 | 72 | /** 73 | * Iterates over children and sets the selected items. 74 | * 75 | * @param children the children element. 76 | */ 77 | function setSelectedChildren(children) { 78 | for (var i = 0, len = children.length; i < len; i++) { 79 | if (!isItemSelected(children[i]) && children[i].selected === true) { 80 | $scope.selectedItems.push(children[i]); 81 | } else if (isItemSelected(children[i]) && children[i].selected === false) { 82 | children[i].selected = true; 83 | } 84 | if (children[i] && children[i].children) { 85 | setSelectedChildren(children[i].children); 86 | } 87 | } 88 | } 89 | /** 90 | * Checks of the item is already selected. 91 | * 92 | * @param item the item to be checked. 93 | * @return {boolean} if the item is already selected. 94 | */ 95 | function isItemSelected(item) { 96 | var isSelected = false; 97 | if ($scope.selectedItems) { 98 | for (var i = 0; i < $scope.selectedItems.length; i++) { 99 | if ($scope.selectedItems[i].id === item.id) { 100 | isSelected = true; 101 | break; 102 | } 103 | } 104 | } 105 | return isSelected; 106 | } 107 | 108 | /** 109 | * Deselect the item. 110 | * 111 | * @param item the item element 112 | * @param $event 113 | */ 114 | $scope.deselectItem = function (item, $event) { 115 | $event.stopPropagation(); 116 | $scope.selectedItems.splice($scope.selectedItems.indexOf(item), 1); 117 | item.selected = false; 118 | this.refreshOutputModel(); 119 | }; 120 | 121 | /** 122 | * Swap the tree popup on control click event. 123 | * 124 | * @param $event the click event. 125 | */ 126 | $scope.onControlClicked = function ($event) { 127 | $event.stopPropagation(); 128 | $scope.showTree = !$scope.showTree; 129 | if ($scope.showTree) { 130 | $document.on('click', docClickHide); 131 | } 132 | }; 133 | 134 | /** 135 | * Stop the event on filter clicked. 136 | * 137 | * @param $event the click event 138 | */ 139 | $scope.onFilterClicked = function ($event) { 140 | $event.stopPropagation(); 141 | }; 142 | 143 | /** 144 | * Clears the filter text. 145 | * 146 | * @param $event the click event 147 | */ 148 | $scope.clearFilter = function ($event) { 149 | $event.stopPropagation(); 150 | $scope.filterKeyword = ''; 151 | }; 152 | 153 | /** 154 | * Wrapper function for can select item callback. 155 | * 156 | * @param item the item 157 | */ 158 | $scope.canSelectItem = function (item) { 159 | return $scope.callback({item: item, selectedItems: $scope.selectedItems}); 160 | }; 161 | 162 | /** 163 | * The callback is used to switch the views. 164 | * based on the view type. 165 | * 166 | * @param $event the event object. 167 | */ 168 | $scope.switchCurrentView = function($event) { 169 | $event.stopPropagation(); 170 | $scope.switchViewCallback({scopeObj:$scope}); 171 | }; 172 | 173 | /** 174 | * Handles the item select event. 175 | * 176 | * @param item the selected item. 177 | */ 178 | $scope.itemSelected = function (item) { 179 | if (($scope.useCallback && $scope.canSelectItem(item) === false) || 180 | ($scope.selectOnlyLeafs && item.children && item.children.length > 0)) { 181 | return; 182 | } 183 | 184 | if (!$scope.multiSelect) { 185 | closePopup(); 186 | for (var i = 0; i < $scope.selectedItems.length; i++) { 187 | $scope.selectedItems[i].selected = false; 188 | } 189 | item.selected = true; 190 | $scope.selectedItems = []; 191 | $scope.selectedItems.push(item); 192 | } else { 193 | item.selected = true; 194 | var indexOfItem = $scope.selectedItems.indexOf(item); 195 | if (isItemSelected(item)) { 196 | item.selected = false; 197 | $scope.selectedItems.splice(indexOfItem, 1); 198 | } else { 199 | $scope.selectedItems.push(item); 200 | } 201 | } 202 | this.refreshOutputModel(); 203 | }; 204 | 205 | }]); 206 | 207 | /** 208 | * sortableItem directive. 209 | */ 210 | mainModule.directive('multiSelectTree', 211 | function () { 212 | return { 213 | restrict: 'E', 214 | templateUrl: 'src/multi-select-tree.tpl.html', 215 | scope: { 216 | inputModel: '=', 217 | outputModel: '=?', 218 | multiSelect: '=?', 219 | switchView: '=?', 220 | switchViewLabel: '@', 221 | switchViewCallback: '&', 222 | selectOnlyLeafs: '=?', 223 | callback: '&', 224 | defaultLabel: '@' 225 | }, 226 | link: function (scope, element, attrs) { 227 | if (attrs.callback) { 228 | scope.useCallback = true; 229 | } 230 | 231 | // watch for changes in input model as a whole 232 | // this on updates the multi-select when a user load a whole new input-model. 233 | scope.$watch('inputModel', function (newVal) { 234 | if (newVal) { 235 | scope.refreshSelectedItems(); 236 | scope.refreshOutputModel(); 237 | } 238 | }); 239 | 240 | /** 241 | * Checks whether any of children match the keyword. 242 | * 243 | * @param item the parent item 244 | * @param keyword the filter keyword 245 | * @returns {boolean} false if matches. 246 | */ 247 | function isChildrenFiltered(item, keyword) { 248 | var childNodes = getAllChildNodesFromNode(item, []); 249 | for (var i = 0, len = childNodes.length; i < len; i++) { 250 | if (childNodes[i].name.toLowerCase().indexOf(keyword.toLowerCase()) !== -1) { 251 | return false; 252 | } 253 | } 254 | return true; 255 | } 256 | 257 | /** 258 | * Return all childNodes of a given node (as Array of Nodes) 259 | */ 260 | function getAllChildNodesFromNode(node, childNodes) { 261 | for (var i = 0; i < node.children.length; i++) { 262 | childNodes.push(node.children[i]); 263 | // add the childNodes from the children if available 264 | getAllChildNodesFromNode(node.children[i], childNodes); 265 | } 266 | return childNodes; 267 | } 268 | 269 | scope.$watch('filterKeyword', function () { 270 | if (scope.filterKeyword !== undefined) { 271 | angular.forEach(scope.inputModel, function (item) { 272 | if (item.name.toLowerCase().indexOf(scope.filterKeyword.toLowerCase()) !== -1) { 273 | item.isFiltered = false; 274 | } else if (!isChildrenFiltered(item, scope.filterKeyword)) { 275 | item.isFiltered = false; 276 | } else { 277 | item.isFiltered = true; 278 | } 279 | }); 280 | } 281 | }); 282 | }, 283 | controller: 'multiSelectTreeCtrl' 284 | }; 285 | }); 286 | }()); -------------------------------------------------------------------------------- /src/multi-select-tree.less: -------------------------------------------------------------------------------- 1 | .tree-control .tree-input { 2 | position: relative; 3 | display: inline-block; 4 | text-align: center; 5 | cursor: pointer; 6 | border: 1px solid #c6c6c6; 7 | padding: 1px 8px 1px 8px; 8 | font-size: 14px; 9 | min-height : 38px !important; 10 | border-radius: 4px; 11 | color: #555; 12 | -webkit-user-select: none; 13 | -moz-user-select: none; 14 | -ms-user-select: none; 15 | -o-user-select: none; 16 | user-select: none; 17 | white-space:normal; 18 | background-color: #fff; 19 | background-image: linear-gradient(#fff, #f7f7f7); 20 | } 21 | 22 | .tree-control .tree-input:hover { 23 | background-image: linear-gradient(#fff, #e9e9e9); 24 | } 25 | 26 | /* downward pointing arrow */ 27 | .tree-control .caret { 28 | display: inline-block; 29 | width: 0; 30 | height: 0; 31 | margin: 0px 0px 1px 12px !important; 32 | vertical-align: middle; 33 | border-top: 4px solid #333; 34 | border-right: 4px solid transparent; 35 | border-left: 4px solid transparent; 36 | border-bottom: 0 dotted; 37 | } 38 | 39 | .tree-control .tree-input span.selected-items .selected-item { 40 | background: #f2f2f2; 41 | border: 1px solid darkgray; 42 | border-radius: 3px; 43 | padding: 3px; 44 | cursor: text; 45 | } 46 | 47 | .tree-control .tree-input span.selected-items .selected-item-close { 48 | width: 20px; 49 | cursor: pointer; 50 | font-weight: bold; 51 | display: inline-block; 52 | padding: 2px; 53 | text-align: center; 54 | } 55 | .tree-control .tree-input span.selected-items .selected-item-close:hover { 56 | background-color: #f2f2f2 57 | } 58 | .tree-control .tree-input span.selected-items .selected-item-close:before { 59 | content: 'x'; 60 | } 61 | 62 | .tree-control .tree-view { 63 | background-color: #fff; 64 | position: absolute; 65 | z-index: 999; 66 | border: 1px solid rgba(0, 0, 0, 0.15); 67 | border-radius: 4px; 68 | -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 69 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 70 | min-width:348px; 71 | margin-right: 30px; 72 | max-height: 300px; 73 | overflow: auto; 74 | padding: 10px 5px; 75 | } 76 | .tree-control .tree-view ul { 77 | padding: 0; 78 | margin: 0; 79 | } 80 | .tree-control .tree-view ul .item-details { 81 | display: inline-block; 82 | margin-left: 5px; 83 | } 84 | .tree-control .tree-view ul .tree-checkbox { 85 | margin-right: 3px; 86 | margin-top: 0; 87 | color: #ddd !important; 88 | cursor: pointer; 89 | } 90 | .tree-control .tree-view .active { 91 | background-color: #f2f2f2; 92 | border-radius: 3px; 93 | } 94 | .tree-control .tree-view .selected.active { 95 | background-color: #46b8da; 96 | } 97 | 98 | /* container of helper elements */ 99 | .tree-control .tree-view .helper-container { 100 | border-bottom: 1px solid #ddd; 101 | padding: 8px 8px 0px 8px; 102 | } 103 | 104 | /* container of multi select items */ 105 | .tree-control .tree-view .tree-container { 106 | padding: 8px; 107 | } 108 | 109 | .tree-control .tree-view .item-container { 110 | padding: 3px; 111 | color: #444; 112 | white-space: nowrap; 113 | -webkit-user-select: none; 114 | -moz-user-select: none; 115 | -ms-user-select: none; 116 | -o-user-select: none; 117 | user-select: none; 118 | border: 1px solid transparent; 119 | position: relative; 120 | } 121 | 122 | /* item labels focus on mouse hover */ 123 | .tree-control .tree-view .item-container:hover { 124 | background-image: linear-gradient( #c1c1c1, #999 ) !important; 125 | color: #fff !important; 126 | cursor: pointer; 127 | border: 1px solid #ccc !important; 128 | } 129 | 130 | .tree-control .tree-view .selected { 131 | background-image: linear-gradient( #e9e9e9, #f1f1f1 ); 132 | color: #555; 133 | cursor: pointer; 134 | border-top: 1px solid #e4e4e4; 135 | border-left: 1px solid #e4e4e4; 136 | border-right: 1px solid #d9d9d9; 137 | } 138 | 139 | /* helper buttons (select all, none, reset); */ 140 | .tree-control .tree-view .helper-button { 141 | display: inline; 142 | text-align: center; 143 | cursor: pointer; 144 | border: 1px solid #ccc; 145 | height: 26px; 146 | font-size: 13px; 147 | border-radius: 2px; 148 | color: #666; 149 | background-color: #f1f1f1; 150 | line-height: 1.6; 151 | margin: 0px 0px 8px 0px; 152 | } 153 | 154 | /* clear button */ 155 | .tree-control .tree-view .clear-button { 156 | position: absolute; 157 | display: inline; 158 | text-align: center; 159 | cursor: pointer; 160 | border: 1px solid #ccc; 161 | height: 22px; 162 | width: 22px; 163 | font-size: 13px; 164 | border-radius: 2px; 165 | color: #666; 166 | background-color: #f1f1f1; 167 | line-height: 1.4; 168 | right : 2px; 169 | top: 2px; 170 | } 171 | 172 | /* filter */ 173 | .tree-control .tree-view .input-filter { 174 | border-radius: 2px; 175 | border: 1px solid #ccc; 176 | height: 26px; 177 | font-size: 14px; 178 | width:100%; 179 | padding-left:7px; 180 | -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ 181 | -moz-box-sizing: border-box; /* Firefox, other Gecko */ 182 | box-sizing: border-box; /* Opera/IE 8+ */ 183 | color: #888; 184 | margin: 0px 0px 8px 0px; 185 | } 186 | 187 | /* helper elements on hover & focus */ 188 | .tree-control .tree-view .clear-button:hover, 189 | .tree-control .tree-view .helper-button:hover { 190 | border: 1px solid #ccc; 191 | color: #999; 192 | background-color: #f4f4f4; 193 | } 194 | 195 | .tree-control .tree-view .clear-button:focus, 196 | .tree-control .tree-view .helper-button:focus, 197 | .tree-control .tree-view .input-filter:focus { 198 | border: 1px solid #66AFE9 !important; 199 | box-shadow: inset 0 0px 1px rgba(0,0,0,.035), 0 0 5px rgba(82,168,236,.7) !important; 200 | } 201 | 202 | /* ! create a "row" */ 203 | .tree-control .tree-view .line { 204 | max-height: 34px; 205 | overflow: hidden; 206 | position: relative; 207 | } 208 | 209 | .tree-control .tree-view .item-close { 210 | width: 20px; 211 | cursor: pointer; 212 | font-weight: bold; 213 | padding: 5px; 214 | } 215 | .tree-control .tree-view .item-close:hover { 216 | background-color: #f2f2f2 217 | } 218 | .tree-control .tree-view .item-close:before { 219 | content: 'x'; 220 | } 221 | 222 | .tree-control .tree-view li { 223 | list-style-type: none; 224 | margin-left: 15px; 225 | } 226 | 227 | .tree-control .tree-view li .expand { 228 | display: inline-block; 229 | width: 0; 230 | height: 0; 231 | border-top: 6px solid transparent; 232 | border-bottom: 6px solid transparent; 233 | border-left: 10px solid #525252; 234 | } 235 | .tree-control .tree-view li .expand-opened { 236 | border: none; 237 | border-left: 6px solid transparent; 238 | border-right: 6px solid transparent; 239 | border-top: 10px solid #525252; 240 | } 241 | .tree-control .tree-view li.top-level { 242 | margin: 0; 243 | } 244 | -------------------------------------------------------------------------------- /src/multi-select-tree.tpl.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 | 5 | 6 | 7 | 8 | {{selectedItem.name}} 10 | 11 | 12 | 13 |
    14 |
    15 |
    16 |
    17 | 18 |
    19 |
    20 | 22 | 23 |
    24 |
    25 |
      26 | 30 |
    31 |
    32 |
    33 | -------------------------------------------------------------------------------- /src/tree-item.js: -------------------------------------------------------------------------------- 1 | /*jshint indent: 2 */ 2 | /*global angular: false */ 3 | 4 | (function () { 5 | 6 | 'use strict'; 7 | var mainModule = angular.module('multi-select-tree'); 8 | 9 | /** 10 | * Controller for sortable item. 11 | * 12 | * @param $scope - drag item scope 13 | */ 14 | mainModule.controller('treeItemCtrl', ['$scope', function ($scope) { 15 | 16 | $scope.item.isExpanded = false; 17 | 18 | /** 19 | * Shows the expand option. 20 | * 21 | * @param item the item 22 | * @returns {*|boolean} 23 | */ 24 | $scope.showExpand = function (item) { 25 | return item.children && item.children.length > 0; 26 | }; 27 | 28 | /** 29 | * On expand clicked toggle the option. 30 | * 31 | * @param item the item 32 | * @param $event 33 | */ 34 | $scope.onExpandClicked = function (item, $event) { 35 | $event.stopPropagation(); 36 | item.isExpanded = !item.isExpanded; 37 | }; 38 | 39 | /** 40 | * Event on click of select item. 41 | * 42 | * @param item the item 43 | * @param $event 44 | */ 45 | $scope.clickSelectItem = function (item, $event) { 46 | $event.stopPropagation(); 47 | if ($scope.itemSelected) { 48 | $scope.itemSelected({item: item}); 49 | } 50 | }; 51 | 52 | /** 53 | * Is leaf selected. 54 | * 55 | * @param item the item 56 | * @param $event 57 | */ 58 | $scope.subItemSelected = function (item, $event) { 59 | if ($scope.itemSelected) { 60 | $scope.itemSelected({item: item}); 61 | } 62 | }; 63 | 64 | /** 65 | * Active sub item. 66 | * 67 | * @param item the item 68 | * @param $event 69 | */ 70 | $scope.activeSubItem = function (item, $event) { 71 | if ($scope.onActiveItem) { 72 | $scope.onActiveItem({item: item}); 73 | } 74 | }; 75 | 76 | /** 77 | * On mouse over event. 78 | * 79 | * @param item the item 80 | * @param $event 81 | */ 82 | $scope.onMouseOver = function (item, $event) { 83 | $event.stopPropagation(); 84 | if ($scope.onActiveItem) { 85 | $scope.onActiveItem({item: item}); 86 | } 87 | }; 88 | 89 | /** 90 | * Can select item. 91 | * 92 | * @returns {*} 93 | */ 94 | $scope.showCheckbox = function () { 95 | if (!$scope.multiSelect) { 96 | return false; 97 | } 98 | 99 | if ($scope.selectOnlyLeafs) { 100 | return false; 101 | } 102 | 103 | if ($scope.useCallback) { 104 | return $scope.canSelectItem($scope.item); 105 | } 106 | }; 107 | 108 | }]); 109 | 110 | /** 111 | * sortableItem directive. 112 | */ 113 | mainModule.directive('treeItem', ['$compile', 114 | function ($compile) { 115 | return { 116 | restrict: 'E', 117 | templateUrl: 'src/tree-item.tpl.html', 118 | scope: { 119 | item: '=', 120 | itemSelected: '&', 121 | onActiveItem: '&', 122 | multiSelect: '=?', 123 | selectOnlyLeafs: '=?', 124 | isActive: '=', // the item is active - means it is highlighted but not selected 125 | useCallback: '=', 126 | canSelectItem: '=' // reference from the parent control 127 | }, 128 | controller: 'treeItemCtrl', 129 | /** 130 | * Manually compiles the element, fixing the recursion loop. 131 | * @param element 132 | * @param [link] A post-link function, or an object with function(s) registered via pre and post properties. 133 | * @returns An object containing the linking functions. 134 | */ 135 | compile: function (element, attrs, link) { 136 | // Normalize the link parameter 137 | if (angular.isFunction(link)) { 138 | link = { post: link }; 139 | } 140 | 141 | // Break the recursion loop by removing the contents 142 | var contents = element.contents().remove(); 143 | var compiledContents; 144 | return { 145 | pre: (link && link.pre) ? link.pre : null, 146 | /** 147 | * Compiles and re-adds the contents 148 | */ 149 | post: function (scope, element, attrs) { 150 | // Compile the contents 151 | if (!compiledContents) { 152 | compiledContents = $compile(contents); 153 | } 154 | // Re-add the compiled contents to the element 155 | compiledContents(scope, function (clone) { 156 | element.append(clone); 157 | }); 158 | 159 | // Call the post-linking function, if any 160 | if (link && link.post) { 161 | link.post.apply(null, arguments); 162 | } 163 | } 164 | }; 165 | } 166 | }; 167 | }]); 168 | }()); -------------------------------------------------------------------------------- /src/tree-item.tpl.html: -------------------------------------------------------------------------------- 1 |
  • 2 |
    4 | 6 | 7 |
    {{item.name}} 9 |
    10 |
    11 |
      12 | 15 |
    16 |
  • 17 | -------------------------------------------------------------------------------- /test/unit/componentSpec.js: -------------------------------------------------------------------------------- 1 | describe('author.component-name', function () { 2 | 3 | beforeEach(module('author.component-name')); 4 | 5 | it('should have thingService', function () { 6 | inject(function (thingService) { 7 | expect(thingService).toBeDefined(); 8 | }); 9 | }); 10 | 11 | describe('thingService', function () { 12 | 13 | var thingService; 14 | 15 | beforeEach(inject(function (_thingService_) { 16 | thingService = _thingService_; 17 | })); 18 | 19 | it('should be an object', function () { 20 | expect(typeof thingService).toBe('object'); 21 | }); 22 | 23 | it('should have a method sayHello()', function () { 24 | expect(thingService.sayHello).toBeDefined(); 25 | }); 26 | 27 | describe('sayHello()', function () { 28 | 29 | it('should be a function', function () { 30 | expect(typeof thingService.sayHello).toBe('function'); 31 | }); 32 | 33 | it('should return a string', function () { 34 | expect(typeof thingService.sayHello()).toBe('string'); 35 | }); 36 | 37 | it('should return \'Hello!\'', function () { 38 | expect(thingService.sayHello()).toEqual('Hello!'); 39 | }); 40 | }); 41 | }); 42 | }); 43 | --------------------------------------------------------------------------------