├── .gitignore ├── README.md ├── gulpfile.js ├── gulpfile.ts ├── package.json ├── resources └── radio-group-example.png ├── sample ├── sample1-simple-example │ ├── Car.ts │ ├── index.html │ └── main.ts ├── sample2-select-items │ ├── Car.ts │ ├── index.html │ └── main.ts ├── sample3-autocomplete │ ├── Car.ts │ ├── index.html │ └── main.ts ├── sample4-select-dropdown │ ├── Car.ts │ ├── index.html │ └── main.ts └── sample5-select-tags │ ├── Car.ts │ ├── index.html │ └── main.ts ├── src ├── Autocomplete.ts ├── Checkbox.ts ├── CheckboxGroup.ts ├── CheckboxItem.ts ├── ItemTemplate.ts ├── RadioBox.ts ├── RadioGroup.ts ├── RadioItem.ts ├── SelectControlsOptions.ts ├── SelectDropdown.ts ├── SelectItems.ts ├── SelectTags.ts ├── SelectValidator.ts ├── SelectValueAccessor.ts ├── Utils.ts ├── WidthCalculator.ts └── index.ts ├── tsconfig.json ├── tslint.json └── typings.json /.gitignore: -------------------------------------------------------------------------------- 1 | typings/ 2 | build/ 3 | node_modules 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > This repository is for demonstration purposes of how it can be implemented in Angular and is not maintaned. Please fork and maintain your own version of this repository. 2 | 3 | # ng2-radio-group 4 | 5 | Ultimate set of select components that you ever need. All with angular2, no jquery. 6 | Checkbox group and radio group control for your angular2 applications. Does not depend of jquery. 7 | Please star a project if you liked it, or create an issue if you have problems with it. 8 | 9 | ![angular 2 radio groups and checkbox groups](https://raw.githubusercontent.com/pleerock/ng2-radio-group/master/resources/radio-group-example.png) 10 | 11 | ## Installation 12 | 13 | 1. Install npm module: 14 | 15 | `npm install ng2-radio-group --save` 16 | 17 | 2. If you are using system.js you may want to add this into `map` and `package` config: 18 | 19 | ```json 20 | { 21 | "map": { 22 | "ng2-radio-group": "node_modules/ng2-radio-group" 23 | }, 24 | "packages": { 25 | "ng2-radio-group": { "main": "index.js", "defaultExtension": "js" } 26 | } 27 | } 28 | ``` 29 | 30 | ## Simple checkboxes and radio boxes 31 | 32 | If you need a simple checkbox for your variable: 33 | 34 | ```html 35 | remember me?
36 | ``` 37 | 38 | * `ngModel` is a model you are trying to change (rememberMe is a boolean variable in your component) 39 | * `value` is a value that should be written to the model when checkbox is checked. Default is **true**. 40 | * `uncheckedValue` is a value that should be written to the model when checkbox is unchecked. Default is **false**. 41 | * `required` you can set it to required, and you can use it with forms 42 | 43 | If your model is an array and you want to push multiple items into it, you can use it with this component: 44 | 45 | ```html 46 | Rating
47 | Date
48 | Watch count
49 | Comment count
50 | ``` 51 | 52 | Don't forget to initialize your `orderBy` array. 53 | 54 | 55 | If you need to select only one item from the multiple options, you need a radio boxes: 56 | 57 | ```html 58 | Rating
59 | Date
60 | Watch count
61 | Comment count
62 | ``` 63 | 64 | ## Checkbox and Radio groups 65 | 66 | To simplify this selection you can use checkbox and radio groups: 67 | 68 | ```html 69 | 70 | Rating
71 | Date
72 | Watch count
73 | Comment count
74 |
75 | 76 | 77 | Rating
78 | Date
79 | Watch count
80 | Comment count
81 |
82 | ``` 83 | 84 | If you want to go deeper and make better (but less customizable) radio groups, then use radio-groups in composition 85 | with radio-items: 86 | 87 | ```html 88 | 89 | Rating 90 | Date 91 | Watch count 92 | Comment count 93 | 94 | 95 | 96 | Rating 97 | Date 98 | Watch count 99 | Comment count 100 | 101 | ``` 102 | 103 | Last method allows you to click on labels and you click will treat like you clicked on a checkbox itself. 104 | 105 | ## Select items component 106 | 107 | tbd 108 | 109 | ## Select dropdown component 110 | 111 | tbd 112 | 113 | ## Autocomplete component 114 | 115 | tbd 116 | 117 | ## Select tags component 118 | 119 | tbd 120 | 121 | ## Sample 122 | 123 | Complete example of usage: 124 | 125 | ```typescript 126 | import {Component} from "@angular/core"; 127 | import {RADIO_GROUP_DIRECTIVES} from "ng2-radio-group"; 128 | 129 | @Component({ 130 | selector: "app", 131 | template: ` 132 | 133 |

Is something enabled: (non-multiple checkbox)

134 | 135 | isSomethingEnabled value: {{ isSomethingEnabled }}

136 | 137 |

Order by: (multiple check boxes)

138 | Rating
139 | Date
140 | Watch count
141 | Comment count
142 | 143 | selected items: {{ order }}

144 | 145 | 146 |

Sort by: (simple radio boxes)

147 | Rating
148 | Date
149 | Watch count
150 | Comment count
151 | 152 | selected item: {{ sortWithoutGroup }}

153 | 154 | 155 |

Sort by: (radio boxes wrapped in the group)

156 | 157 | Rating
158 | Date
159 | Watch count
160 | Comment count
161 |
162 | 163 | selected item: {{ sortBy }}

164 | 165 | 166 |

Order by: (check boxes wrapped in the group)

167 | 168 | Rating
169 | Date
170 | Watch count
171 | Comment count
172 |
173 | 174 | selected items: {{ order }}

175 | 176 | 177 |

Sort by: (check boxes in group, less flexible, but simpler and the whole component is clickable)

178 | 179 | Rating 180 | Date 181 | Watch count 182 | Comment count 183 | 184 | 185 | selected item: {{ sortBy }}

186 | 187 | 188 |

Order by: (radio boxes in group, less flexible, but simpler and the whole component is clickable)

189 | 190 | Rating 191 | Date 192 | Watch count 193 | Comment count 194 | 195 | 196 | selected items: {{ order }}

197 | 198 | 199 |

Example with form:

200 | 201 |
202 | 203 | Not selected
204 | Rating
205 | Date
206 | Watch count
207 | Comment count
208 |
209 |
210 | Sort by is required 211 |
212 |
213 | 214 | Rating 215 | Date 216 | Watch count 217 | Comment count 218 | 219 |
220 | Order by is required 221 |
222 |
223 | 224 | `, 225 | directives: [RADIO_GROUP_DIRECTIVES] 226 | }) 227 | export class App { 228 | 229 | rememberMe: boolean = false; 230 | sortBy: string = "date"; 231 | orderBy: string[] = ["rating", "comments"]; 232 | 233 | } 234 | ``` 235 | 236 | Take a look on samples in [./sample](https://github.com/pleerock/ng2-radio-group/tree/master/sample) for more examples of 237 | usages. 238 | 239 | ## Release notes 240 | 241 | **0.0.5** 242 | 243 | * `[(model)]` now should be `[(ngModel)]` 244 | * component now can be used with forms and validation can be applied (like `required`) 245 | * `check-box` and `radio-box` has been removed, now you simply need to make your input as checkbox or radio: 246 | `` or `` 247 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | eval(require("typescript").transpile(require("fs").readFileSync("./gulpfile.ts").toString())); -------------------------------------------------------------------------------- /gulpfile.ts: -------------------------------------------------------------------------------- 1 | import {Gulpclass, Task, SequenceTask} from "gulpclass/Decorators"; 2 | 3 | const gulp = require("gulp"); 4 | const del = require("del"); 5 | const shell = require("gulp-shell"); 6 | const replace = require("gulp-replace"); 7 | const mocha = require("gulp-mocha"); 8 | const chai = require("chai"); 9 | const tslint = require("gulp-tslint"); 10 | const stylish = require("tslint-stylish"); 11 | 12 | @Gulpclass() 13 | export class Gulpfile { 14 | 15 | // ------------------------------------------------------------------------- 16 | // General tasks 17 | // ------------------------------------------------------------------------- 18 | 19 | /** 20 | * Cleans build folder. 21 | */ 22 | @Task() 23 | clean(cb: Function) { 24 | return del(["./build/**"], cb); 25 | } 26 | 27 | /** 28 | * Runs typescript files compilation. 29 | */ 30 | @Task() 31 | compile() { 32 | return gulp.src("*.js", { read: false }) 33 | .pipe(shell(["tsc"])); 34 | } 35 | 36 | // ------------------------------------------------------------------------- 37 | // Packaging and Publishing tasks 38 | // ------------------------------------------------------------------------- 39 | 40 | /** 41 | * Publishes a package to npm from ./build/package directory. 42 | */ 43 | @Task() 44 | npmPublish() { 45 | return gulp.src("*.js", { read: false }) 46 | .pipe(shell([ 47 | "cd ./build/package && npm publish" 48 | ])); 49 | } 50 | 51 | /** 52 | * Copies all files that will be in a package. 53 | */ 54 | @Task() 55 | packageFiles() { 56 | return gulp.src("./build/es5/src/**/*") 57 | .pipe(gulp.dest("./build/package")); 58 | } 59 | 60 | /** 61 | * Change the "private" state of the packaged package.json file to public. 62 | */ 63 | @Task() 64 | packagePreparePackageFile() { 65 | return gulp.src("./package.json") 66 | .pipe(replace("\"private\": true,", "\"private\": false,")) 67 | .pipe(gulp.dest("./build/package")); 68 | } 69 | 70 | /** 71 | * This task will replace all typescript code blocks in the README (since npm does not support typescript syntax 72 | * highlighting) and copy this README file into the package folder. 73 | */ 74 | @Task() 75 | packageReadmeFile() { 76 | return gulp.src("./README.md") 77 | .pipe(replace(/```typescript([\s\S]*?)```/g, "```javascript$1```")) 78 | .pipe(gulp.dest("./build/package")); 79 | } 80 | 81 | /** 82 | * This task will copy typings.json file to the build package. 83 | */ 84 | @Task() 85 | copyTypingsFile() { 86 | return gulp.src("./typings.json") 87 | .pipe(gulp.dest("./build/package")); 88 | } 89 | 90 | /** 91 | * Creates a package that can be published to npm. 92 | */ 93 | @SequenceTask() 94 | package() { 95 | return [ 96 | "clean", 97 | "compile", 98 | ["packageFiles", "packagePreparePackageFile", "packageReadmeFile", "copyTypingsFile"] 99 | ]; 100 | } 101 | 102 | /** 103 | * Creates a package and publishes it to npm. 104 | */ 105 | @SequenceTask() 106 | publish() { 107 | return ["package", "npmPublish"]; 108 | } 109 | 110 | // ------------------------------------------------------------------------- 111 | // Run tests tasks 112 | // ------------------------------------------------------------------------- 113 | 114 | /** 115 | * Runs ts linting to validate source code. 116 | */ 117 | @Task() 118 | tslint() { 119 | return gulp.src(["./src/**/*.ts", "./test/**/*.ts", "./sample/**/*.ts"]) 120 | .pipe(tslint()) 121 | .pipe(tslint.report(stylish, { 122 | emitError: true, 123 | sort: true, 124 | bell: true 125 | })); 126 | } 127 | 128 | /** 129 | * Runs unit-tests. 130 | */ 131 | @Task() 132 | unit() { 133 | chai.should(); 134 | chai.use(require("sinon-chai")); 135 | return gulp.src("./build/es5/test/unit/**/*.js") 136 | .pipe(mocha()); 137 | } 138 | 139 | /** 140 | * Compiles the code and runs tests. 141 | */ 142 | @SequenceTask() 143 | tests() { 144 | return ["clean", "compile", "tslint", "unit"]; 145 | } 146 | 147 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng2-select-controls", 3 | "version": "0.0.1", 4 | "description": "Advanced select, checkbox and radio controls.", 5 | "license": "MIT", 6 | "readmeFilename": "README.md", 7 | "private": true, 8 | "author": { 9 | "name": "Umed Khudoiberdiev", 10 | "email": "pleerock.me@gmail.com" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/pleerock/ng2-select-controls.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/pleerock/ng2-select-controls/issues" 18 | }, 19 | "tags": [ 20 | "checkbox-group", 21 | "radio-group", 22 | "autocomplete", 23 | "select", 24 | "select2", 25 | "tags", 26 | "tags-input", 27 | "multiselect", 28 | "multiselect-dropdown", 29 | "dropdown", 30 | "dropdown-select", 31 | "angular2", 32 | "typescript", 33 | "angular2-autocomplete", 34 | "angular2-checkbox", 35 | "angular2-checkbox-group", 36 | "angular2-radio", 37 | "angular2-radio-group", 38 | "angular2-tags", 39 | "angular2-tags-input", 40 | "angular2-select", 41 | "angular2-select2", 42 | "angular2-dropdown", 43 | "angular2-multiselect", 44 | "angular2-multiselect-dropdown", 45 | "angular2-dropdown-select" 46 | ], 47 | "peerDependencies": { 48 | "@angular/core": "^2.0.0-rc.1", 49 | "@angular/forms": "^0.2.0" 50 | }, 51 | "devDependencies": { 52 | "@angular/common": "^2.0.0-rc.4", 53 | "@angular/compiler": "^2.0.0-rc.4", 54 | "@angular/core": "2.0.0-rc.4", 55 | "@angular/forms": "^0.2.0", 56 | "@angular/http": "^2.0.0-rc.4", 57 | "@angular/platform-browser": "^2.0.0-rc.4", 58 | "@angular/platform-browser-dynamic": "^2.0.0-rc.4", 59 | "bootstrap": "^3.3.6", 60 | "chai": "^3.4.1", 61 | "del": "^2.2.0", 62 | "es6-promise": "^3.0.2", 63 | "es6-shim": "^0.33.3", 64 | "gulp": "^3.9.0", 65 | "gulp-mocha": "^2.2.0", 66 | "gulp-replace": "^0.5.4", 67 | "gulp-shell": "^0.5.1", 68 | "gulp-tslint": "^4.3.1", 69 | "gulpclass": "^0.1.0", 70 | "mocha": "^2.3.2", 71 | "reflect-metadata": "0.1.2", 72 | "rxjs": "5.0.0-beta.6", 73 | "sinon": "^1.17.2", 74 | "sinon-chai": "^2.8.0", 75 | "systemjs": "0.19.27", 76 | "tslint": "^3.3.0", 77 | "tslint-stylish": "^2.1.0-beta", 78 | "typescript": "^1.8.2", 79 | "typings": "^0.6.6", 80 | "zone.js": "^0.6.12" 81 | }, 82 | "dependencies": { 83 | "ng2-dropdown": "^0.0.9" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /resources/radio-group-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pleerock/ngx-select-controls/44cedb11e9c029decab7bdfa20732b337cb01d0e/resources/radio-group-example.png -------------------------------------------------------------------------------- /sample/sample1-simple-example/Car.ts: -------------------------------------------------------------------------------- 1 | export class Car { 2 | constructor(public id: number, 3 | public name: string, 4 | public year: number) { 5 | } 6 | } -------------------------------------------------------------------------------- /sample/sample1-simple-example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | checkbox and radio groups 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 40 | 41 | 42 | 43 | 44 | 45 | Loading... 46 | 47 | -------------------------------------------------------------------------------- /sample/sample1-simple-example/main.ts: -------------------------------------------------------------------------------- 1 | import {bootstrap} from "@angular/platform-browser-dynamic"; 2 | import {Component} from "@angular/core"; 3 | import {disableDeprecatedForms, provideForms } from "@angular/forms"; 4 | import {SELECT_DIRECTIVES} from "../../src/index"; 5 | import {Car} from "./Car"; 6 | 7 | @Component({ 8 | selector: "app", 9 | template: ` 10 |
11 | 12 |

Is something enabled: (non-multiple checkbox)

13 | 14 | isSomethingEnabled value: {{ isSomethingEnabled }}

15 | 16 |

Order by: (multiple check boxes)

17 | Rating
18 | Date
19 | Watch count
20 | Comment count
21 | 22 | selected items: {{ order }}

23 | 24 | 25 |

Sort by: (simple radio boxes)

26 | Rating
27 | Date
28 | Watch count
29 | Comment count
30 | 31 | selected item: {{ sortWithoutGroup }}

32 | 33 | 34 |

Sort by: (radio boxes wrapped in the group)

35 | 36 | Rating
37 | Date
38 | Watch count
39 | Comment count
40 |
41 | 42 | selected item: {{ sortBy }}

43 | 44 | 45 |

Order by: (check boxes wrapped in the group)

46 | 47 | Rating
48 | Date
49 | Watch count
50 | Comment count
51 |
52 | 53 | selected items: {{ order }}

54 | 55 | 56 |

Sort by: (check boxes in group, less flexible, but simpler and the whole component is clickable)

57 | 58 | Rating 59 | Date 60 | Watch count 61 | Comment count 62 | 63 | 64 | selected item: {{ sortBy }}

65 | 66 | 67 |

Order by: (radio boxes in group, less flexible, but simpler and the whole component is clickable)

68 | 69 | Rating 70 | Date 71 | Watch count 72 | Comment count 73 | 74 | 75 | selected items: {{ order }}

76 | 77 | 78 |

Example with form:

79 | 80 |
81 | 82 | Not selected
83 | Rating
84 | Date
85 | Watch count
86 | Comment count
87 |
88 |
89 | Sort by is required 90 |
91 | 92 | selected item: {{ sortBy }}

93 | 94 | 95 | Rating 96 | Date 97 | Watch count 98 | Comment count 99 | 100 |
101 | Order by is required 102 |
103 | 104 | selected items: {{ order }}

105 |
106 | 107 |

Disabled group:

108 | 109 | Rating 110 | Date 111 | Watch count 112 | Comment count 113 | 114 | 115 |

Selecting objects:

116 | 117 | {{ car.name }} 118 | 119 | selectedCars: 120 |
{{ selectedCars1 | json }}
121 | 122 |

Using track by:

123 | 124 | {{ car.name }} 125 | 126 | selectedCars: 127 |
{{ selectedCars2 | json }}
128 | 129 |
130 | `, 131 | directives: [SELECT_DIRECTIVES] 132 | }) 133 | export class Sample1App { 134 | 135 | isSomethingEnabled: boolean = false; 136 | sortBy: string = "date"; 137 | orderBy: string[] = ["rating", "comments"]; 138 | 139 | selectedCars1: Car[] = []; 140 | selectedCars2: Car[] = []; 141 | cars: Car[]; 142 | 143 | constructor() { 144 | this.cars = [ 145 | new Car(1, "BMW", 2000), 146 | new Car(2, "Mercedes", 1999), 147 | new Car(3, "Opel", 2008) 148 | ]; 149 | this.selectedCars2 = [ 150 | new Car(2, "Mercedes", 1999), 151 | ]; 152 | } 153 | 154 | } 155 | 156 | bootstrap(Sample1App, [ 157 | disableDeprecatedForms(), 158 | provideForms(), 159 | ]); -------------------------------------------------------------------------------- /sample/sample2-select-items/Car.ts: -------------------------------------------------------------------------------- 1 | export class Car { 2 | constructor(public id: number, 3 | public name: string, 4 | public year: number) { 5 | } 6 | } -------------------------------------------------------------------------------- /sample/sample2-select-items/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | select-items 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 40 | 41 | 42 | 66 | 67 | 68 | 69 | 70 | Loading... 71 | 72 | -------------------------------------------------------------------------------- /sample/sample2-select-items/main.ts: -------------------------------------------------------------------------------- 1 | import {bootstrap} from "@angular/platform-browser-dynamic"; 2 | import {Component} from "@angular/core"; 3 | import {SELECT_DIRECTIVES} from "../../src/index"; 4 | import {Car} from "./Car"; 5 | import {ItemTemplate} from "../../src/ItemTemplate"; 6 | import {provideForms, disableDeprecatedForms} from "@angular/forms"; 7 | 8 | @Component({ 9 | selector: "app", 10 | template: ` 11 |
12 | 13 |

Simple select items:

14 | 17 | 18 | 19 |

model:

20 |
{{ selectedCars | json }}
21 | 22 |

Select list with search

23 | 28 | 29 |

model:

30 |
{{ selectedCars1 | json }}
31 | 32 |

Select list with ordering

33 | 37 | 38 |

model:

39 |
{{ selectedCars2 | json }}
40 | 41 |

Select list with descendant ordering

42 | 47 | 48 |

model:

49 |
{{ selectedCars3 | json }}
50 | 51 | 52 |

Select items with select all option

53 | 58 | 59 |

model:

60 |
{{ selectedCars4 | json }}
61 | 62 |

Select items with limited number of shown items:

63 | 67 | 68 |

model:

69 |
{{ selectedCars5 | json }}
70 | 71 |

Select items with limited number of shown items, with more button:

72 | 77 | 78 |

model:

79 |
{{ selectedCars6 | json }}
80 | 81 |

Select items with limited number of shown items, with more & hide button:

82 | 88 | 89 |

model:

90 |
{{ selectedCars7 | json }}
91 | 92 |

Select items where items can be removed:

93 | 97 | 98 |

model:

99 |
{{ selectedCars8 | json }}
100 | 101 |

Select items with no controls:

102 | 106 | 107 |

model:

108 |
{{ selectedCars9 | json }}
109 | 110 |

Select items where where selected items are not showing after they are selected:

111 | 115 | 116 |

model:

117 |
{{ selectedCars10 | json }}
118 | 119 |

Select items with maximal number of allowed selected items:

120 | 124 | 125 |

model:

126 |
{{ selectedCars11 | json }}
127 | 128 |

Select items with minimal number of allowed selected items:

129 | 133 | 134 |

model:

135 |
{{ selectedCars12 | json }}
136 | 137 |

Select items with track by, to track by another model:

138 | 142 | 143 |

model:

144 |
{{ secondSelectedCars | json }}
145 | 146 |

Select items with value by, to get more specific values:

147 | 151 | 152 |

model:

153 |
{{ selectedCarNames | json }}
154 | 155 |

Select items with grouping:

156 | 161 | 162 |

model:

163 |
{{ selectedCars13 | json }}
164 | 165 |

Select items with grouping and select-alls in groups:

166 | 172 | 173 |

model:

174 |
{{ selectedCars14 | json }}
175 | 176 |

Select items with grouping and select-alls in groups, but without a checkbox:

177 | 184 | 185 |

model:

186 |
{{ selectedCars15 | json }}
187 | 188 |

Select items with custom item template:

189 | 193 | 194 | 195 | #{{ item.id }} {{ item.name }} ({{ item.year }}) 196 | 197 | 198 | 199 |

model:

200 |
{{ selectedCars17 | json }}
201 | 202 |

Select items with custom item template and hidden controls:

203 | 208 | 209 | 210 | #{{ item.id }} {{ item.name }} ({{ item.year }}) 211 | 212 | 213 | 214 |

model:

215 |
{{ selectedCars17 | json }}
216 | 217 |

All-in-one select items:

218 | 234 | 235 |

model:

236 |
{{ selectedCars18 | json }}
237 | 238 | 239 | 240 |
241 | Single item: 242 |
243 | 244 |

Simple select items:

245 | 248 | 249 | 250 |

model:

251 |
{{ selectedCar | json }}
252 | 253 |

Select list with search

254 | 259 | 260 |

model:

261 |
{{ selectedCar | json }}
262 | 263 |

Select list with ordering

264 | 268 | 269 |

model:

270 |
{{ selectedCar | json }}
271 | 272 |

Select list with descendant ordering

273 | 278 | 279 |

model:

280 |
{{ selectedCar | json }}
281 | 282 | 283 |

Select items with nothing is selected

284 | 289 | 290 |

model:

291 |
{{ selectedCar | json }}
292 | 293 |

Select items with limited number of shown items:

294 | 298 | 299 |

model:

300 |
{{ selectedCar | json }}
301 | 302 |

Select items with limited number of shown items, with more button:

303 | 308 | 309 |

model:

310 |
{{ selectedCar | json }}
311 | 312 |

Select items with limited number of shown items, with more & hide button:

313 | 319 | 320 |

model:

321 |
{{ selectedCar | json }}
322 | 323 |

Select items where items can be removed:

324 | 328 | 329 |

model:

330 |
{{ selectedCar | json }}
331 | 332 |

Select items where with no controls:

333 | 337 | 338 |

model:

339 |
{{ selectedCar | json }}
340 | 341 |

Select items where where selected items are not showing after they are selected:

342 | 346 | 347 |

model:

348 |
{{ selectedCar | json }}
349 | 350 |

Select items with maximal number of allowed selected items:

351 | 355 | 356 |

model:

357 |
{{ selectedCar | json }}
358 | 359 |

Select items with minimal number of allowed selected items:

360 | 364 | 365 |

model:

366 |
{{ selectedCar | json }}
367 | 368 |

Select items with track by, to track by another model:

369 | 373 | 374 |

model:

375 |
{{ secondSelectedCar | json }}
376 | 377 |

Select items with value by, to get more specific values:

378 | 382 | 383 |

model:

384 |
{{ selectedCarName | json }}
385 | 386 |

Select items with explicitly set multiple option:

387 | 391 | 392 |

model:

393 |
{{ allNewSelectedCars | json }}
394 | 395 |

Select items single with grouping:

396 | 401 | 402 |

model:

403 |
{{ selectedCar | json }}
404 | 405 |

All-in-one select items:

406 | 423 | 424 |

model:

425 |
{{ selectedCar | json }}
426 | 427 | 428 |
429 | `, 430 | directives: [SELECT_DIRECTIVES] 431 | }) 432 | export class Sample1App { 433 | 434 | cars: Car[] = [ 435 | new Car(1, "BMW", 2000), 436 | new Car(2, "Mercedes", 1999), 437 | new Car(3, "Opel", 2008), 438 | new Car(4, "Porshe", 1940), 439 | new Car(5, "Ferrari", 2000), 440 | new Car(6, "Toyota", 2008), 441 | new Car(7, "Nissan", 1940) 442 | ]; 443 | 444 | selectedCars: Car[] = []; 445 | selectedCars1: Car[] = []; 446 | selectedCars2: Car[] = []; 447 | selectedCars3: Car[] = []; 448 | selectedCars4: Car[] = []; 449 | selectedCars5: Car[] = []; 450 | selectedCars6: Car[] = []; 451 | selectedCars7: Car[] = []; 452 | selectedCars8: Car[] = []; 453 | selectedCars9: Car[] = []; 454 | selectedCars10: Car[] = []; 455 | selectedCars11: Car[] = []; 456 | selectedCars12: Car[] = []; 457 | selectedCars13: Car[] = []; 458 | selectedCars14: Car[] = []; 459 | selectedCars15: Car[] = []; 460 | selectedCars16: Car[] = []; 461 | selectedCars17: Car[] = []; 462 | selectedCars18: Car[] = []; 463 | selectedCars19: Car[] = []; 464 | secondSelectedCars: Car[] = [ 465 | new Car(2, "Mercedes", 1999) 466 | ]; 467 | selectedCarNames: string[] = []; 468 | 469 | selectedCar: Car; 470 | secondSelectedCar: Car = new Car(2, "Mercedes", 1999); 471 | selectedCarName: string; 472 | 473 | constructor() { 474 | } 475 | 476 | } 477 | 478 | bootstrap(Sample1App, [ 479 | disableDeprecatedForms(), 480 | provideForms(), 481 | ]); -------------------------------------------------------------------------------- /sample/sample3-autocomplete/Car.ts: -------------------------------------------------------------------------------- 1 | export class Car { 2 | constructor(public id: number, 3 | public name: string, 4 | public year: number) { 5 | } 6 | } -------------------------------------------------------------------------------- /sample/sample3-autocomplete/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | autocomplete 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 40 | 41 | 42 | 66 | 67 | 68 | 69 | 70 | Loading... 71 | 72 | -------------------------------------------------------------------------------- /sample/sample3-autocomplete/main.ts: -------------------------------------------------------------------------------- 1 | import {bootstrap} from "@angular/platform-browser-dynamic"; 2 | import {Component} from "@angular/core"; 3 | import {SELECT_DIRECTIVES} from "../../src/index"; 4 | import {Car} from "./Car"; 5 | import {HTTP_PROVIDERS, Http} from "@angular/http"; 6 | import {Observable} from "rxjs/Rx"; 7 | import {disableDeprecatedForms, provideForms} from "@angular/forms"; 8 | 9 | @Component({ 10 | selector: "app", 11 | template: ` 12 |
13 | 14 |

Autocomplete multiple:

15 | 19 | 20 |

Selected items:

21 | 28 | 29 |
model: 30 |
{{ selectedCars1 | json }}
31 | 32 |

Autocomplete multiple with persist and custom button label

33 | 39 | 40 | model: 41 |
{{ selectedCars2 | json }}
42 | 43 |

Autocomplete single:

44 | 48 | 49 | model: 50 |
{{ selectedCar1 | json }}
51 | 52 |

Autocomplete single with persist:

53 | 58 | 59 | model: 60 |
{{ selectedCar2 | json }}
61 | 62 |

Autocomplete with predefined model:

63 | 68 | 69 | model: 70 |
{{ newSelectedCar | json }}
71 | 72 | 73 |

Autocomplete multiple disabled:

74 | 80 | 81 | model: 82 |
{{ selectedCars3 | json }}
83 | 84 |

Autocomplete explicit multiple:

85 | 91 | 92 | model: 93 |
{{ allSelectedCars | json }}
94 | 95 |

Autocomplete with minimal number of characters to send a request:

96 | 101 | 102 | model: 103 |
{{ selectedCars4 | json }}
104 | 105 |

Autocomplete with specific values selected:

106 | 111 | 112 | model: 113 |
{{ selectedCars5 | json }}
114 | 115 |

Autocomplete with ordering enabled:

116 | 121 | 122 | model: 123 |
{{ selectedCars6 | json }}
124 | 125 |

Autocomplete with descendant ordering enabled:

126 | 132 | 133 | model: 134 |
{{ selectedCars7 | json }}
135 | 136 |

Autocomplete with limit:

137 | 142 | 143 | model: 144 |
{{ selectedCars8 | json }}
145 | 146 |

Autocomplete with maximal allowed to selection:

147 | 152 | 153 | model: 154 |
{{ selectedCars9 | json }}
155 | 156 |

Autocomplete with custom item constructor function:

157 | 163 | 164 | model: 165 |
{{ selectedCars10 | json }}
166 | 167 |

Autocomplete with custom template:

168 | 175 | 176 | 177 | 178 | {{ item.name }} ({{ item.owner.login }}) 179 | 180 | 181 | 182 | 183 | 184 | model: 185 |
{{ selectedCars11 | json }}
186 | 187 |
188 | `, 189 | directives: [SELECT_DIRECTIVES] 190 | }) 191 | export class Sample1App { 192 | 193 | cars: Car[] = [ 194 | new Car(1, "BMW", 2000), 195 | new Car(2, "Mercedes", 1999), 196 | new Car(3, "Opel", 2008), 197 | new Car(4, "Porshe", 1940), 198 | new Car(4, "Ferrari", 2000) 199 | ]; 200 | selectedCars1: Car[] = []; 201 | selectedCars2: Car[] = []; 202 | selectedCars3: Car[] = []; 203 | selectedCars4: Car[] = []; 204 | selectedCars5: string[] = []; 205 | selectedCars6: Car[] = []; 206 | selectedCars7: Car[] = []; 207 | selectedCars8: Car[] = []; 208 | selectedCars9: Car[] = []; 209 | selectedCars10: Car[] = []; 210 | selectedCars11: Car[] = []; 211 | selectedCar: Car; 212 | selectedCar2: Car; 213 | newSelectedCar: Car = new Car(1, "BMW", 2000); 214 | 215 | loader = (term: string) => { 216 | return this.http 217 | .get("https://api.github.com/search/repositories?q=" + term) 218 | .map(res => res.json().items) as Observable; 219 | }; 220 | 221 | itemConstructor = (term: string) => { 222 | return new Car(0, term, 2016); 223 | }; 224 | 225 | constructor(private http: Http) { 226 | } 227 | 228 | resetModel() { 229 | this.newSelectedCar = new Car(1, "BMW", 2000); 230 | } 231 | 232 | } 233 | 234 | bootstrap(Sample1App, [ 235 | HTTP_PROVIDERS, 236 | disableDeprecatedForms(), 237 | provideForms(), 238 | ]); -------------------------------------------------------------------------------- /sample/sample4-select-dropdown/Car.ts: -------------------------------------------------------------------------------- 1 | export class Car { 2 | constructor(public id: number, 3 | public name: string, 4 | public year: number) { 5 | } 6 | } -------------------------------------------------------------------------------- /sample/sample4-select-dropdown/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | select-dropdown 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 40 | 41 | 42 | 66 | 67 | 68 | 69 | 70 | Loading... 71 | 72 | -------------------------------------------------------------------------------- /sample/sample4-select-dropdown/main.ts: -------------------------------------------------------------------------------- 1 | import {bootstrap} from "@angular/platform-browser-dynamic"; 2 | import {Component} from "@angular/core"; 3 | import {SELECT_DIRECTIVES} from "../../src/index"; 4 | import {Car} from "./Car"; 5 | import {HTTP_PROVIDERS, Http} from "@angular/http"; 6 | import {disableDeprecatedForms, provideForms} from "@angular/forms"; 7 | 8 | @Component({ 9 | selector: "app", 10 | template: ` 11 |
12 | 13 |

Select Dropdown multiple:

14 | 18 | 19 |
model: 20 |
{{ selectedCars1 | json }}
21 | 22 |

Select Dropdown single select:

23 | 27 | 28 |
model: 29 |
{{ selectedCar1 | json }}
30 | 31 |

Select Dropdown multiselect with explicit multiple:

32 | 37 | 38 |
model: 39 |
{{ anotherSelectedCars | json }}
40 | 41 |

Select Dropdown multiple with custom label:

42 | 47 | 48 |
model: 49 |
{{ selectedCars2 | json }}
50 | 51 |

Select Dropdown single select with no selection option:

52 | 57 | 58 |
model: 59 |
{{ selectedCar2 | json }}
60 | 61 |

Select Dropdown multiple with select all option:

62 | 67 | 68 |
model: 69 |
{{ selectedCars3 | json }}
70 | 71 |

Select Dropdown multiple with limit:

72 | 77 | 78 |
model: 79 |
{{ selectedCars4 | json }}
80 | 81 |

Select Dropdown disabled:

82 | 87 | 88 |
model: 89 |
{{ selectedCars5 | json }}
90 | 91 |

Select Dropdown ordered:

92 | 97 | 98 |
model: 99 |
{{ selectedCars6 | json }}
100 | 101 |

Select Dropdown ordered desc:

102 | 108 | 109 |
model: 110 |
{{ selectedCars7 | json }}
111 | 112 |

Select Dropdown specific values:

113 | 117 | 118 |
model: 119 |
{{ selectedCars8 | json }}
120 | 121 |

Select Dropdown with search:

122 | 128 | 129 |
model: 130 |
{{ selectedCars9 | json }}
131 | 132 |

Select Dropdown single with search:

133 | 139 | 140 |
model: 141 |
{{ selectedCar3 | json }}
142 | 143 |

Select Dropdown with hidden controls:

144 | 149 | 150 |
model: 151 |
{{ selectedCars10 | json }}
152 | 153 |

Select Dropdown multiple readonly:

154 | 159 | 160 |
model: 161 |
{{ selectedCars10 | json }}
162 | 163 |

Select Dropdown multiple readonly and readonly custom label:

164 | 170 | 171 |
model: 172 |
{{ selectedCars11 | json }}
173 | 174 |

Select Dropdown single readonly:

175 | 180 | 181 |
model: 182 |
{{ newSelectedCar | json }}
183 | 184 |

Select Dropdown readonly with custom readonly label:

185 | 191 | 192 |
model: 193 |
{{ notSelectedCar | json }}
194 | 195 |
196 | `, 197 | directives: [SELECT_DIRECTIVES] 198 | }) 199 | export class Sample1App { 200 | 201 | cars: Car[] = [ 202 | new Car(1, "BMW", 2000), 203 | new Car(2, "Mercedes", 1999), 204 | new Car(3, "Opel", 2008), 205 | new Car(4, "Porshe", 1940), 206 | new Car(4, "Ferrari", 2000) 207 | ]; 208 | selectedCars1: Car[] = []; 209 | selectedCars2: Car[] = []; 210 | selectedCars3: Car[] = []; 211 | selectedCars4: Car[] = []; 212 | selectedCars5: string[] = []; 213 | selectedCars6: Car[] = []; 214 | selectedCars7: Car[] = []; 215 | selectedCars8: Car[] = []; 216 | selectedCars9: Car[] = []; 217 | selectedCars10: Car[] = []; 218 | selectedCars11: Car[] = []; 219 | selectedCars12: Car[] = []; 220 | selectedCar: Car; 221 | selectedCar2: Car; 222 | selectedCar3: Car; 223 | newSelectedCar: Car = new Car(1, "BMW", 2000); 224 | 225 | } 226 | 227 | bootstrap(Sample1App, [ 228 | HTTP_PROVIDERS, 229 | disableDeprecatedForms(), 230 | provideForms(), 231 | ]); -------------------------------------------------------------------------------- /sample/sample5-select-tags/Car.ts: -------------------------------------------------------------------------------- 1 | export class Car { 2 | constructor(public id: number, 3 | public name: string, 4 | public year: number) { 5 | } 6 | } -------------------------------------------------------------------------------- /sample/sample5-select-tags/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | select-tags 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 40 | 41 | 42 | 66 | 67 | 68 | 69 | 70 | Loading... 71 | 72 | -------------------------------------------------------------------------------- /sample/sample5-select-tags/main.ts: -------------------------------------------------------------------------------- 1 | import {bootstrap} from "@angular/platform-browser-dynamic"; 2 | import {Component} from "@angular/core"; 3 | import {SELECT_DIRECTIVES} from "../../src/index"; 4 | import {Car} from "./Car"; 5 | import {HTTP_PROVIDERS, Http} from "@angular/http"; 6 | import {Observable} from "rxjs/Rx"; 7 | import {provideForms, disableDeprecatedForms} from "@angular/forms"; 8 | 9 | @Component({ 10 | selector: "app", 11 | template: ` 12 |
13 | 14 |

Select Tags with static list:

15 | 19 | 20 |
model: 21 |
{{ selectedCars0 | json }}
22 | 23 |

Select Tags without persistence:

24 | 28 | 29 |
model: 30 |
{{ selectedCars1 | json }}
31 | 32 |

Select Tags with persistence:

33 | 39 | 40 |
model: 41 |
{{ selectedCars2 | json }}
42 | 43 |

Select Tags with pre-defined model:

44 | 48 | 49 |
model: 50 |
{{ selectedCars3 | json }}
51 | 52 |

Select Tags with track by:

53 | 58 | 59 |
model: 60 |
{{ selectedCars4 | json }}
61 | 62 |

Select Tags disabled:

63 | 68 | 69 |
model: 70 |
{{ selectedCars4 | json }}
71 | 72 |

Select Tags with specific values selected:

73 | 77 | 78 | model: 79 |
{{ selectedCars5 | json }}
80 | 81 |

Select Tags with minimal number of characters to send a request:

82 | 87 | 88 | model: 89 |
{{ selectedCars6 | json }}
90 | 91 |

Select Tags with ordering enabled:

92 | 97 | 98 | model: 99 |
{{ selectedCars7 | json }}
100 | 101 |

Select Tags with descendant ordering enabled:

102 | 108 | 109 | model: 110 |
{{ selectedCars8 | json }}
111 | 112 |

Select Tags with limit:

113 | 118 | 119 | model: 120 |
{{ selectedCars9 | json }}
121 | 122 |

Select Tags with maximal allowed to selection:

123 | 128 | 129 | model: 130 |
{{ selectedCars10 | json }}
131 | 132 |

Select Tags with custom item constructor function:

133 | 139 | 140 | model: 141 |
{{ selectedCars11 | json }}
142 | 143 |

Select Tags with customized add button:

144 | 151 | 152 |
model: 153 |
{{ selectedCars12 | json }}
154 | 155 |

Select Tags with select all option:

156 | 163 | 164 |
model: 165 |
{{ selectedCars13 | json }}
166 | 167 |

Select Tags with unique names:

168 | 174 | 175 |
model: 176 |
{{ selectedCars14 | json }}
177 | 178 |

Select Tags readonly:

179 | 185 | 186 |
model: 187 |
{{ selectedCars15 | json }}
188 | 189 |

Select Tags with custom templates:

190 | 195 | 196 | 197 | 198 | 199 | 200 | {{ item.name }} ({{ item.year }}) 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | #{{ item.id }} {{ item.name }} ({{ item.year }}) 210 | 211 | 212 | 213 | 214 | 215 | 216 |
model: 217 |
{{ selectedCars16 | json }}
218 | 219 |
220 | `, 221 | directives: [SELECT_DIRECTIVES] 222 | }) 223 | export class Sample1App { 224 | 225 | cars: Car[] = [ 226 | new Car(1, "BMW", 2000), 227 | new Car(2, "Mercedes", 1999), 228 | new Car(3, "Opel", 2008), 229 | new Car(4, "Porshe", 1940), 230 | new Car(4, "Ferrari", 2000) 231 | ]; 232 | selectedCars0: Car[] = []; 233 | selectedCars1: Car[] = []; 234 | selectedCars2: Car[] = []; 235 | selectedCars3: Car[] = [ 236 | this.cars[0] 237 | ]; 238 | selectedCars4: Car[] = [ 239 | new Car(1, "BMW", 2000), 240 | ]; 241 | selectedCars5: string[] = []; 242 | selectedCars6: Car[] = []; 243 | selectedCars7: Car[] = []; 244 | selectedCars8: Car[] = []; 245 | selectedCars9: Car[] = []; 246 | selectedCars10: Car[] = []; 247 | selectedCars11: Car[] = []; 248 | selectedCars12: Car[] = []; 249 | selectedCars13: Car[] = []; 250 | selectedCars14: Car[] = []; 251 | selectedCars15: Car[] = [ 252 | this.cars[0], 253 | this.cars[1], 254 | this.cars[2] 255 | ]; 256 | selectedCars16: Car[] = []; 257 | 258 | loader = (term: string) => { 259 | return this.http 260 | .get("https://api.github.com/search/repositories?q=" + term) 261 | .map(res => res.json().items) as Observable; 262 | }; 263 | 264 | itemConstructor = (term: string) => { 265 | return new Car(0, term, 2016); 266 | }; 267 | 268 | constructor(private http: Http) { 269 | } 270 | 271 | } 272 | 273 | bootstrap(Sample1App, [ 274 | HTTP_PROVIDERS, 275 | disableDeprecatedForms(), 276 | provideForms(), 277 | ]); -------------------------------------------------------------------------------- /src/Autocomplete.ts: -------------------------------------------------------------------------------- 1 | import "rxjs/Rx"; 2 | import { 3 | Component, 4 | Input, 5 | ViewEncapsulation, 6 | OnInit, 7 | Directive, 8 | ContentChildren, 9 | QueryList, 10 | ContentChild 11 | } from "@angular/core"; 12 | import {NG_VALIDATORS, NG_VALUE_ACCESSOR} from "@angular/forms"; 13 | import {SelectItems} from "./SelectItems"; 14 | import {DROPDOWN_DIRECTIVES} from "ng2-dropdown"; 15 | import {Observable, Subscription} from "rxjs/Rx"; 16 | import {SelectValueAccessor} from "./SelectValueAccessor"; 17 | import {SelectValidator} from "./SelectValidator"; 18 | import {Utils} from "./Utils"; 19 | import {ItemTemplate} from "./ItemTemplate"; 20 | 21 | @Directive({ 22 | selector: "autocomplete-dropdown-template" 23 | }) 24 | export class AutocompleteDropdownTemplate { 25 | 26 | @ContentChildren(ItemTemplate) 27 | itemTemplates: QueryList; 28 | 29 | } 30 | 31 | @Component({ 32 | selector: "autocomplete", 33 | template: ` 34 |
35 | 70 |
`, 71 | styles: [` 72 | .autocomplete .hidden { 73 | display: none !important; 74 | } 75 | .autocomplete .autocomplete-dropdown { 76 | position: relative; 77 | } 78 | .autocomplete .autocomplete-input input { 79 | width: 100%; 80 | } 81 | .autocomplete .autocomplete-dropdown-menu { 82 | position: absolute; 83 | top: 100%; 84 | left: 0; 85 | z-index: 1000; 86 | display: none; 87 | float: left; 88 | min-width: 160px; 89 | padding: 5px 0; 90 | margin: 2px 0 0; 91 | font-size: 14px; 92 | text-align: left; 93 | list-style: none; 94 | background-color: #fff; 95 | -webkit-background-clip: padding-box; 96 | background-clip: padding-box; 97 | border: 1px solid #ccc; 98 | border: 1px solid rgba(0, 0, 0, .15); 99 | } 100 | .autocomplete .autocomplete-dropdown.open .dropdown-menu { 101 | display: block; 102 | } 103 | .autocomplete .autocomplete-dropdown-menu .select-items .no-selection, 104 | .autocomplete .autocomplete-dropdown-menu .select-items .select-all, 105 | .autocomplete .autocomplete-dropdown-menu .select-items .checkbox-item, 106 | .autocomplete .autocomplete-dropdown-menu .select-items .radio-item { 107 | padding: 3px 15px; 108 | clear: both; 109 | font-weight: normal; 110 | line-height: 1.42857; 111 | color: #333333; 112 | white-space: nowrap; 113 | display: block; 114 | } 115 | .autocomplete .autocomplete-dropdown-menu .select-items .no-selection:hover, 116 | .autocomplete .autocomplete-dropdown-menu .select-items .select-all:hover, 117 | .autocomplete .autocomplete-dropdown-menu .select-items .checkbox-item:hover, 118 | .autocomplete .autocomplete-dropdown-menu .select-items .radio-item:hover { 119 | text-decoration: none; 120 | color: #fff; 121 | background-color: #0095cc; 122 | cursor: pointer; 123 | } 124 | .autocomplete .select-items .checkbox-item.disabled:hover, 125 | .autocomplete .select-items .radio-item.disabled:hover { 126 | color: #333; 127 | background-color: #eeeeee; 128 | cursor: not-allowed; 129 | } 130 | .autocomplete .autocomplete-add-button { 131 | float: right; 132 | font-size: 0.75em; 133 | color: #999; 134 | } 135 | .autocomplete .autocomplete-add-button a { 136 | border-bottom: 1px dotted; 137 | } 138 | `], 139 | encapsulation: ViewEncapsulation.None, 140 | directives: [ 141 | SelectItems, 142 | DROPDOWN_DIRECTIVES 143 | ], 144 | providers: [ 145 | Utils, 146 | SelectValueAccessor, 147 | SelectValidator, 148 | { provide: NG_VALUE_ACCESSOR, useExisting: SelectValueAccessor, multi: true }, 149 | { provide: NG_VALIDATORS, useExisting: SelectValidator, multi: true } 150 | ] 151 | }) 152 | export class Autocomplete implements OnInit { 153 | 154 | // ------------------------------------------------------------------------- 155 | // Inputs 156 | // ------------------------------------------------------------------------- 157 | 158 | @Input() 159 | placeholder: string = ""; 160 | 161 | @Input() 162 | multiple: boolean; 163 | 164 | @Input() 165 | debounceTime = 500; 166 | 167 | @Input() 168 | minQueryLength = 2; 169 | 170 | @Input() 171 | persist: boolean = false; 172 | 173 | @Input() 174 | itemLabelBy: string|((item: any) => string); 175 | 176 | @Input() 177 | labelBy: string|((item: any) => string); 178 | 179 | @Input() 180 | disableBy: string|((item: any) => string); 181 | 182 | @Input() 183 | orderBy: string|((item1: any, item2: any) => number); 184 | 185 | @Input() 186 | orderDirection: "asc"|"desc"; 187 | 188 | @Input() 189 | disabled: boolean = false; 190 | 191 | @Input() 192 | limit: number; 193 | 194 | @Input() 195 | maxModelSize: number; 196 | 197 | @Input() 198 | loader: (term: string) => Observable; 199 | 200 | @Input() 201 | itemConstructor: ((term: string) => any); 202 | 203 | @Input() 204 | addButtonLabel: string = "add"; 205 | 206 | @Input() 207 | addButtonSecondaryLabel: string = "(or press enter)"; 208 | 209 | // ------------------------------------------------------------------------- 210 | // Input accessors 211 | // ------------------------------------------------------------------------- 212 | 213 | @Input() 214 | set valueBy(valueBy: string|((item: any) => string)) { 215 | this.valueAccessor.valueBy = valueBy; 216 | } 217 | 218 | get valueBy() { 219 | return this.valueAccessor.valueBy; 220 | } 221 | 222 | @Input() 223 | set trackBy(trackBy: string|((item: any) => string)) { 224 | this.valueAccessor.trackBy = trackBy; 225 | } 226 | 227 | get trackBy() { 228 | return this.valueAccessor.trackBy; 229 | } 230 | 231 | @Input() 232 | set required(required: boolean) { 233 | this.validator.options.required = required; 234 | } 235 | 236 | get required() { 237 | return this.validator.options.required; 238 | } 239 | 240 | // ------------------------------------------------------------------------- 241 | // Public Properties 242 | // ------------------------------------------------------------------------- 243 | 244 | term: string; 245 | lastLoadTerm: string = ""; 246 | items: any[] = []; 247 | 248 | @ContentChild(AutocompleteDropdownTemplate) 249 | dropdownTemplate: AutocompleteDropdownTemplate; 250 | 251 | // ------------------------------------------------------------------------- 252 | // Private Properties 253 | // ------------------------------------------------------------------------- 254 | 255 | private originalModel = false; 256 | private initialized: boolean = false; 257 | private loadDenounce: Function; 258 | 259 | // ------------------------------------------------------------------------- 260 | // Constructor 261 | // ------------------------------------------------------------------------- 262 | 263 | constructor(public valueAccessor: SelectValueAccessor, 264 | private validator: SelectValidator, 265 | private utils: Utils) { 266 | this.valueAccessor.modelWrites.subscribe((model: any) => { 267 | if (model) 268 | this.originalModel = true; 269 | if (this.initialized) 270 | this.term = this.getItemLabel(model); 271 | }); 272 | } 273 | 274 | // ------------------------------------------------------------------------- 275 | // Lifecycle callbacks 276 | // ------------------------------------------------------------------------- 277 | 278 | ngOnInit() { 279 | this.initialized = true; 280 | this.term = this.getItemLabel(this.valueAccessor.model); 281 | 282 | this.loadDenounce = this.utils.debounce(() => { 283 | if (!this.originalModel && typeof this.term === "string" && this.term.trim().length >= this.minQueryLength) { 284 | this.load(); 285 | } 286 | }, this.debounceTime); 287 | } 288 | 289 | // ------------------------------------------------------------------------- 290 | // Public Methods 291 | // ------------------------------------------------------------------------- 292 | 293 | get dropdownItems() { 294 | return this.items; 295 | } 296 | 297 | onTermChange(term: string) { 298 | 299 | // if persist mode is set then create a new object 300 | if (!this.isMultiple()) { 301 | if (this.persist && term && (!this.valueAccessor.model || this.getItemLabel(this.valueAccessor.model) !== term)) { 302 | const value = this.itemConstructor ? this.itemConstructor(term) : { [this.labelBy as string]: term }; 303 | this.valueAccessor.set(value); 304 | this.originalModel = false; 305 | } 306 | 307 | // if term is empty then clean the model 308 | if (this.valueAccessor.model && term === "") { 309 | this.valueAccessor.set(undefined); 310 | } 311 | } else { 312 | this.originalModel = false; 313 | } 314 | 315 | this.loadDenounce(); 316 | } 317 | 318 | load(): Subscription { 319 | if (!this.loader || this.originalModel || !this.term || this.term.length < this.minQueryLength || this.term === this.lastLoadTerm) 320 | return; 321 | 322 | return this 323 | .loader(this.term) 324 | .subscribe(items => { 325 | this.lastLoadTerm = this.term; 326 | this.items = items; 327 | }); 328 | } 329 | 330 | onModelChange(model: any) { 331 | this.valueAccessor.set(model); 332 | if (!this.isMultiple() && model) { 333 | this.term = this.getItemLabel(model); 334 | this.lastLoadTerm = this.term; 335 | this.items = []; 336 | } else { 337 | this.lastLoadTerm = ""; 338 | this.term = ""; 339 | this.items = []; 340 | } 341 | } 342 | 343 | addTerm() { 344 | if (!this.term || !this.persist || !this.isMultiple()) return; 345 | 346 | // if (!this.valueAccessor.model) 347 | // this.valueAccessor.set([]); 348 | 349 | const newModel = this.itemConstructor ? this.itemConstructor(this.term) : { [this.labelBy as string]: this.term }; 350 | this.valueAccessor.add(newModel); 351 | this.lastLoadTerm = ""; 352 | this.term = ""; 353 | this.items = []; 354 | } 355 | 356 | isMultiple() { 357 | if (this.multiple !== undefined) 358 | return this.multiple; 359 | 360 | return this.valueAccessor.model instanceof Array; 361 | } 362 | 363 | isDisabled() { 364 | if (this.maxModelSize > 0 && 365 | this.isMultiple() && 366 | this.valueAccessor.model.length >= this.maxModelSize) 367 | return true; 368 | 369 | return this.disabled; 370 | } 371 | 372 | getItemLabel(item: any) { 373 | if (!item) return ""; 374 | const labelBy = this.valueBy ? this.itemLabelBy : (this.itemLabelBy || this.labelBy); 375 | 376 | if (labelBy) { 377 | if (typeof labelBy === "string") { 378 | return item[labelBy as string]; 379 | 380 | } else if (typeof labelBy === "function") { 381 | return (labelBy as any)(item); 382 | } 383 | } 384 | 385 | return item; 386 | } 387 | 388 | 389 | } -------------------------------------------------------------------------------- /src/Checkbox.ts: -------------------------------------------------------------------------------- 1 | import {Input, Host, Directive, HostBinding, Optional, HostListener, Provider, forwardRef, Inject} from "@angular/core"; 2 | import {NG_VALUE_ACCESSOR, NG_VALIDATORS} from "@angular/forms"; 3 | import {SelectValueAccessor} from "./SelectValueAccessor"; 4 | import {SelectValidator} from "./SelectValidator"; 5 | import {CheckboxGroup} from "./CheckboxGroup"; 6 | 7 | @Directive({ 8 | selector: "input[type=checkbox]", 9 | providers: [ 10 | SelectValueAccessor, 11 | SelectValidator, 12 | { provide: NG_VALUE_ACCESSOR, useExisting: SelectValueAccessor, multi: true }, 13 | { provide: NG_VALIDATORS, useExisting: SelectValidator, multi: true } 14 | ], 15 | }) 16 | export class Checkbox { 17 | 18 | // ------------------------------------------------------------------------- 19 | // Inputs 20 | // ------------------------------------------------------------------------- 21 | 22 | @Input() 23 | value: any = true; 24 | 25 | @Input() 26 | uncheckedValue: any = false; 27 | 28 | // ------------------------------------------------------------------------- 29 | // Input accessors 30 | // ------------------------------------------------------------------------- 31 | 32 | @Input() 33 | set required(required: boolean) { 34 | this.validator.options.required = required; 35 | } 36 | 37 | get required() { 38 | return this.validator.options.required; 39 | } 40 | 41 | // ------------------------------------------------------------------------- 42 | // Constructor 43 | // ------------------------------------------------------------------------- 44 | 45 | constructor(@Optional() @Host() @Inject(forwardRef(() => CheckboxGroup)) private checkboxGroup: CheckboxGroup, 46 | private validator: SelectValidator, 47 | private valueAccessor: SelectValueAccessor) { 48 | } 49 | 50 | // ------------------------------------------------------------------------- 51 | // Bindings 52 | // ------------------------------------------------------------------------- 53 | 54 | @HostBinding("checked") 55 | get checked() { 56 | const valueAccessor = this.checkboxGroup ? this.checkboxGroup.valueAccessor : this.valueAccessor; 57 | return valueAccessor.has(this.value); 58 | } 59 | 60 | @HostListener("click") 61 | check() { 62 | const valueAccessor = this.checkboxGroup ? this.checkboxGroup.valueAccessor : this.valueAccessor; 63 | if (valueAccessor.model instanceof Array) { 64 | valueAccessor.addOrRemove(this.value); 65 | } else { 66 | if (valueAccessor.has(this.value)) { 67 | valueAccessor.set(this.uncheckedValue); 68 | } else { 69 | valueAccessor.set(this.value); 70 | } 71 | } 72 | } 73 | 74 | } -------------------------------------------------------------------------------- /src/CheckboxGroup.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input, Provider, forwardRef, ViewEncapsulation, ContentChildren} from "@angular/core"; 2 | import {NG_VALUE_ACCESSOR, NG_VALIDATORS} from "@angular/forms"; 3 | import {SelectValueAccessor} from "./SelectValueAccessor"; 4 | import {SelectValidator} from "./SelectValidator"; 5 | import {CheckboxItem} from "./CheckboxItem"; 6 | 7 | @Component({ 8 | selector: "checkbox-group", 9 | template: ``, 10 | encapsulation: ViewEncapsulation.None, 11 | providers: [ 12 | SelectValueAccessor, 13 | SelectValidator, 14 | { provide: NG_VALUE_ACCESSOR, useExisting: SelectValueAccessor, multi: true }, 15 | { provide: NG_VALIDATORS, useExisting: SelectValidator, multi: true } 16 | ] 17 | }) 18 | export class CheckboxGroup { 19 | 20 | // ------------------------------------------------------------------------- 21 | // Inputs 22 | // ------------------------------------------------------------------------- 23 | 24 | @Input() 25 | disabled: boolean = false; 26 | 27 | @Input() 28 | readonly: boolean = false; 29 | 30 | @Input() 31 | customToggleLogic: (options: { event: MouseEvent, valueAccessor: SelectValueAccessor, value: any }) => void; 32 | 33 | // ------------------------------------------------------------------------- 34 | // Input accessors 35 | // ------------------------------------------------------------------------- 36 | 37 | @Input() 38 | set trackBy(trackBy: string|((item: any) => string)) { 39 | this.valueAccessor.trackBy = trackBy; 40 | } 41 | 42 | get trackBy() { 43 | return this.valueAccessor.trackBy; 44 | } 45 | 46 | @Input() 47 | set required(required: boolean) { 48 | this.validator.options.required = required; 49 | } 50 | 51 | get required() { 52 | return this.validator.options.required; 53 | } 54 | 55 | // ------------------------------------------------------------------------- 56 | // Public Properties 57 | // ------------------------------------------------------------------------- 58 | 59 | @ContentChildren(forwardRef(() => CheckboxItem)) 60 | checkboxItems: CheckboxItem[]; 61 | 62 | // ------------------------------------------------------------------------- 63 | // Constructor 64 | // ------------------------------------------------------------------------- 65 | 66 | constructor(public valueAccessor: SelectValueAccessor, 67 | private validator: SelectValidator) { 68 | } 69 | // ------------------------------------------------------------------------- 70 | // Public Methods 71 | // ------------------------------------------------------------------------- 72 | 73 | get values() { 74 | if (!this.checkboxItems) return []; 75 | return this.checkboxItems.map(checkboxItem => checkboxItem.value); 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /src/CheckboxItem.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input, Host, forwardRef, Inject, ViewEncapsulation, Output, EventEmitter} from "@angular/core"; 2 | import {CheckboxGroup} from "./CheckboxGroup"; 3 | 4 | @Component({ 5 | selector: "checkbox-item", 6 | template: ` 7 |
11 | 12 |
`, 13 | encapsulation: ViewEncapsulation.None, 14 | styles: [` 15 | .checkbox-item { 16 | cursor: pointer; 17 | } 18 | .checkbox-item.disabled { 19 | cursor: not-allowed; 20 | } 21 | .checkbox-item.readonly { 22 | cursor: default; 23 | } 24 | `] 25 | }) 26 | export class CheckboxItem { 27 | 28 | // ------------------------------------------------------------------------- 29 | // Inputs 30 | // ------------------------------------------------------------------------- 31 | 32 | @Input() 33 | value: any; 34 | 35 | @Input() 36 | disabled: boolean = false; 37 | 38 | @Input() 39 | readonly: boolean = false; 40 | 41 | @Output() 42 | onSelect = new EventEmitter<{ event: Event }>(); 43 | 44 | // ------------------------------------------------------------------------- 45 | // Constructor 46 | // ------------------------------------------------------------------------- 47 | 48 | constructor(@Host() @Inject(forwardRef(() => CheckboxGroup)) private checkboxGroup: CheckboxGroup) { 49 | } 50 | 51 | // ------------------------------------------------------------------------- 52 | // Public Methods 53 | // ------------------------------------------------------------------------- 54 | 55 | toggleCheck(event: MouseEvent) { 56 | if (this.isReadonly() || this.isDisabled()) return; 57 | if (this.checkboxGroup.customToggleLogic) { 58 | this.checkboxGroup.customToggleLogic({ 59 | event: event, 60 | valueAccessor: this.checkboxGroup.valueAccessor, 61 | value: this.value 62 | }); 63 | 64 | } else { 65 | this.checkboxGroup.valueAccessor.addOrRemove(this.value); 66 | } 67 | 68 | this.onSelect.emit({ event: event }); 69 | } 70 | 71 | isChecked() { 72 | return this.checkboxGroup.valueAccessor.has(this.value); 73 | } 74 | 75 | isDisabled() { 76 | return this.disabled === true || this.checkboxGroup.disabled; 77 | } 78 | 79 | isReadonly() { 80 | return this.readonly || this.checkboxGroup.readonly; 81 | } 82 | 83 | } -------------------------------------------------------------------------------- /src/ItemTemplate.ts: -------------------------------------------------------------------------------- 1 | import {Input, TemplateRef, ViewContainerRef, Directive, QueryList} from "@angular/core"; 2 | 3 | @Directive({ 4 | selector: "[itemTemplate]" 5 | }) 6 | export class ItemTemplate { 7 | 8 | @Input("itemTemplate") 9 | item: any; 10 | 11 | constructor(public templateRef: TemplateRef) { 12 | } 13 | 14 | } 15 | 16 | @Directive({ 17 | selector: "[itemTemplateTransclude]" 18 | }) 19 | export class ItemTemplateTransclude { 20 | 21 | private itemTemplates: QueryList; 22 | 23 | @Input() 24 | item: any; 25 | 26 | @Input() 27 | set itemTemplateTransclude(itemTemplates: QueryList) { 28 | this.itemTemplates = itemTemplates; 29 | if (itemTemplates) { 30 | const itemTemplate = itemTemplates.toArray().find(itemTemplate => itemTemplate.item === this.item); 31 | if (itemTemplate && this.viewContainer) 32 | this.viewContainer.createEmbeddedView(itemTemplate.templateRef); 33 | } 34 | }; 35 | 36 | get itemTemplateTransclude() { 37 | return this.itemTemplates; 38 | } 39 | 40 | constructor(private viewContainer: ViewContainerRef) { 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /src/RadioBox.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Input, 3 | Host, 4 | Directive, 5 | ElementRef, 6 | HostBinding, 7 | HostListener, 8 | Optional, 9 | Provider, 10 | forwardRef 11 | } from "@angular/core"; 12 | import {NG_VALUE_ACCESSOR, NG_VALIDATORS} from "@angular/forms"; 13 | import {SelectValueAccessor} from "./SelectValueAccessor"; 14 | import {SelectValidator} from "./SelectValidator"; 15 | import {RadioGroup} from "./RadioGroup"; 16 | 17 | @Directive({ 18 | selector: "input[type=radio]", 19 | providers: [ 20 | SelectValueAccessor, 21 | SelectValidator, 22 | { provide: NG_VALUE_ACCESSOR, useExisting: SelectValueAccessor, multi: true }, 23 | { provide: NG_VALIDATORS, useExisting: SelectValidator, multi: true } 24 | ] 25 | }) 26 | export class RadioBox { 27 | 28 | // ------------------------------------------------------------------------- 29 | // Input accessors 30 | // ------------------------------------------------------------------------- 31 | 32 | @Input() 33 | set required(required: boolean) { 34 | this.validator.options.required = required; 35 | } 36 | 37 | get required() { 38 | return this.validator.options.required; 39 | } 40 | 41 | // ------------------------------------------------------------------------- 42 | // Constructor 43 | // ------------------------------------------------------------------------- 44 | 45 | constructor(private element: ElementRef, @Optional() @Host() private radioGroup: RadioGroup, 46 | private valueAccessor: SelectValueAccessor, 47 | private validator: SelectValidator) { 48 | } 49 | 50 | // ------------------------------------------------------------------------- 51 | // Bindings 52 | // ------------------------------------------------------------------------- 53 | 54 | @HostBinding("checked") 55 | get checked() { 56 | const element: HTMLInputElement = this.element.nativeElement; 57 | const valueAccessor = this.radioGroup ? this.radioGroup.valueAccessor : this.valueAccessor; 58 | return valueAccessor.model === element.value; 59 | } 60 | 61 | @HostListener("click") 62 | check() { 63 | const element: HTMLInputElement = this.element.nativeElement; 64 | const valueAccessor = this.radioGroup ? this.radioGroup.valueAccessor : this.valueAccessor; 65 | valueAccessor.set(element.value); 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /src/RadioGroup.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input, Provider, ViewEncapsulation} from "@angular/core"; 2 | import {NG_VALUE_ACCESSOR, NG_VALIDATORS} from "@angular/forms"; 3 | import {SelectValueAccessor} from "./SelectValueAccessor"; 4 | import {SelectValidator} from "./SelectValidator"; 5 | 6 | @Component({ 7 | selector: "radio-group", 8 | template: `
`, 9 | encapsulation: ViewEncapsulation.None, 10 | providers: [ 11 | SelectValueAccessor, 12 | SelectValidator, 13 | { provide: NG_VALUE_ACCESSOR, useExisting: SelectValueAccessor, multi: true }, 14 | { provide: NG_VALIDATORS, useExisting: SelectValidator, multi: true } 15 | ] 16 | }) 17 | export class RadioGroup { 18 | 19 | // ------------------------------------------------------------------------- 20 | // Inputs 21 | // ------------------------------------------------------------------------- 22 | 23 | @Input() 24 | disabled: boolean = false; 25 | 26 | @Input() 27 | readonly: boolean = false; 28 | 29 | // ------------------------------------------------------------------------- 30 | // Input accessors 31 | // ------------------------------------------------------------------------- 32 | 33 | @Input() 34 | set trackBy(trackBy: string|((item: any) => string)) { 35 | this.valueAccessor.trackBy = trackBy; 36 | } 37 | 38 | get trackBy() { 39 | return this.valueAccessor.trackBy; 40 | } 41 | 42 | @Input() 43 | set required(required: boolean) { 44 | this.validator.options.required = required; 45 | } 46 | 47 | get required() { 48 | return this.validator.options.required; 49 | } 50 | 51 | // ------------------------------------------------------------------------- 52 | // Constructor 53 | // ------------------------------------------------------------------------- 54 | 55 | constructor(public valueAccessor: SelectValueAccessor, 56 | private validator: SelectValidator) { 57 | } 58 | 59 | } -------------------------------------------------------------------------------- /src/RadioItem.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input, Host, forwardRef, Inject, ViewEncapsulation, Output} from "@angular/core"; 2 | import {RadioGroup} from "./RadioGroup"; 3 | import {EventEmitter} from "@angular/platform-browser-dynamic/src/facade/async"; 4 | 5 | @Component({ 6 | selector: "radio-item", 7 | template: ` 8 |
12 | 13 |
`, 14 | styles: [` 15 | .radio-item { 16 | cursor: pointer; 17 | } 18 | .radio-item.disabled { 19 | cursor: not-allowed; 20 | } 21 | .radio-item.readonly { 22 | cursor: default; 23 | } 24 | `], 25 | encapsulation: ViewEncapsulation.None 26 | }) 27 | export class RadioItem { 28 | 29 | // ------------------------------------------------------------------------- 30 | // Inputs 31 | // ------------------------------------------------------------------------- 32 | 33 | @Input() 34 | value: any; 35 | 36 | @Input() 37 | disabled: boolean = false; 38 | 39 | @Input() 40 | readonly: boolean = false; 41 | 42 | @Output() 43 | onSelect = new EventEmitter<{ event: Event }>(); 44 | 45 | // ------------------------------------------------------------------------- 46 | // Constructor 47 | // ------------------------------------------------------------------------- 48 | 49 | constructor(@Host() @Inject(forwardRef(() => RadioGroup)) private radioGroup: RadioGroup) { 50 | } 51 | 52 | // ------------------------------------------------------------------------- 53 | // Public Methods 54 | // ------------------------------------------------------------------------- 55 | 56 | check(event: MouseEvent) { 57 | if (this.isReadonly() || this.isDisabled()) return; 58 | this.radioGroup.valueAccessor.set(this.value); 59 | this.onSelect.emit({ event: event }); 60 | } 61 | 62 | isChecked() { 63 | return this.radioGroup.valueAccessor.has(this.value); 64 | } 65 | 66 | isDisabled() { 67 | return this.disabled === true || this.radioGroup.disabled; 68 | } 69 | 70 | isReadonly() { 71 | return this.readonly || this.radioGroup.readonly; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/SelectControlsOptions.ts: -------------------------------------------------------------------------------- 1 | import "rxjs/Rx"; 2 | import { 3 | Component, 4 | Input, 5 | ViewEncapsulation, 6 | OnInit, 7 | Directive, 8 | ContentChildren, 9 | QueryList, 10 | ContentChild, Injectable 11 | } from "@angular/core"; 12 | import {NG_VALIDATORS, NG_VALUE_ACCESSOR} from "@angular/forms"; 13 | import {SelectItems} from "./SelectItems"; 14 | import {DROPDOWN_DIRECTIVES} from "ng2-dropdown"; 15 | import {Observable, Subscription} from "rxjs/Rx"; 16 | import {SelectValueAccessor} from "./SelectValueAccessor"; 17 | import {SelectValidator} from "./SelectValidator"; 18 | import {Utils} from "./Utils"; 19 | import {ItemTemplate} from "./ItemTemplate"; 20 | 21 | @Injectable() 22 | export class SelectControlsOptions { 23 | 24 | searchLabel: string; 25 | selectAllLabel: string; 26 | noSelectionLabel: string; 27 | 28 | } -------------------------------------------------------------------------------- /src/SelectDropdown.ts: -------------------------------------------------------------------------------- 1 | import "rxjs/Rx"; 2 | import {Component, Input, Provider, ViewEncapsulation, ViewChild} from "@angular/core"; 3 | import {NG_VALIDATORS, NG_VALUE_ACCESSOR} from "@angular/forms"; 4 | import {SelectItems} from "./SelectItems"; 5 | import {DROPDOWN_DIRECTIVES, Dropdown} from "ng2-dropdown"; 6 | import {SelectValidator} from "./SelectValidator"; 7 | import {SelectValueAccessor} from "./SelectValueAccessor"; 8 | 9 | @Component({ 10 | selector: "select-dropdown", 11 | template: ` 12 |
13 | 65 |
`, 66 | styles: [` 67 | .select-dropdown .hidden { 68 | display: none !important; 69 | } 70 | .select-dropdown .select-dropdown-box { 71 | outline: none; 72 | } 73 | .select-dropdown .select-dropdown-box .select-items-item { 74 | display: inline-block; 75 | } 76 | .select-dropdown .btn-plus { 77 | border-left: none; 78 | } 79 | .select-dropdown .select-dropdown-dropdown { 80 | position: relative; 81 | } 82 | .select-dropdown .select-dropdown-dropdown-menu { 83 | position: absolute; 84 | top: 100%; 85 | left: 0; 86 | z-index: 1000; 87 | display: none; 88 | float: left; 89 | min-width: 160px; 90 | padding: 5px 0; 91 | margin: 2px 0 0; 92 | font-size: 14px; 93 | text-align: left; 94 | list-style: none; 95 | background-color: #fff; 96 | -webkit-background-clip: padding-box; 97 | background-clip: padding-box; 98 | border: 1px solid #ccc; 99 | border: 1px solid rgba(0, 0, 0, .15); 100 | } 101 | .select-dropdown .dropdown.open .dropdown-menu { 102 | display: block; 103 | } 104 | .select-dropdown .select-dropdown-box .single-selected, 105 | .select-dropdown .select-dropdown-box .select-items .select-items-item .select-items-label, 106 | .select-dropdown .select-dropdown-box .no-selection { 107 | color: #337ab7; 108 | border-bottom: 1px dashed #337ab7; 109 | cursor: pointer; 110 | } 111 | .select-dropdown .dropdown-menu .select-items .select-items-item.selected { 112 | text-decoration: none; 113 | color: #fff; 114 | background-color: #0095cc; 115 | } 116 | .select-dropdown .dropdown-menu .select-items .select-items-item.active.selected { 117 | background-color: #469FE0; 118 | } 119 | .select-dropdown .select-dropdown-box .single-selected.disabled, 120 | .select-dropdown .select-dropdown-box .no-selection.disabled { 121 | color: #CCC; 122 | border-bottom: 1px dashed #CCC; 123 | cursor: not-allowed; 124 | } 125 | .select-dropdown .select-dropdown-box .single-selected.readonly, 126 | .select-dropdown .select-dropdown-box .no-selection.readonly { 127 | color: #333; 128 | border: none; 129 | cursor: default; 130 | } 131 | .select-dropdown .select-dropdown-box .select-items .select-items-label { 132 | padding-right: 0; 133 | } 134 | .select-dropdown .select-dropdown-box .select-items .select-items-item .separator { 135 | padding-right: 2px; 136 | } 137 | .select-dropdown .select-dropdown-box .select-items .select-items-item .separator:before { 138 | content: ","; 139 | } 140 | .select-dropdown .select-dropdown-dropdown-menu .select-items .no-selection, 141 | .select-dropdown .select-dropdown-dropdown-menu .select-items .select-all, 142 | .select-dropdown .select-dropdown-dropdown-menu .select-items .checkbox-item, 143 | .select-dropdown .select-dropdown-dropdown-menu .select-items .radio-item { 144 | padding: 3px 10px; 145 | clear: both; 146 | font-weight: normal; 147 | line-height: 1.42857; 148 | white-space: nowrap; 149 | display: block; 150 | } 151 | .select-dropdown .select-dropdown-dropdown-menu .select-items .no-selection:hover, 152 | .select-dropdown .select-dropdown-dropdown-menu .select-items .select-all:hover, 153 | .select-dropdown .select-dropdown-dropdown-menu .select-items .checkbox-item:hover, 154 | .select-dropdown .select-dropdown-dropdown-menu .select-items .radio-item:hover { 155 | text-decoration: none; 156 | color: #fff; 157 | background-color: #0095cc; 158 | cursor: pointer; 159 | } 160 | .select-dropdown .select-dropdown-dropdown-menu .select-items .checkbox-item.disabled:hover, 161 | .select-dropdown .select-dropdown-dropdown-menu .select-items .radio-item.disabled:hover { 162 | color: #333; 163 | background-color: #eeeeee; 164 | cursor: not-allowed; 165 | } 166 | .select-dropdown .select-dropdown-dropdown-menu .select-items .select-items-search { 167 | margin: 0 5px 5px 5px; 168 | } 169 | `], 170 | encapsulation: ViewEncapsulation.None, 171 | directives: [ 172 | SelectItems, 173 | DROPDOWN_DIRECTIVES 174 | ], 175 | providers: [ 176 | SelectValueAccessor, 177 | SelectValidator, 178 | { provide: NG_VALUE_ACCESSOR, useExisting: SelectValueAccessor, multi: true }, 179 | { provide: NG_VALIDATORS, useExisting: SelectValidator, multi: true } 180 | ] 181 | }) 182 | export class SelectDropdown { 183 | 184 | // ------------------------------------------------------------------------- 185 | // Inputs 186 | // ------------------------------------------------------------------------- 187 | 188 | @Input() 189 | items: any[]; 190 | 191 | @Input() 192 | label: string = "click to select"; 193 | 194 | @Input() 195 | readonly: boolean = false; 196 | 197 | @Input() 198 | readonlyLabel: string; 199 | 200 | @Input() 201 | multiple: boolean; 202 | 203 | @Input() 204 | listTrackBy: string|((item: any) => string); 205 | 206 | @Input() 207 | listLabelBy: string|((item: any) => string); 208 | 209 | @Input() 210 | labelBy: string|((item: any) => string); 211 | 212 | @Input() 213 | disableBy: string|((item: any) => string); 214 | 215 | @Input() 216 | searchBy: string|((item: any, keyword: string) => boolean); 217 | 218 | @Input() 219 | orderBy: string|((item1: any, item2: any) => number); 220 | 221 | @Input() 222 | orderDirection: "asc"|"desc"; 223 | 224 | @Input() 225 | disabled: boolean = false; 226 | 227 | @Input() 228 | limit: number; 229 | 230 | @Input() 231 | noSelectionLabel: string; 232 | 233 | @Input() 234 | searchLabel: string; 235 | 236 | @Input() 237 | selectAllLabel: string; 238 | 239 | @Input() 240 | hideControls: boolean; 241 | 242 | @Input() 243 | maxModelSize: number; 244 | 245 | @Input() 246 | minModelSize: number; 247 | 248 | @Input() 249 | filter: (items: any[]) => any[]; 250 | 251 | // ------------------------------------------------------------------------- 252 | // Input accessors 253 | // ------------------------------------------------------------------------- 254 | 255 | @Input() 256 | set valueBy(valueBy: string|((item: any) => string)) { 257 | this.valueAccessor.valueBy = valueBy; 258 | } 259 | 260 | get valueBy() { 261 | return this.valueAccessor.valueBy; 262 | } 263 | 264 | @Input() 265 | set trackBy(trackBy: string|((item: any) => string)) { 266 | this.valueAccessor.trackBy = trackBy; 267 | } 268 | 269 | get trackBy() { 270 | return this.valueAccessor.trackBy; 271 | } 272 | 273 | @Input() 274 | set required(required: boolean) { 275 | this.validator.options.required = required; 276 | } 277 | 278 | get required() { 279 | return this.validator.options.required; 280 | } 281 | 282 | // ------------------------------------------------------------------------- 283 | // Private Properties 284 | // ------------------------------------------------------------------------- 285 | 286 | @ViewChild(Dropdown) 287 | dropdown: Dropdown; 288 | 289 | @ViewChild("dropdownSelectItems") 290 | selectItems: SelectItems; 291 | 292 | // ------------------------------------------------------------------------- 293 | // Constructor 294 | // ------------------------------------------------------------------------- 295 | 296 | constructor(public valueAccessor: SelectValueAccessor, 297 | private validator: SelectValidator) { 298 | } 299 | 300 | // ------------------------------------------------------------------------- 301 | // Public Methods 302 | // ------------------------------------------------------------------------- 303 | 304 | isMultiple() { 305 | if (this.multiple !== undefined) 306 | return this.multiple; 307 | 308 | return this.valueAccessor.model instanceof Array; 309 | } 310 | 311 | getItemLabel(item: any) {// todo: duplication 312 | if (!item) return ""; 313 | const labelBy = this.valueBy ? this.listLabelBy : (this.listLabelBy || this.labelBy); 314 | 315 | if (labelBy) { 316 | if (typeof labelBy === "string") { 317 | return item[labelBy as string]; 318 | 319 | } else if (typeof labelBy === "function") { 320 | return (labelBy as any)(item); 321 | } 322 | } 323 | 324 | return item; 325 | } 326 | 327 | onModelChange() { 328 | if (!this.isMultiple()) { 329 | this.dropdown.close(); 330 | } 331 | } 332 | 333 | /** 334 | * When user keydowns on component we need to capture esc/enter/arrows to make possible keyboard management. 335 | */ 336 | onSelectTagsBoxKeydown(event: KeyboardEvent) { 337 | if (event.keyCode === 27 && this.dropdown.isOpened()) { 338 | this.dropdown.close(); 339 | 340 | } else if (event.keyCode === 38) { // top 341 | this.selectItems.previousActive(); 342 | event.preventDefault(); 343 | event.stopPropagation(); 344 | 345 | } else if (event.keyCode === 40) { // bottom 346 | this.selectItems.nextActive(); 347 | event.preventDefault(); 348 | event.stopPropagation(); 349 | 350 | } else if (event.keyCode === 13 || event.keyCode === 32) { // enter or space 351 | this.selectItems.selectActive(); 352 | event.preventDefault(); 353 | event.stopPropagation(); 354 | } 355 | 356 | } 357 | 358 | } -------------------------------------------------------------------------------- /src/SelectItems.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | Input, 4 | ViewEncapsulation, 5 | ChangeDetectorRef, 6 | AfterViewInit, 7 | ElementRef, 8 | ViewChildren, 9 | QueryList, 10 | Output, 11 | EventEmitter, 12 | ContentChildren, Optional 13 | } from "@angular/core"; 14 | import {NG_VALIDATORS, NG_VALUE_ACCESSOR} from "@angular/forms"; 15 | import {CheckboxGroup} from "./CheckboxGroup"; 16 | import {RadioGroup} from "./RadioGroup"; 17 | import {RadioItem} from "./RadioItem"; 18 | import {CheckboxItem} from "./CheckboxItem"; 19 | import {SelectValueAccessor} from "./SelectValueAccessor"; 20 | import {SelectValidator} from "./SelectValidator"; 21 | import {ItemTemplate, ItemTemplateTransclude} from "./ItemTemplate"; 22 | import {SelectControlsOptions} from "./SelectControlsOptions"; 23 | 24 | @Component({ 25 | selector: "select-items", 26 | template: ` 27 |
28 |
29 | 30 |
31 |
32 |
38 | 39 | {{ getSelectAllLabel() }} 40 |
41 | 42 |
43 |
47 | 50 | {{ group }} 51 |
52 |
59 | 64 | 65 | {{ getItemLabel(item) }} 66 | 67 | × 69 |
70 |
71 |
72 |
73 |
74 |
80 | 81 | {{ getNoSelectionLabel() }} 82 |
83 | 84 |
85 |
86 | {{ group }} 87 |
88 |
96 | 101 | 102 | {{ getItemLabel(item) }} 103 | 104 | × 105 |
106 |
107 |
108 |
109 |
110 | {{ moreLabel }} 111 |
112 |
113 |
114 | {{ hideLabel }} 115 |
116 |
117 |
`, 118 | styles: [` 119 | .select-items .hidden { 120 | display: none !important; 121 | } 122 | .select-items input.select-items-search { 123 | padding: 6px 12px; 124 | margin-bottom: 5px; 125 | 126 | } 127 | .select-items .select-all, .select-items .no-selection { 128 | cursor: pointer; 129 | } 130 | .select-items .select-items-item.hide-controls.selected { 131 | background: #337ab7; 132 | color: #FFF; 133 | } 134 | .select-items .select-items-item.selected.active { 135 | background: #469FE0; 136 | color: #FFF; 137 | } 138 | .select-items .select-items-item.active { 139 | color: #495c68; 140 | background-color: #f5fafd; 141 | } 142 | .select-items .select-items-label { 143 | padding-left: 3px; 144 | padding-right: 3px; 145 | } 146 | .select-items .select-items-group-header { 147 | font-weight: bold; 148 | margin-top: 2px; 149 | } 150 | .select-items .select-items-group-header.select-all { 151 | cursor: pointer; 152 | } 153 | .select-items .select-items-group-header input[type=checkbox] { 154 | vertical-align: text-top; 155 | } 156 | .select-items .remove-button { 157 | font-size: 12px; 158 | font-weight: bold; 159 | color: #999; 160 | vertical-align: text-bottom; 161 | cursor: pointer; 162 | } 163 | .select-items .remove-button:hover { 164 | color: #333; 165 | } 166 | .select-items .checkbox-item, .select-items .radio-item { 167 | display: inline; 168 | } 169 | .select-items .select-items-item.hide-controls input[type=checkbox], 170 | .select-items .select-items-item.hide-controls input[type=radio] { 171 | display: none; 172 | } 173 | .select-items .more-button, .select-items .hide-button { 174 | color: #999; 175 | cursor: pointer; 176 | } 177 | .select-items .more-button a, .select-items .hide-button a { 178 | border-bottom: 1px dashed; 179 | } 180 | .select-items .more-button a:hover, .select-items .hide-button a:hover { 181 | text-decoration: none; 182 | } 183 | .select-items .caret-body { 184 | display: inline-block; 185 | width: 0; 186 | height: 0; 187 | margin-left: 2px; 188 | vertical-align: middle; 189 | border-right: 4px solid transparent; 190 | border-left: 4px solid transparent; 191 | } 192 | .select-items .caret-bottom { 193 | border-bottom: 4px dashed; 194 | } 195 | .select-items .caret-top { 196 | border-top: 4px dashed; 197 | } 198 | `], 199 | encapsulation: ViewEncapsulation.None, 200 | directives: [ 201 | RadioGroup, RadioItem, CheckboxGroup, CheckboxItem, ItemTemplateTransclude 202 | ], 203 | providers: [ 204 | SelectValueAccessor, 205 | SelectValidator, 206 | { provide: NG_VALUE_ACCESSOR, useExisting: SelectValueAccessor, multi: true }, 207 | { provide: NG_VALIDATORS, useExisting: SelectValidator, multi: true } 208 | ] 209 | }) 210 | export class SelectItems implements AfterViewInit { 211 | 212 | // ------------------------------------------------------------------------- 213 | // Inputs / Outputs 214 | // ------------------------------------------------------------------------- 215 | 216 | @Input() 217 | items: any[]; 218 | 219 | @Output() 220 | itemsChange = new EventEmitter(); 221 | 222 | @Input() 223 | multiple: boolean; 224 | 225 | @Input() 226 | disableBy: string|((item: any) => string); 227 | 228 | @Input() 229 | labelBy: string|((item: any) => string); 230 | 231 | @Input() 232 | searchBy: string|((item: any, keyword: string) => boolean); 233 | 234 | @Input() 235 | orderBy: string|((item1: any, item2: any) => number); 236 | 237 | @Input() 238 | groupBy: string|((item1: any, item2: any) => number); 239 | 240 | @Input() 241 | groupSelectAll: boolean = false; 242 | 243 | @Input() 244 | hideGroupSelectAllCheckbox: boolean = false; 245 | 246 | @Input() 247 | orderDirection: "asc"|"desc"; 248 | 249 | @Input() 250 | limit: number; 251 | 252 | @Input() 253 | disabled: boolean = false; 254 | 255 | @Input() 256 | readonly: boolean = false; 257 | 258 | @Input() 259 | hideSelected: boolean = false; 260 | 261 | @Input() 262 | removeButton: boolean = false; 263 | 264 | @Input() 265 | hideControls: boolean = false; 266 | 267 | @Input() 268 | searchLabel: string; 269 | 270 | @Input() 271 | moreLabel: string; 272 | 273 | @Input() 274 | hideLabel: string; 275 | 276 | @Input() 277 | selectAll: boolean = false; 278 | 279 | @Input() 280 | selectAllLabel: string; 281 | 282 | @Input() 283 | noSelection: boolean = false; 284 | 285 | @Input() 286 | noSelectionLabel: string; 287 | 288 | @Input() 289 | maxModelSize: number; 290 | 291 | @Input() 292 | minModelSize: number; 293 | 294 | @Input() 295 | filter: (items: any[]) => any[]; 296 | 297 | @Input() 298 | customToggleLogic: (options: { event: MouseEvent, valueAccessor: SelectValueAccessor }) => void; 299 | 300 | @Output() 301 | onSelect = new EventEmitter<{ event: Event }>(); 302 | 303 | @Input() 304 | keyword: string; 305 | 306 | // ------------------------------------------------------------------------- 307 | // Input accessors 308 | // ------------------------------------------------------------------------- 309 | 310 | @Input() 311 | set valueBy(valueBy: string|((item: any) => string)) { 312 | this.valueAccessor.valueBy = valueBy; 313 | } 314 | 315 | get valueBy() { 316 | return this.valueAccessor.valueBy; 317 | } 318 | 319 | @Input() 320 | set trackBy(trackBy: string|((item: any) => string)) { 321 | this.valueAccessor.trackBy = trackBy; 322 | } 323 | 324 | get trackBy() { 325 | return this.valueAccessor.trackBy; 326 | } 327 | 328 | @Input() 329 | set required(required: boolean) { 330 | this.validator.options.required = required; 331 | } 332 | 333 | get required() { 334 | return this.validator.options.required; 335 | } 336 | 337 | // ------------------------------------------------------------------------- 338 | // Public Properties 339 | // ------------------------------------------------------------------------- 340 | 341 | active: number = -1; 342 | activeSelectAll: boolean = false; 343 | activeNoSelection: boolean = false; 344 | isMoreShown: boolean = false; 345 | isMaxLimitReached: boolean = false; 346 | 347 | @ViewChildren("itemElement") 348 | itemElements: QueryList; 349 | 350 | @ContentChildren(ItemTemplate) 351 | itemTemplates: QueryList; 352 | 353 | @Input() 354 | customItemTemplates: QueryList; 355 | 356 | // ------------------------------------------------------------------------- 357 | // Constructor 358 | // ------------------------------------------------------------------------- 359 | 360 | constructor(private cdr: ChangeDetectorRef, 361 | public valueAccessor: SelectValueAccessor, 362 | private validator: SelectValidator, 363 | @Optional() private defaultOptions: SelectControlsOptions) { 364 | } 365 | 366 | // ------------------------------------------------------------------------- 367 | // Lifecycle callbacks 368 | // ------------------------------------------------------------------------- 369 | 370 | ngAfterViewInit(): void { 371 | this.cdr.detectChanges(); 372 | } 373 | 374 | // ------------------------------------------------------------------------- 375 | // Public Methods 376 | // ------------------------------------------------------------------------- 377 | 378 | get displayedItems() { 379 | return this.getItems(); 380 | } 381 | 382 | getItemTemplates() { 383 | if (this.customItemTemplates) 384 | return this.customItemTemplates; 385 | 386 | return this.itemTemplates; 387 | } 388 | 389 | changeModel(model: any) { 390 | this.valueAccessor.set(model); 391 | this.checkActive(); 392 | } 393 | 394 | isMultiple() { 395 | if (this.multiple === undefined) 396 | return this.valueAccessor.model instanceof Array; 397 | 398 | return this.multiple; 399 | } 400 | 401 | isItemDisabled(item: any) { 402 | if (this.disabled) 403 | return true; 404 | 405 | if (this.disableBy) { 406 | if (typeof this.disableBy === "string") { 407 | return !!item[this.disableBy as string]; 408 | 409 | } else if (typeof this.disableBy === "function") { 410 | return !!(this.disableBy as any)(item); 411 | } 412 | } 413 | 414 | if (this.isMultiple()) { 415 | if (this.maxModelSize > 0 && this.valueAccessor.model.length >= this.maxModelSize) { 416 | return this.valueAccessor.has(item) ? false : true; 417 | } 418 | if (this.minModelSize > 0 && this.valueAccessor.model.length <= this.minModelSize) { 419 | return this.valueAccessor.has(item) ? true : false; 420 | } 421 | 422 | } 423 | 424 | return false; 425 | } 426 | 427 | getItemLabel(item: any) { 428 | if (this.labelBy) { 429 | if (typeof this.labelBy === "string") { 430 | return item[this.labelBy as string]; 431 | 432 | } else if (typeof this.labelBy === "function") { 433 | return (this.labelBy as any)(item); 434 | } 435 | } 436 | 437 | return item; 438 | } 439 | 440 | getItemValue(item: any) { 441 | if (this.valueBy) { 442 | if (typeof this.valueBy === "string") { 443 | return item[this.valueBy as string]; 444 | 445 | } else if (typeof this.valueBy === "function") { 446 | return (this.valueBy as any)(item); 447 | } 448 | } 449 | 450 | return item; 451 | } 452 | 453 | getGroups() { 454 | if (!this.groupBy) 455 | return [undefined]; 456 | 457 | return this.getItems().map(item => { 458 | if (typeof this.groupBy === "string") { 459 | return item[this.groupBy as string]; 460 | } else { 461 | return (this.groupBy as (item: any) => any)(item); 462 | } 463 | }).filter((item, index, items) => items.lastIndexOf(item) === index); 464 | } 465 | 466 | getItems(group?: any) { 467 | if (!this.items) return []; 468 | 469 | let items = this.items.map(item => item); 470 | if (this.searchBy && this.keyword) { 471 | items = items.filter(item => { 472 | if (typeof this.searchBy === "string") { 473 | if (typeof item[this.searchBy as string] === "string") 474 | return item[this.searchBy as string].toLowerCase().indexOf(this.keyword.toLowerCase()) !== -1; 475 | 476 | } else { 477 | return (this.searchBy as any)(item, this.keyword); 478 | } 479 | return false; 480 | }); 481 | } 482 | 483 | if (this.orderBy) { 484 | if (typeof this.orderBy === "string") { 485 | items.sort((item1, item2) => { 486 | const a = item1[this.orderBy as string]; 487 | const b = item2[this.orderBy as string]; 488 | 489 | if (typeof a === "string" && typeof b === "string") { // order logic for strings 490 | const aLower = a.toLowerCase(); 491 | const bLower = b.toLowerCase(); 492 | if (aLower < bLower) 493 | return -1; 494 | if (aLower > bLower) 495 | return 1; 496 | return 0; 497 | 498 | } else if (typeof a === "number" && typeof b === "number") { // order logic for numbers 499 | return a - b; 500 | } 501 | 502 | return 0; // else simply don't order 503 | }); 504 | } else { 505 | items.sort(this.orderBy as any); 506 | } 507 | 508 | if (this.orderDirection === "desc") 509 | items = items.reverse(); 510 | } 511 | 512 | if (this.hideSelected) { 513 | items = items.filter(item => !this.valueAccessor.has(item)); 514 | } 515 | 516 | this.isMaxLimitReached = false; 517 | if (this.limit) { 518 | const startFrom = items.length - this.limit; 519 | this.isMaxLimitReached = startFrom <= 0; 520 | if (startFrom > 0 && !this.isMoreShown) 521 | items.splice(startFrom * -1); 522 | } 523 | 524 | if (this.filter) 525 | this.filter(items); 526 | 527 | if (this.groupBy && group) { 528 | items = items.filter(item => { 529 | if (typeof this.groupBy === "string") { 530 | return item[this.groupBy as string] === group; 531 | } else { 532 | return (this.groupBy as (item: any) => any)(item) === group; 533 | } 534 | }); 535 | } 536 | 537 | return items; 538 | } 539 | 540 | removeItem(item: any) { 541 | if (this.isItemDisabled(item)) return; 542 | this.items.splice(this.items.indexOf(item), 1); 543 | this.itemsChange.emit(this.items); 544 | this.checkActive(); 545 | } 546 | 547 | showMore() { 548 | this.isMoreShown = true; 549 | } 550 | 551 | hideMore() { 552 | this.isMoreShown = false; 553 | } 554 | 555 | selectAllItems(items: any[]) { 556 | if (!this.isAllSelected(items)) { 557 | items.forEach(item => this.valueAccessor.add(this.getItemValue(item))); 558 | } else { 559 | items.forEach(item => this.valueAccessor.remove(this.getItemValue(item))); 560 | } 561 | } 562 | 563 | isAllSelected(items: any[]): boolean { 564 | let has = true; 565 | items.forEach(item => { 566 | if (has) 567 | has = this.valueAccessor.has(this.getItemValue(item)); 568 | }); 569 | 570 | return has; 571 | } 572 | 573 | resetModel() { 574 | this.valueAccessor.set(undefined); 575 | } 576 | 577 | previousActive() { 578 | const items = this.getItems(); 579 | let newIndex = this.active - 1; 580 | if (newIndex === -1) { 581 | if (this.isMultiple() && this.selectAllLabel) { 582 | this.activeSelectAll = true; 583 | this.active = -1; 584 | } else if (!this.isMultiple() && this.noSelectionLabel) { 585 | this.activeNoSelection = true; 586 | this.active = -1; 587 | } else { 588 | newIndex = 0; 589 | } 590 | } else if (newIndex === -2 && !this.activeNoSelection && !this.activeSelectAll) { 591 | this.active = items.length - 1; 592 | } 593 | 594 | if (newIndex !== -1) { 595 | if (items[newIndex] !== null && items[newIndex] !== undefined) { 596 | this.active = newIndex; 597 | } 598 | } 599 | } 600 | 601 | nextActive(): void { 602 | const items = this.getItems(); 603 | const newIndex = this.active + 1; 604 | if (this.activeNoSelection || this.activeSelectAll) { 605 | this.activeNoSelection = this.activeSelectAll = false; 606 | } 607 | if (items[newIndex] !== null && items[newIndex] !== undefined) { 608 | this.active = newIndex; 609 | } 610 | } 611 | 612 | resetActive(): void { 613 | this.active = -1; 614 | this.activeNoSelection = false; 615 | this.activeSelectAll = false; 616 | } 617 | 618 | selectActive() { 619 | const items = this.getItems(); 620 | if (this.activeSelectAll) { 621 | this.selectAllItems(items); 622 | 623 | } else if (this.activeNoSelection) { 624 | this.resetModel(); 625 | 626 | } else if (this.active > -1 && items[this.active]) { 627 | if (this.isMultiple()) { 628 | this.valueAccessor.addOrRemove(items[this.active]); 629 | } else { 630 | this.valueAccessor.set(items[this.active]); 631 | } 632 | this.checkActive(); 633 | } 634 | } 635 | 636 | checkActive(): void { 637 | const items = this.getItems(); 638 | if (this.active > -1 && this.active >= items.length) { 639 | this.active = items.length - 1; 640 | } 641 | } 642 | 643 | hasActive(): boolean { 644 | return !!this.activeNoSelection || !!this.activeSelectAll || this.active > -1; 645 | } 646 | 647 | getSearchLabel() { 648 | if (this.searchLabel !== undefined) 649 | return this.searchLabel; 650 | 651 | if (this.defaultOptions && this.defaultOptions.searchLabel !== undefined) 652 | return this.defaultOptions.searchLabel; 653 | 654 | return ""; 655 | } 656 | 657 | getSelectAllLabel() { 658 | if (this.selectAllLabel !== undefined) 659 | return this.selectAllLabel; 660 | 661 | if (this.defaultOptions && this.defaultOptions.selectAllLabel !== undefined) 662 | return this.defaultOptions.selectAllLabel; 663 | 664 | return "select all"; 665 | } 666 | 667 | getNoSelectionLabel() { 668 | if (this.noSelectionLabel !== undefined) 669 | return this.noSelectionLabel; 670 | 671 | if (this.defaultOptions && this.defaultOptions.noSelectionLabel !== undefined) 672 | return this.defaultOptions.noSelectionLabel; 673 | 674 | return "no selection"; 675 | } 676 | 677 | } -------------------------------------------------------------------------------- /src/SelectTags.ts: -------------------------------------------------------------------------------- 1 | import "rxjs/Rx"; 2 | import { 3 | Component, Input, Provider, ViewEncapsulation, OnInit, ViewChild, ElementRef, 4 | ChangeDetectorRef, ContentChild, QueryList, ContentChildren, Directive 5 | } from "@angular/core"; 6 | import {NG_VALIDATORS, NG_VALUE_ACCESSOR, AbstractControl} from "@angular/forms"; 7 | import {SelectItems} from "./SelectItems"; 8 | import {DROPDOWN_DIRECTIVES} from "ng2-dropdown"; 9 | import {Observable, Subscription} from "rxjs/Rx"; 10 | import {WidthCalculator} from "./WidthCalculator"; 11 | import {SelectValidator} from "./SelectValidator"; 12 | import {SelectValueAccessor} from "./SelectValueAccessor"; 13 | import {Utils} from "./Utils"; 14 | import {ItemTemplate} from "./ItemTemplate"; 15 | 16 | @Directive({ 17 | selector: "select-tags-dropdown-template" 18 | }) 19 | export class SelectTagsDropdownTemplate { 20 | 21 | @ContentChildren(ItemTemplate) 22 | itemTemplates: QueryList; 23 | 24 | } 25 | 26 | @Directive({ 27 | selector: "select-tags-box-template" 28 | }) 29 | export class SelectTagsBoxTemplate { 30 | 31 | @ContentChildren(ItemTemplate) 32 | itemTemplates: QueryList; 33 | 34 | } 35 | 36 | @Component({ 37 | selector: "select-tags", 38 | template: ` 39 |
42 | 108 |
`, 109 | styles: [` 110 | .select-tags { 111 | } 112 | .select-tags .hidden { 113 | display: none !important; 114 | } 115 | .select-tags-add-button { 116 | margin-top: 2px; 117 | float: right; 118 | font-size: 0.75em; 119 | color: #999; 120 | } 121 | .select-tags-non-unique { 122 | margin-top: 2px; 123 | float: left; 124 | font-size: 0.75em; 125 | color: #992914; 126 | } 127 | .select-tags-add-button a { 128 | border-bottom: 1px dotted; 129 | } 130 | .select-tags .select-tags-dropdown .select-tags-input { 131 | border: none; 132 | outline: none; 133 | box-shadow: none; 134 | height: 25px; 135 | width: 4px; 136 | display: inline-block; 137 | } 138 | .select-tags .select-tags-dropdown { 139 | position: relative; 140 | } 141 | .select-tags .select-tags-dropdown-menu { 142 | position: absolute; 143 | top: 100%; 144 | width: 100%; 145 | left: 0; 146 | z-index: 1000; 147 | display: none; 148 | margin: 2px 0px 2px -1px; 149 | min-width: 160px; 150 | padding: 5px 0 5px 0; 151 | font-size: 14px; 152 | text-align: left; 153 | list-style: none; 154 | background-color: #fff; 155 | -webkit-background-clip: padding-box; 156 | background-clip: padding-box; 157 | border: 1px solid #ccc; 158 | border: 1px solid rgba(0, 0, 0, .15); 159 | } 160 | .select-tags .select-tags-dropdown.open .dropdown-menu { 161 | display: block; 162 | } 163 | .select-tags .select-tags-box { 164 | padding: 5px 5px 3px 5px; 165 | outline: 1px solid #CCC; 166 | } 167 | .select-tags .select-tags-box .select-items, 168 | .select-tags .select-tags-box .select-items .select-items-group, 169 | .select-tags .select-tags-box .select-items .select-items-multiple, 170 | .select-tags .select-tags-box .select-items .select-items-single, 171 | .select-tags .select-tags-box .select-items .radio-group, 172 | .select-tags .select-tags-box .select-items .checkbox-group { 173 | display: inline; 174 | } 175 | .select-tags .select-tags-box .select-items .select-items-item .remove-button { 176 | position: absolute; 177 | top: 0; 178 | right: 0; 179 | bottom: 0; 180 | display: inline-block; 181 | width: 20px; 182 | padding: 2px 0 0 0; 183 | font-size: 12px; 184 | font-weight: bold; 185 | color: inherit; 186 | text-align: center; 187 | text-decoration: none; 188 | vertical-align: middle; 189 | border-left: 1px solid #0073bb; 190 | -webkit-border-radius: 0 2px 2px 0; 191 | -moz-border-radius: 0 2px 2px 0; 192 | border-radius: 0 2px 2px 0; 193 | -webkit-box-sizing: border-box; 194 | -moz-box-sizing: border-box; 195 | box-sizing: border-box; 196 | } 197 | .select-tags .select-tags-box .select-items .select-items-item .remove-button:hover { 198 | background: rgba(0, 0, 0, 0.05); 199 | } 200 | .select-tags .select-tags-box .select-items .select-items-item.with-remove-button { 201 | position: relative; 202 | padding-right: 24px !important; 203 | } 204 | .select-tags .select-tags-box .select-items .select-items-item { 205 | display: inline-block; 206 | padding: 2px 6px; 207 | margin: 0 3px 3px 0; 208 | color: #ffffff; 209 | cursor: pointer; 210 | background: #1da7ee; 211 | border: 1px solid #0073bb; 212 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), inset 0 1px rgba(255, 255, 255, 0.03); 213 | border-radius: 3px; 214 | background-image: linear-gradient(to bottom, #1da7ee, #178ee9); 215 | background-repeat: repeat-x; 216 | text-shadow: 0 1px 0 rgba(0, 51, 83, 0.3); 217 | background-color: #1b9dec; 218 | -moz-user-select: none; 219 | -khtml-user-select: none; 220 | -webkit-user-select: none; 221 | -ms-user-select: none; 222 | user-select: none; 223 | } 224 | .select-tags .select-tags-box .select-items .select-items-item.selected, 225 | .select-tags .select-tags-box .select-items .select-items-item.selected { 226 | background-color: #0085d4; 227 | background-image: -moz-linear-gradient(top, #008fd8, #0075cf); 228 | background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#008fd8), to(#0075cf)); 229 | background-image: -webkit-linear-gradient(top, #008fd8, #0075cf); 230 | background-image: -o-linear-gradient(top, #008fd8, #0075cf); 231 | background-image: linear-gradient(to bottom, #008fd8, #0075cf); 232 | background-repeat: repeat-x; 233 | border: 1px solid #00578d; 234 | } 235 | .select-tags .select-tags-box .select-items .select-items-item.selected .remove-button , 236 | .select-tags .select-tags-box .select-items .select-items-item.selected .remove-button { 237 | border-left-color: #00578d; 238 | } 239 | .select-tags .select-tags-dropdown-menu .select-items .no-selection, 240 | .select-tags .select-tags-dropdown-menu .select-items .select-all, 241 | .select-tags .select-tags-dropdown-menu .select-items .checkbox-item, 242 | .select-tags .select-tags-dropdown-menu .select-items .radio-item { 243 | padding: 3px 15px; 244 | clear: both; 245 | font-weight: normal; 246 | line-height: 1.42857; 247 | color: #333333; 248 | white-space: nowrap; 249 | display: block; 250 | } 251 | .select-tags .select-tags-dropdown-menu .select-items .no-selection:hover, 252 | .select-tags .select-tags-dropdown-menu .select-items .select-all:hover, 253 | .select-tags .select-tags-dropdown-menu .select-items .checkbox-item:hover, 254 | .select-tags .select-tags-dropdown-menu .select-items .radio-item:hover { 255 | text-decoration: none; 256 | color: #fff; 257 | background-color: #0095cc; 258 | cursor: pointer; 259 | } 260 | .select-tags .select-items .checkbox-item.disabled:hover, 261 | .select-tags .select-items .radio-item.disabled:hover { 262 | color: #333; 263 | background-color: #eeeeee; 264 | cursor: not-allowed; 265 | } 266 | .select-tags.disabled .select-items .checkbox-item.disabled:hover, .select-tags .select-items .radio-item.disabled:hover{ 267 | color: #BBB; 268 | } 269 | .select-tags.disabled, 270 | .select-tags.disabled .select-tags-box .select-items .remove-button, 271 | .select-tags.disabled .select-tags-box .select-items .select-items-item, 272 | .select-tags.disabled .select-tags-box .select-items .no-selection, 273 | .select-tags.disabled .select-tags-box .select-items .select-all, 274 | .select-tags.disabled .select-tags-box .select-items .checkbox-item, 275 | .select-tags.disabled .select-tags-box .select-items .radio-item { 276 | cursor: not-allowed; 277 | } 278 | .select-tags.disabled .select-tags-box .select-items .select-items-item .remove-button:hover { 279 | background: #EEE; 280 | } 281 | .select-tags.disabled .select-tags-box .select-items .select-items-item .remove-button { 282 | border-left: 1px solid #CCC; 283 | } 284 | .select-tags.disabled .select-tags-box .select-items .select-items-item { 285 | background: #EEE; 286 | border: 1px solid #CCC; 287 | color: #BBB; 288 | box-shadow: none; 289 | text-shadow: none; 290 | } 291 | .select-tags.readonly .select-tags-input { 292 | display: none; 293 | } 294 | .select-tags.readonly .select-tags-box .select-items .select-items-item { 295 | cursor: default; 296 | } 297 | `], 298 | encapsulation: ViewEncapsulation.None, 299 | directives: [ 300 | SelectItems, 301 | DROPDOWN_DIRECTIVES 302 | ], 303 | providers: [ 304 | Utils, 305 | SelectValueAccessor, 306 | SelectValidator, 307 | WidthCalculator, 308 | { provide: NG_VALUE_ACCESSOR, useExisting: SelectValueAccessor, multi: true }, 309 | { provide: NG_VALIDATORS, useExisting: SelectValidator, multi: true } 310 | ] 311 | }) 312 | export class SelectTags implements OnInit { 313 | 314 | // ------------------------------------------------------------------------- 315 | // Inputs 316 | // ------------------------------------------------------------------------- 317 | 318 | @Input() 319 | name: string; 320 | 321 | @Input() 322 | debounceTime = 500; 323 | 324 | @Input() 325 | minQueryLength = 2; 326 | 327 | @Input() 328 | persist: boolean = false; 329 | 330 | @Input() 331 | listTrackBy: string|((item: any) => string); 332 | 333 | @Input() 334 | listLabelBy: string|((item: any) => string); 335 | 336 | @Input() 337 | labelBy: string|((item: any) => string); 338 | 339 | @Input() 340 | disableBy: string|((item: any) => string); 341 | 342 | @Input() 343 | orderBy: string|((item1: any, item2: any) => number); 344 | 345 | @Input() 346 | orderDirection: "asc"|"desc"; 347 | 348 | @Input() 349 | readonly: boolean = false; 350 | 351 | @Input() 352 | disabled: boolean = false; 353 | 354 | @Input() 355 | limit: number; 356 | 357 | @Input() 358 | maxModelSize: number; 359 | 360 | @Input() 361 | loader: (term: string) => Observable; 362 | 363 | @Input() 364 | items: any[] = []; 365 | 366 | @Input() 367 | itemConstructor: ((term: string) => any); 368 | 369 | @Input() 370 | nonUniqueTermLabel: string = "item with such name already exist"; 371 | 372 | @Input() 373 | addButtonLabel: string = "add"; 374 | 375 | @Input() 376 | addButtonSecondaryLabel: string = "(or press enter)"; 377 | 378 | @Input() 379 | removeButton: boolean = true; 380 | 381 | @Input() 382 | removeByKey: boolean = true; 383 | 384 | @Input() 385 | unique: boolean = false; 386 | 387 | @Input() 388 | selectAllLabel: string; 389 | 390 | /** 391 | * Additional filter to filter items displayed in the dropdown. 392 | */ 393 | @Input() 394 | filter: (items: any[]) => any[]; 395 | 396 | // ------------------------------------------------------------------------- 397 | // Input accessors 398 | // ------------------------------------------------------------------------- 399 | 400 | @Input() 401 | set placeholder(placeholder: string) { 402 | this._placeholder = placeholder; 403 | if (!this.term) 404 | this.recalculateInputWidth(undefined, placeholder); 405 | } 406 | 407 | get placeholder() { 408 | return this._placeholder; 409 | } 410 | 411 | @Input() 412 | set valueBy(valueBy: string|((item: any) => string)) { 413 | this.valueAccessor.valueBy = valueBy; 414 | } 415 | 416 | get valueBy() { 417 | return this.valueAccessor.valueBy; 418 | } 419 | 420 | @Input() 421 | set trackBy(trackBy: string|((item: any) => string)) { 422 | this.valueAccessor.trackBy = trackBy; 423 | } 424 | 425 | get trackBy() { 426 | return this.valueAccessor.trackBy; 427 | } 428 | 429 | @Input() 430 | set required(required: boolean) { 431 | this.validator.options.required = required; 432 | } 433 | 434 | get required() { 435 | return this.validator.options.required; 436 | } 437 | 438 | @Input() 439 | set minModelSize(minModelSize: number) { 440 | this.validator.options.minModelSize = minModelSize; 441 | } 442 | 443 | get minModelSize() { 444 | return this.validator.options.minModelSize; 445 | } 446 | 447 | // ------------------------------------------------------------------------- 448 | // Public Properties 449 | // ------------------------------------------------------------------------- 450 | 451 | @ViewChild("termControl") 452 | termControl: AbstractControl; 453 | 454 | term: string = ""; 455 | lastLoadTerm: string = ""; 456 | selectedItems: any[] = []; 457 | 458 | @ViewChild("dropdownSelectItems") 459 | dropdownSelectItems: SelectItems; 460 | 461 | @ViewChild("selectTagsBoxInput") 462 | selectTagsBoxInput: ElementRef; 463 | 464 | @ViewChild("tagSelectItems") 465 | tagSelectItems: SelectItems; 466 | 467 | @ViewChild("selectTagsBox") 468 | selectTagsBox: ElementRef; 469 | 470 | @ContentChild(SelectTagsDropdownTemplate) 471 | selectDropdownTemplate: SelectTagsDropdownTemplate; 472 | 473 | @ContentChild(SelectTagsBoxTemplate) 474 | selectTagsTemplate: SelectTagsBoxTemplate; 475 | 476 | selectItemsToggleLogic = (options: { event: MouseEvent, valueAccessor: SelectValueAccessor, value: any }) => { 477 | if (options.event.metaKey || options.event.shiftKey || options.event.ctrlKey) { 478 | options.valueAccessor.addOrRemove(options.value); 479 | } else { 480 | options.valueAccessor.clear(); 481 | options.valueAccessor.add(options.value); 482 | } 483 | }; 484 | 485 | // ------------------------------------------------------------------------- 486 | // Private Properties 487 | // ------------------------------------------------------------------------- 488 | 489 | private _placeholder: string = ""; 490 | private cursorPosition: number = 0; 491 | private originalModel = false; 492 | private initialized: boolean = false; 493 | private itemsAreLoaded: boolean = false; 494 | private loadDenounce: Function; 495 | 496 | // ------------------------------------------------------------------------- 497 | // Constructor 498 | // ------------------------------------------------------------------------- 499 | 500 | constructor(private widthCalculator: WidthCalculator, 501 | public valueAccessor: SelectValueAccessor, 502 | private validator: SelectValidator, 503 | private utils: Utils) { 504 | this.valueAccessor.modelWrites.subscribe((model: any) => { 505 | if (model) 506 | this.originalModel = true; 507 | if (this.initialized) { 508 | this.cursorPosition = model.length; 509 | this.recalculateInputWidth(undefined, this.term); 510 | } 511 | }); 512 | } 513 | 514 | // ------------------------------------------------------------------------- 515 | // Lifecycle callbacks 516 | // ------------------------------------------------------------------------- 517 | 518 | ngOnInit() { 519 | this.initialized = true; 520 | 521 | if (this.valueAccessor.model) { 522 | this.cursorPosition = this.valueAccessor.model.length; 523 | } 524 | 525 | this.loadDenounce = this.utils.debounce(() => { 526 | if (!this.originalModel && typeof this.term === "string" && this.term.trim().length >= this.minQueryLength) { 527 | this.load(); 528 | } 529 | }, this.debounceTime); 530 | } 531 | 532 | // ------------------------------------------------------------------------- 533 | // Public Methods 534 | // ------------------------------------------------------------------------- 535 | 536 | onTermChange(term: string) { 537 | this.originalModel = false; 538 | this.dropdownSelectItems.resetActive(); 539 | this.loadDenounce(); 540 | } 541 | 542 | /** 543 | * Load items using loader. 544 | */ 545 | load(): Subscription { 546 | if (this.readonly || !this.loader || this.originalModel || !this.term || this.term.length < this.minQueryLength || this.term === this.lastLoadTerm) 547 | return; 548 | 549 | return this 550 | .loader(this.term) 551 | .subscribe(items => { 552 | this.lastLoadTerm = this.term; 553 | this.items = items; 554 | this.itemsAreLoaded = true; 555 | }); 556 | } 557 | 558 | /** 559 | * On model change outside. 560 | */ 561 | onModelChange(model: any[]) { 562 | this.cursorPosition = model.length; 563 | this.valueAccessor.set(model); 564 | this.lastLoadTerm = ""; 565 | this.term = ""; 566 | this.recalculateInputWidth(undefined, this.term); 567 | this.focusTagsInput(); 568 | if (this.itemsAreLoaded) 569 | this.items = []; 570 | } 571 | 572 | onItemsChange(model: any[]) { 573 | this.cursorPosition = model.length; 574 | this.valueAccessor.set(model); 575 | } 576 | 577 | /** 578 | * Adds new term to the tags input. 579 | */ 580 | addTerm() { 581 | const term = this.term ? this.term.trim() : ""; 582 | if (!term || !this.persist) return; 583 | if (this.dropdownSelectItems.hasActive()) return; // if dropdown has active then we select it, instead of adding a new term 584 | if (!this.isTermUnique()) return; 585 | 586 | const newModel = this.itemConstructor ? this.itemConstructor(term) : { [this.labelBy as string]: term }; 587 | this.valueAccessor.addAt(newModel, this.cursorPosition); 588 | this.cursorPosition++; 589 | setTimeout(() => this.move()); // using timeout is monkey patch 590 | this.lastLoadTerm = ""; 591 | this.term = ""; 592 | if (this.itemsAreLoaded) 593 | this.items = []; 594 | this.recalculateInputWidth(undefined, term); 595 | } 596 | 597 | isTermUnique() { 598 | if (!this.term || !this.unique || !this.valueAccessor.model) return true; 599 | return !(this.valueAccessor.model as any[]).find(i => { 600 | return this.getItemLabel(i) === this.term; 601 | }); 602 | } 603 | 604 | /** 605 | * Checks if this component is disabled. 606 | */ 607 | isDisabled() { 608 | if (this.valueAccessor.model && 609 | this.maxModelSize > 0 && 610 | this.valueAccessor.model.length >= this.maxModelSize) 611 | return true; 612 | if (this.readonly) 613 | return true; 614 | 615 | return this.disabled; 616 | } 617 | 618 | /** 619 | * Gets item's label. 620 | */ 621 | getItemLabel(item: any) { // todo: duplication 622 | if (!item) return ""; 623 | const labelBy = this.valueBy ? this.listLabelBy : (this.listLabelBy || this.labelBy); 624 | 625 | if (labelBy) { 626 | if (typeof labelBy === "string") { 627 | return item[labelBy as string]; 628 | 629 | } else if (typeof labelBy === "function") { 630 | return (labelBy as any)(item); 631 | } 632 | } 633 | 634 | return item; 635 | } 636 | 637 | /** 638 | * Recalculates input width. Input width always match input's contents. 639 | */ 640 | recalculateInputWidth(event?: Event, value?: any) { 641 | this.widthCalculator.recalculateInputWidth(this.selectTagsBoxInput.nativeElement, event, value); 642 | } 643 | 644 | /** 645 | * When keydown in the tags input occurs we need to recalculate width of input. 646 | * Also we handle to some of them specifically: 647 | * - left, right to move input to the left or right 648 | * - backspace and delete to remove previous or next tag box 649 | */ 650 | onInputKeydown(event: KeyboardEvent) { 651 | this.recalculateInputWidth(event); 652 | 653 | // actions with tags can be done when focus inside, but no text in the input box 654 | if (!this.term) { 655 | if (event.keyCode === 37) { // left 656 | this.moveLeft(); 657 | event.preventDefault(); 658 | 659 | } else if (event.keyCode === 39) { // right 660 | this.moveRight(); 661 | event.preventDefault(); 662 | 663 | } else if (event.keyCode === 8 && this.removeByKey) { // backspace 664 | this.valueAccessor.removeAt(this.cursorPosition - 1); 665 | setTimeout(() => this.moveLeft()); // using timeout is monkey patch 666 | 667 | } else if (event.keyCode === 46 && this.removeByKey) { // delete 668 | this.valueAccessor.removeAt(this.cursorPosition); 669 | setTimeout(() => this.move()); // using timeout is monkey patch 670 | 671 | } 672 | } 673 | } 674 | 675 | /** 676 | * Moves input to the left. 677 | */ 678 | moveLeft() { 679 | if (this.cursorPosition === 0) return; 680 | --this.cursorPosition; 681 | this.move(); 682 | } 683 | 684 | /** 685 | * Moves input to the right. 686 | */ 687 | moveRight() { 688 | if (this.cursorPosition >= this.tagSelectItems.itemElements.toArray().length) return; 689 | ++this.cursorPosition; 690 | this.move(); 691 | } 692 | 693 | /** 694 | * Sets the focus to tags input. Selected tag boxes should be unselected after this operation. 695 | */ 696 | focusTagsInput() { 697 | this.selectedItems = []; 698 | this.selectTagsBoxInput.nativeElement.focus(); 699 | } 700 | 701 | /** 702 | * When user keydowns on select-tags-input we need to capture backspace, delete, esc and Ctrl+A to make 703 | * operations with tag boxes. 704 | */ 705 | onSelectTagsBoxKeydown(event: KeyboardEvent) { 706 | /*if (this.term) { 707 | this.focusTagsInput(); 708 | return; 709 | }*/ 710 | 711 | if (!this.term && (event.keyCode === 46 || event.keyCode === 8) && this.removeByKey && this.selectedItems.length) { // backspace or delete 712 | this.valueAccessor.removeMany(this.selectedItems); 713 | this.cursorPosition = this.valueAccessor.model.length; 714 | this.selectedItems = []; 715 | event.preventDefault(); 716 | event.stopPropagation(); 717 | this.focusTagsInput(); 718 | 719 | } else if (!this.term && event.keyCode === 27) { // esc 720 | this.selectedItems = []; 721 | 722 | } else if (!this.term && event.keyCode === 65 && (event.metaKey || event.ctrlKey)) { // ctrl + A 723 | this.selectedItems = this.valueAccessor.model.map((i: any) => i); 724 | event.preventDefault(); 725 | event.stopPropagation(); 726 | 727 | } else if (event.keyCode === 38) { // top 728 | this.dropdownSelectItems.previousActive(); 729 | event.preventDefault(); 730 | event.stopPropagation(); 731 | 732 | } else if (event.keyCode === 40) { // bottom 733 | this.dropdownSelectItems.nextActive(); 734 | event.preventDefault(); 735 | event.stopPropagation(); 736 | 737 | } else if (event.keyCode === 13) { // enter 738 | this.dropdownSelectItems.selectActive(); 739 | } 740 | 741 | } 742 | 743 | /** 744 | * When user clicks item in boxes a tag we need to stop the event propagation, because we don't want out input 745 | * to get a focus at this time. 746 | */ 747 | onTagSelect(event: { event: MouseEvent }) { 748 | event.event.preventDefault(); 749 | event.event.stopPropagation(); 750 | } 751 | 752 | /** 753 | * Exposes items in the dropdown, so they can be customized. 754 | */ 755 | get dropdownItems() { 756 | return this.items; 757 | } 758 | 759 | /** 760 | * Exposes items in the tags box, so they can be customized. 761 | */ 762 | get tagsItems() { 763 | return this.valueAccessor.model; 764 | } 765 | 766 | // ------------------------------------------------------------------------- 767 | // Private Methods 768 | // ------------------------------------------------------------------------- 769 | 770 | /** 771 | * Moves input box into cursorPosition. 772 | */ 773 | private move() { 774 | const items = this.tagSelectItems.itemElements.toArray(); 775 | const input = this.selectTagsBoxInput.nativeElement; 776 | if (items[this.cursorPosition]) { 777 | const element = items[this.cursorPosition].nativeElement; 778 | element.parentElement.insertBefore(input, element); 779 | } else { 780 | if (this.cursorPosition === 0 || !items[this.cursorPosition - 1]) 781 | return; 782 | 783 | const element = items[this.cursorPosition - 1].nativeElement; 784 | element.parentElement.insertBefore(input, element.nextSibling); 785 | } 786 | this.selectTagsBoxInput.nativeElement.focus(); 787 | } 788 | 789 | } -------------------------------------------------------------------------------- /src/SelectValidator.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from "@angular/core"; 2 | import {Validator, AbstractControl} from "@angular/forms"; 3 | 4 | @Injectable() 5 | export class SelectValidator implements Validator { 6 | 7 | // ------------------------------------------------------------------------- 8 | // Public Properties 9 | // ------------------------------------------------------------------------- 10 | 11 | options: { 12 | required?: boolean; 13 | minModelSize?: number; 14 | } = {}; 15 | 16 | // ------------------------------------------------------------------------- 17 | // Implemented from Validator 18 | // ------------------------------------------------------------------------- 19 | 20 | validate(c: AbstractControl) { 21 | if (!this.options) return null; 22 | 23 | const errors: any = {}; 24 | if (this.options.required && (!c.value || (c.value instanceof Array) && c.value.length === 0)) 25 | errors.required = true; 26 | if (this.options.minModelSize && (!c.value || (c.value instanceof Array) && c.value.length < this.options.minModelSize)) 27 | errors.minModelSize = true; 28 | 29 | return Object.keys(errors).length > 0 ? errors : null; 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/SelectValueAccessor.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, EventEmitter} from "@angular/core"; 2 | import {ControlValueAccessor} from "@angular/forms"; 3 | 4 | @Injectable() 5 | export class SelectValueAccessor implements ControlValueAccessor { 6 | 7 | // ------------------------------------------------------------------------- 8 | // Public Properties 9 | // ------------------------------------------------------------------------- 10 | 11 | modelWrites = new EventEmitter(); 12 | trackBy: string|((item: any) => string); 13 | valueBy: string|((item: any) => string); 14 | 15 | // ------------------------------------------------------------------------- 16 | // Private Properties 17 | // ------------------------------------------------------------------------- 18 | 19 | private _model: any; 20 | private onChange: (m: any) => void; 21 | private onTouched: (m: any) => void; 22 | 23 | // ------------------------------------------------------------------------- 24 | // Implemented from ControlValueAccessor 25 | // ------------------------------------------------------------------------- 26 | 27 | writeValue(value: any): void { 28 | this._model = value; 29 | this.modelWrites.emit(value); 30 | } 31 | 32 | registerOnChange(fn: any): void { 33 | this.onChange = fn; 34 | } 35 | 36 | registerOnTouched(fn: any): void { 37 | this.onTouched = fn; 38 | } 39 | 40 | // ------------------------------------------------------------------------- 41 | // Accessors 42 | // ------------------------------------------------------------------------- 43 | 44 | get model() { 45 | return this._model; 46 | } 47 | 48 | // ------------------------------------------------------------------------- 49 | // Public Methods 50 | // ------------------------------------------------------------------------- 51 | 52 | set(value: any) { 53 | this._model = value; 54 | this.onChange(this._model); 55 | } 56 | 57 | add(value: any) { 58 | if (!this.has(value)) { 59 | if (this._model instanceof Array) { 60 | this._model.push(value); 61 | } else { 62 | this._model = [value]; 63 | } 64 | this.onChange(this._model); 65 | } 66 | } 67 | 68 | remove(value: any) { 69 | // value = this.extractModelValue(value); 70 | if (this.trackBy) { 71 | const item = this._model.find((i: any) => { 72 | return this.extractValue(i, this.trackBy) === this.extractValue(value, this.trackBy); 73 | }); 74 | this.removeAt(this._model.indexOf(item)); 75 | } else { 76 | const item = this._model.find((i: any) => { 77 | return i === value; 78 | }); 79 | this.removeAt(this._model.indexOf(item)); 80 | } 81 | } 82 | 83 | removeAt(index: number): boolean { 84 | if (!this._model || index < 0 || (index > this._model.length - 1)) 85 | return false; 86 | 87 | this._model.splice(index, 1); 88 | this.onChange(this._model); 89 | } 90 | 91 | clear() { 92 | if (this._model instanceof Array) { 93 | this._model.splice(0, this._model.length); 94 | } else { 95 | this._model = undefined; 96 | } 97 | } 98 | 99 | addAt(value: any, index: number): boolean { 100 | if (!this._model || index < 0) 101 | return false; 102 | 103 | this._model.splice(index, 0, value); 104 | this.onChange(this._model); 105 | } 106 | 107 | addOrRemove(value: any) { 108 | if (this.has(value)) { 109 | this.remove(value); 110 | } else { 111 | this.add(value); 112 | } 113 | } 114 | 115 | has(value: any): boolean { 116 | // value = this.extractModelValue(value); 117 | if (this._model instanceof Array) { 118 | if (this.trackBy) { 119 | return !!this._model.find((i: any) => { 120 | return this.extractValue(i, this.trackBy) === this.extractValue(value, this.trackBy); 121 | }); 122 | } else { 123 | return !!this._model.find((i: any) => { 124 | return i === value; 125 | }); 126 | } 127 | 128 | } else if (this._model !== null && this._model !== undefined) { 129 | if (this.trackBy) { 130 | return this.extractValue(this._model, this.trackBy) === this.extractValue(value, this.trackBy); 131 | } else { 132 | return this._model === value; 133 | } 134 | } 135 | 136 | return false; 137 | } 138 | 139 | addMany(values: any[]): void { 140 | if (!values || !values.length) return; 141 | values.forEach(value => this.add(value)); 142 | } 143 | 144 | removeMany(values: any[]): void { 145 | if (!values || !values.length) return; 146 | values.forEach(value => this.remove(value)); 147 | } 148 | 149 | hasMany(values: any[]): boolean { 150 | if (!values || !values.length) return false; 151 | 152 | let has = true; 153 | values.forEach(item => { 154 | if (has) 155 | has = this.has(item.value); 156 | }); 157 | return has; 158 | } 159 | 160 | private extractModelValue(model: any) { 161 | if (this.valueBy) { 162 | return this.extractValue(model, this.valueBy); 163 | } else { 164 | return model; 165 | } 166 | } 167 | 168 | private extractValue(model: any, value: string|((item: any) => string)) { 169 | if (value instanceof Function) { 170 | return (value as (item: any) => any)(model); 171 | } else { 172 | return model[value as string]; 173 | } 174 | } 175 | 176 | } -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from "@angular/core"; 2 | 3 | @Injectable() 4 | export class Utils { 5 | 6 | debounce(func: Function, wait: number, immediate: boolean = false) { 7 | let timeout: any; 8 | return function() { 9 | let context = this, args = arguments; 10 | let later = function() { 11 | timeout = null; 12 | if (!immediate) func.apply(context, args); 13 | }; 14 | let callNow = immediate && !timeout; 15 | clearTimeout(timeout); 16 | timeout = setTimeout(later, wait); 17 | if (callNow) func.apply(context, args); 18 | }; 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /src/WidthCalculator.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from "@angular/core"; 2 | 3 | @Injectable() 4 | export class WidthCalculator { 5 | 6 | /** 7 | * Recalculates input width when input's value changes. 8 | * 9 | * @param {HTMLInputElement} input 10 | * @param {Event} event 11 | */ 12 | recalculateInputWidth(input: HTMLInputElement, event?: any, value?: any) { 13 | if (event && (event.metaKey || event.altKey || !event.target)) return; 14 | 15 | value = value !== undefined ? value : input.value; 16 | if (event && event.type && event.type.toLowerCase() === "keydown") 17 | value = this.processValueAfterPressedKey(value, this.getSelection(input), event.keyCode, event.shiftKey); 18 | 19 | // if there is NO value in the input, it means that in the input there can be 20 | // a placeholder value. and if placeholder is not empty then use its value to measure 21 | let placeholder = input.getAttribute("placeholder") || ""; 22 | if (!value.length && placeholder.length > 0) { 23 | value = placeholder; 24 | } 25 | 26 | // finally measure input value's width and update input's width 27 | let measureContainer = this.createInputStringMeasureContainer(input); 28 | let width = this.measureString(value, measureContainer) + 10; 29 | input.style.setProperty("width", width + "px"); 30 | } 31 | 32 | /** 33 | * Creates and returns a container in the DOM where string can be stored to measure its length. 34 | * 35 | * @param {HTMLInputElement} parentElement Element (input box) from where properties will be copied to match 36 | * the styles (font-size, font-family, etc.) to make a proper string measurement 37 | * @returns {object} Created element where string can be stored to measure string length 38 | */ 39 | createInputStringMeasureContainer(parentElement: HTMLInputElement) { 40 | let body: any = document.querySelector("body"); 41 | let stringMeasureElement = document.getElementById("tokenInputStringMeasure"); 42 | if (!stringMeasureElement) { 43 | stringMeasureElement = document.createElement("div"); 44 | stringMeasureElement.id = "tokenInputStringMeasure"; 45 | const styles: any = { 46 | position: "absolute", 47 | top: "-99999px", 48 | left: "-99999px", 49 | width: "auto", 50 | padding: 0, 51 | whiteSpace: "pre" 52 | }; 53 | Object.keys(styles).forEach(key => { 54 | stringMeasureElement.style.setProperty(key, styles[key]); 55 | }); 56 | body.appendChild(stringMeasureElement); 57 | } 58 | 59 | this.transferStyles(parentElement, stringMeasureElement, [ 60 | "letterSpacing", 61 | "fontSize", 62 | "fontFamily", 63 | "fontWeight", 64 | "textTransform" 65 | ]); 66 | 67 | return stringMeasureElement; 68 | } 69 | 70 | /** 71 | * Copies CSS properties from one element to another. 72 | * 73 | * @param {HTMLElement} from 74 | * @param {HTMLElement} to 75 | * @param {string[]|Array} properties 76 | */ 77 | transferStyles(from: HTMLElement, to: HTMLElement, properties: string[]) { 78 | let i: number, n: number, styles: any = {}; 79 | if (properties) { 80 | for (i = 0, n = properties.length; i < n; i++) { 81 | styles[properties[i]] = this.css(from/*[0]*/, properties[i]); 82 | } 83 | } else { 84 | const fromStyles: any = from.style; 85 | Object.keys(fromStyles).filter(styleName => fromStyles[styleName] !== "").forEach(styleName => { 86 | styles[styleName] = fromStyles[styleName]; 87 | }); 88 | } 89 | Object.keys(styles).forEach(key => { 90 | (to as any).style[key] = styles[key]; 91 | }); 92 | } 93 | 94 | /** 95 | * Gets the proper value of the given CSS property. 96 | * 97 | * @param {HTMLElement} element 98 | * @param {string} name 99 | * @returns {string|undefined} 100 | */ 101 | css(element: any, name: string) { 102 | let val: string; 103 | if (typeof element.currentStyle !== "undefined") { // for old IE 104 | val = element.currentStyle[name]; 105 | } else if (typeof window.getComputedStyle !== "undefined") { // for modern browsers 106 | val = element.ownerDocument.defaultView.getComputedStyle(element, null)[name]; 107 | } else { 108 | val = element.style[name]; 109 | } 110 | return (val === "") ? undefined : val; 111 | } 112 | 113 | /** 114 | * Measures the width of a string within a parent element (in pixels). 115 | * 116 | * @param {string} str String to be measured 117 | * @param {HTMLElement} measureContainer html element 118 | * @returns {int} 119 | */ 120 | measureString(str: string, measureContainer: HTMLElement) { 121 | str = str.replace(new RegExp(" ", "g"), "_"); // replace spaces 122 | const textNode = document.createTextNode(str); 123 | measureContainer.appendChild(textNode); 124 | let width = measureContainer.offsetWidth; 125 | measureContainer.innerHTML = ""; 126 | return width; 127 | } 128 | 129 | /** 130 | * Determines the current selection within a text input control. 131 | * Returns an object containing: 132 | * - start Where selection started 133 | * - length How many characters were selected 134 | * 135 | * @param {object} inputElement 136 | * @returns {{start: int, length: int}} 137 | */ 138 | getSelection(inputElement: any) { 139 | let selection = { start: 0, length: 0 }; 140 | 141 | if ("selectionStart" in inputElement) { 142 | selection.start = inputElement.selectionStart; 143 | selection.length = inputElement.selectionEnd - inputElement.selectionStart; 144 | 145 | } else if ((document as any).selection) { 146 | inputElement.focus(); 147 | let sel = (document as any).selection.createRange(); 148 | let selLen = (document as any).selection.createRange().text.length; 149 | sel.moveStart("character", inputElement.value.length * -1); 150 | selection.start = sel.text.length - selLen; 151 | selection.length = selLen; 152 | } 153 | 154 | return selection; 155 | } 156 | 157 | /** 158 | * Removes value based on the cursor position. If there is something selected then 159 | * this selected text will be removed, otherwise if no selection, but BACKSPACE key 160 | * has been pressed, then previous character will be removed, or if DELETE key has 161 | * been pressed when next character will be removed. 162 | * 163 | * @param {string} value The input value 164 | * @param {object} selection Current selection in the input 165 | * @param {int} pressedKeyCode Key that was pressed by a user 166 | * @returns {string} 167 | */ 168 | removeValueByCursorPosition(value: string, selection: any, pressedKeyCode: number) { 169 | 170 | if (selection.length) { 171 | return value.substring(0, selection.start) + value.substring(selection.start + selection.length); 172 | 173 | } else if (pressedKeyCode === 8 /* "BACKSPACE" */ && selection.start) { 174 | return value.substring(0, selection.start - 1) + value.substring(selection.start + 1); 175 | 176 | } else if (pressedKeyCode === 46 /* "DELETE" */ && typeof selection.start !== "undefined") { 177 | return value.substring(0, selection.start) + value.substring(selection.start + 1); 178 | } 179 | 180 | return value; 181 | } 182 | 183 | /** 184 | * Checks if given key code is a-z, or A-Z, or 1-9 or space. 185 | * 186 | * @param {number} keyCode 187 | * @returns {boolean} True if key code in the [a-zA-Z0-9 ] range or not 188 | */ 189 | isPrintableKey(keyCode: number) { 190 | return ((keyCode >= 97 && keyCode <= 122) || // a-z 191 | (keyCode >= 65 && keyCode <= 90) || // A-Z 192 | (keyCode >= 48 && keyCode <= 57) || // 0-9 193 | keyCode === 32 // space 194 | ); 195 | } 196 | 197 | /** 198 | * Checks if given key code is "removing key" (e.g. backspace or delete). 199 | * 200 | * @param {number} keyCode 201 | * @returns {boolean} 202 | */ 203 | isRemovingKey(keyCode: number) { 204 | return keyCode === 46 || keyCode === 8; // "DELETE" or "BACKSPACE" 205 | } 206 | 207 | /** 208 | * Processes a value after some key has been pressed by a user. 209 | * 210 | * @param {string} value 211 | * @param {{ start: number, length: number }} selection Position where user selected a text 212 | * @param {number} keyCode The code of the key that has been pressed 213 | * @param {boolean} shiftKey Indicates if shift key has been pressed or not 214 | * @returns {string} 215 | */ 216 | processValueAfterPressedKey(value: string, selection: { start: number, length: number }, keyCode: number, shiftKey: boolean) { 217 | 218 | if (this.isRemovingKey(keyCode)) 219 | return this.removeValueByCursorPosition(value, selection, keyCode); 220 | 221 | if (this.isPrintableKey(keyCode)) { 222 | let character = String.fromCharCode(keyCode); 223 | character = shiftKey ? character.toUpperCase() : character.toLowerCase(); 224 | return value + character; 225 | } 226 | 227 | return value; 228 | } 229 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CheckboxGroup"; 2 | export * from "./Checkbox"; 3 | export * from "./CheckboxItem"; 4 | export * from "./RadioGroup"; 5 | export * from "./RadioBox"; 6 | export * from "./RadioItem"; 7 | export * from "./SelectItems"; 8 | export * from "./Autocomplete"; 9 | export * from "./SelectDropdown"; 10 | export * from "./SelectTags"; 11 | export * from "./ItemTemplate"; 12 | 13 | import {Checkbox} from "./Checkbox"; 14 | import {CheckboxItem} from "./CheckboxItem"; 15 | import {CheckboxGroup} from "./CheckboxGroup"; 16 | import {RadioBox} from "./RadioBox"; 17 | import {RadioItem} from "./RadioItem"; 18 | import {RadioGroup} from "./RadioGroup"; 19 | import {SelectItems} from "./SelectItems"; 20 | import {Autocomplete, AutocompleteDropdownTemplate} from "./Autocomplete"; 21 | import {SelectDropdown} from "./SelectDropdown"; 22 | import {SelectTags, SelectTagsDropdownTemplate, SelectTagsBoxTemplate} from "./SelectTags"; 23 | import {ItemTemplate} from "./ItemTemplate"; 24 | 25 | export const SELECT_DIRECTIVES: [any] = [ 26 | CheckboxGroup, 27 | CheckboxItem, 28 | RadioGroup, 29 | RadioItem, 30 | RadioBox, 31 | Checkbox, 32 | SelectItems, 33 | Autocomplete, 34 | SelectDropdown, 35 | SelectTags, 36 | ItemTemplate, 37 | SelectTagsDropdownTemplate, 38 | SelectTagsBoxTemplate, 39 | AutocompleteDropdownTemplate 40 | ]; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.8.0", 3 | "compilerOptions": { 4 | "outDir": "build/es5", 5 | "target": "es5", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "noImplicitAny": true, 12 | "declaration": true 13 | }, 14 | "exclude": [ 15 | "build", 16 | "node_modules", 17 | "typings/browser.d.ts", 18 | "typings/browser" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "no-duplicate-variable": true, 13 | "no-eval": true, 14 | "no-internal-module": true, 15 | "no-var-keyword": true, 16 | "one-line": [ 17 | true, 18 | "check-open-brace", 19 | "check-whitespace" 20 | ], 21 | "quotemark": [ 22 | true, 23 | "double" 24 | ], 25 | "semicolon": true, 26 | "triple-equals": [ 27 | true, 28 | "allow-null-check" 29 | ], 30 | "typedef-whitespace": [ 31 | true, 32 | { 33 | "call-signature": "nospace", 34 | "index-signature": "nospace", 35 | "parameter": "nospace", 36 | "property-declaration": "nospace", 37 | "variable-declaration": "nospace" 38 | } 39 | ], 40 | "variable-name": [ 41 | true, 42 | "ban-keywords" 43 | ], 44 | "whitespace": [ 45 | true, 46 | "check-branch", 47 | "check-decl", 48 | "check-operator", 49 | "check-separator", 50 | "check-type" 51 | ] 52 | } 53 | } -------------------------------------------------------------------------------- /typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ambientDevDependencies": { 3 | "assertion-error": "github:DefinitelyTyped/DefinitelyTyped/assertion-error/assertion-error.d.ts#7a3ca1f0b8a0960af9fc1838f3234cc9d6ce0645", 4 | "chai": "github:DefinitelyTyped/DefinitelyTyped/chai/chai.d.ts#7a3ca1f0b8a0960af9fc1838f3234cc9d6ce0645", 5 | "mocha": "github:DefinitelyTyped/DefinitelyTyped/mocha/mocha.d.ts#7a3ca1f0b8a0960af9fc1838f3234cc9d6ce0645", 6 | "node": "registry:dt/node#4.0.0+20160505172921", 7 | "sinon": "github:DefinitelyTyped/DefinitelyTyped/sinon/sinon.d.ts#7a3ca1f0b8a0960af9fc1838f3234cc9d6ce0645" 8 | }, 9 | "ambientDependencies": { 10 | "es6-shim": "github:DefinitelyTyped/DefinitelyTyped/es6-shim/es6-shim.d.ts#6697d6f7dadbf5773cb40ecda35a76027e0783b2" 11 | } 12 | } 13 | --------------------------------------------------------------------------------