├── .editorconfig ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── README.md ├── config └── webpack.dev.js ├── demo ├── app.component.html ├── app.component.ts ├── app.module.ts ├── index.html ├── main.ts └── polyfills.ts ├── index.ts ├── lib ├── components │ ├── tag-input-autocomplete │ │ └── tag-input-autocomplete.component.ts │ ├── tag-input-item │ │ └── tag-input-item.component.ts │ └── tag-input │ │ └── tag-input.component.ts ├── shared │ └── tag-input-keys.ts └── tag-input.module.ts ├── package.json ├── tsconfig-aot.json ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = 0 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | typings 3 | dist 4 | lib/**/*.css 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | typings 2 | node_modules 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.2.0 4 | - Made library Ahead-of-Time (AoT) ready, closes [#15](https://github.com/rosslavery/angular2-tag-input/issues/15) 5 | - Added missing rxjs imports, closes [#13](https://github.com/rosslavery/angular2-tag-input/issues/13) 6 | 7 | ## 1.1.0 8 | - Added autocomplete feature 9 | - Greatly-improved styling to adhere to Material spec 10 | - Added numerous new inputs/outputs (see API docs) 11 | 12 | ## 1.0.0 13 | ### Breaking Changes 14 | - Removed delimiterCode @Input. While some flexibility is lost, code is more reliable with a limited number of split patterns 15 | - Updated dependencies to match latest RC6 16 | - Exporting an NgModule instead of a component, so installation instructions have changed 17 | - Namespaced selector to be `rl-tag-input` to protect against conflicts 18 | 19 | ### Other Changes 20 | - New @Input() addOnComma 21 | - New @Input() pasteSplitPattern - defaults to comma 22 | - Fixed input not being emptied out on paste 23 | 24 | 25 | ## 0.1.5 26 | - First version published 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # angular2-tag-input 2 | Tag input component for Angular 2 3 | 4 | ## Demo & Examples 5 | [View Demo](http://www.webpackbin.com/EkDO0p3Ab) 6 | 7 | ## Quick Start 8 | ``` 9 | npm install angular2-tag-input --save 10 | ``` 11 | 12 | ``` 13 | // In one of your application NgModules 14 | import {RlTagInputModule} from 'angular2-tag-input'; 15 | 16 | @NgModule({ 17 | imports: [ 18 | RlTagInputModule 19 | ] 20 | }) 21 | export class YourModule {} 22 | 23 | // In one of your component templates 24 | 25 | ``` 26 | 27 | ## API 28 | ### Inputs 29 | | Name | Type | Default | Description | 30 | | --- | --- | --- | --- | 31 | | `addOnBlur` | `boolean` | true | Whether to attempt to add a tag when the input loses focus. | 32 | | `addOnComma` | `boolean` | true | Whether to attempt to add a tag when the user presses comma. | 33 | | `addOnEnter` | `boolean` | true | Whether to attempt to add a tag when the user presses enter. | 34 | | `addOnPaste` | `boolean` | true | Whether to attempt to add a tags when the user pastes their clipboard contents. | 35 | | `addOnSpace` | `boolean` | true | Whether to attempt to add a tags when the user presses space. | 36 | | `allowDuplicates` | `boolean` | `false` | Allow duplicate tags. | 37 | | `allowedTagsPattern` | `RegExp` | `/.+/` | RegExp that must match for a tag to be added. | 38 | | `autocomplete` | `boolean` | `false` | Toggle autocomplete mode on/off | 39 | | `autocompleteItems` | `string[]` | `[]` | List of suggestions for autocomplete menu | 40 | | `autocompleteMustMatch` | `boolean` | `true` | Whether a tag must be present in the suggestions list to be valid | 41 | | `autocompleteSelectFirstItem` | `boolean` | `true` | Pre-highlight the first item in the suggestions list | 42 | | `placeholder` | `string` | `'Add a tag'` | Placeholder for the `` tag. | 43 | 44 | 45 | ### Outputs 46 | | Name | Type Emitted | Description | 47 | | --- | --- | --- | 48 | | `addTag` | `string` | Emits the added tag string | 49 | | `removeTag` | `string` | Emits the removed tag string | 50 | -------------------------------------------------------------------------------- /config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: { 6 | app: './demo/main.ts', 7 | }, 8 | output: { 9 | path: root('/demo/dist'), 10 | publicPath: '/', 11 | filename: '[name].js' 12 | }, 13 | resolve: { 14 | extensions: [ 15 | '.ts', 16 | '.js', 17 | '.json', 18 | '.css', 19 | '.scss', 20 | '.html', 21 | ] 22 | }, 23 | module: { 24 | loaders: [ 25 | { 26 | test: /\.ts$/, 27 | loader: 'awesome-typescript-loader' 28 | }, 29 | { 30 | test: /\.html$/, 31 | loader: 'raw-loader' 32 | } 33 | ] 34 | } 35 | }; 36 | 37 | function root(__path) { 38 | return path.join(__dirname, __path); 39 | } 40 | -------------------------------------------------------------------------------- /demo/app.component.html: -------------------------------------------------------------------------------- 1 |

Basic Tag Input:

2 |
3 | 6 | 7 |
8 |
9 |

Bound List:

10 |
11 |     {{tags | json}}
12 |   
13 |
14 | 15 |



16 | 17 |

AutoComplete Tag Input:

18 |

Tag must belong to autocomplete list.

19 |
20 | 26 | 27 |
28 |
29 |

Bound List:

30 |
31 | 		{{autocompleteTags | json}}
32 | 	
33 |
34 | -------------------------------------------------------------------------------- /demo/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'rl-demo-app', 5 | template: require('./app.component.html') 6 | }) 7 | export class AppComponent { 8 | public tags = ['Car', 'Bus', 'Train']; 9 | public autocompleteTags = []; 10 | public autocompleteItems = [ 11 | 'Banana', 12 | 'Orange', 13 | 'Apple', 14 | 'Pear', 15 | 'Grape', 16 | 'Potato', 17 | 'Peach' 18 | ]; 19 | } 20 | -------------------------------------------------------------------------------- /demo/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { AppComponent } from './app.component'; 5 | import { RlTagInputModule } from '../lib/tag-input.module'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | AppComponent 10 | ], 11 | imports: [ 12 | BrowserModule, 13 | FormsModule, 14 | RlTagInputModule 15 | ], 16 | bootstrap: [ 17 | AppComponent 18 | ] 19 | }) 20 | export class AppModule {} 21 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | Tag Input Demo 10 | 11 | 12 | Loading... 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/main.ts: -------------------------------------------------------------------------------- 1 | import './polyfills'; 2 | 3 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 4 | import { AppModule } from './app.module'; 5 | 6 | platformBrowserDynamic().bootstrapModule(AppModule); 7 | 8 | -------------------------------------------------------------------------------- /demo/polyfills.ts: -------------------------------------------------------------------------------- 1 | // This file includes polyfills needed by Angular 2 and is loaded before 2 | // the app. You can add your own extra polyfills to this file. 3 | import 'core-js/es6/symbol'; 4 | import 'core-js/es6/object'; 5 | import 'core-js/es6/function'; 6 | import 'core-js/es6/parse-int'; 7 | import 'core-js/es6/parse-float'; 8 | import 'core-js/es6/number'; 9 | import 'core-js/es6/math'; 10 | import 'core-js/es6/string'; 11 | import 'core-js/es6/date'; 12 | import 'core-js/es6/array'; 13 | import 'core-js/es6/regexp'; 14 | import 'core-js/es6/map'; 15 | import 'core-js/es6/set'; 16 | import 'core-js/es6/reflect'; 17 | 18 | import 'core-js/es7/reflect'; 19 | import 'zone.js/dist/zone'; 20 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/tag-input.module'; 2 | export * from './lib/components/tag-input/tag-input.component'; 3 | -------------------------------------------------------------------------------- /lib/components/tag-input-autocomplete/tag-input-autocomplete.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'; 2 | import { Observable } from 'rxjs/Observable'; 3 | import { Subscription } from 'rxjs/Subscription'; 4 | 5 | import { KEYS } from '../../shared/tag-input-keys'; 6 | 7 | @Component({ 8 | selector: 'rl-tag-input-autocomplete', 9 | template: ` 10 |
15 | {{item}} 16 |
17 | `, 18 | styles: [` 19 | :host { 20 | box-shadow: 0 1.5px 4px rgba(0, 0, 0, 0.24), 0 1.5px 6px rgba(0, 0, 0, 0.12); 21 | display: block; 22 | position: absolute; 23 | top: 100%; 24 | font-family: "Roboto", "Helvetica Neue", sans-serif; 25 | font-size: 16px; 26 | color: #444444; 27 | background: white; 28 | padding: 8px 0; 29 | } 30 | 31 | :host .rl-autocomplete-item { 32 | padding: 0 16px; 33 | height: 48px; 34 | line-height: 48px; 35 | } 36 | 37 | :host .is-selected { 38 | background: #eeeeee; 39 | } 40 | `] 41 | }) 42 | export class TagInputAutocompleteComponent implements OnChanges, OnDestroy, OnInit { 43 | @Input() items: string[]; 44 | @Input() selectFirstItem: boolean = false; 45 | @Output() itemSelected: EventEmitter = new EventEmitter(); 46 | @Output() enterPressed: EventEmitter = new EventEmitter(); 47 | public selectedItemIndex: number = null; 48 | private keySubscription: Subscription; 49 | private get itemsCount(): Number { 50 | return this.items ? this.items.length : 0; 51 | } 52 | 53 | constructor(private elementRef: ElementRef) { } 54 | 55 | ngOnInit() { 56 | this.keySubscription = Observable.fromEvent(window, 'keydown') 57 | .filter( 58 | (event: KeyboardEvent) => 59 | event.keyCode === KEYS.upArrow || 60 | event.keyCode === KEYS.downArrow || 61 | event.keyCode === KEYS.enter || 62 | event.keyCode === KEYS.esc 63 | ) 64 | .do((event: KeyboardEvent) => { 65 | switch (event.keyCode) { 66 | case KEYS.downArrow: 67 | this.handleDownArrow(); 68 | break; 69 | 70 | case KEYS.upArrow: 71 | this.handleUpArrow(); 72 | break; 73 | 74 | case KEYS.enter: 75 | this.selectItem(); 76 | this.enterPressed.emit(); 77 | break; 78 | 79 | case KEYS.esc: 80 | break; 81 | } 82 | 83 | event.stopPropagation(); 84 | event.preventDefault(); 85 | }) 86 | .subscribe(); 87 | } 88 | 89 | ensureHighlightVisible() { 90 | let container = this.elementRef.nativeElement.querySelector('.sk-select-results__container'); 91 | if (!container) { 92 | return; 93 | } 94 | let choices = container.querySelectorAll('.sk-select-results__item'); 95 | if (choices.length < 1) { 96 | return; 97 | } 98 | if (this.selectedItemIndex < 0) { 99 | return; 100 | } 101 | let highlighted: any = choices[this.selectedItemIndex]; 102 | if (!highlighted) { 103 | return; 104 | } 105 | let posY: number = highlighted.offsetTop + highlighted.clientHeight - container.scrollTop; 106 | let height: number = container.offsetHeight; 107 | 108 | if (posY > height) { 109 | container.scrollTop += posY - height; 110 | } else if (posY < highlighted.clientHeight) { 111 | container.scrollTop -= highlighted.clientHeight - posY; 112 | } 113 | } 114 | 115 | goToTop() { 116 | this.selectedItemIndex = 0; 117 | this.ensureHighlightVisible(); 118 | } 119 | 120 | goToBottom(itemsCount) { 121 | this.selectedItemIndex = itemsCount - 1; 122 | this.ensureHighlightVisible(); 123 | } 124 | 125 | goToNext() { 126 | if (this.selectedItemIndex + 1 < this.itemsCount) { 127 | this.selectedItemIndex++; 128 | } else { 129 | this.goToTop(); 130 | } 131 | this.ensureHighlightVisible(); 132 | } 133 | 134 | goToPrevious() { 135 | if (this.selectedItemIndex - 1 >= 0) { 136 | this.selectedItemIndex--; 137 | } else { 138 | this.goToBottom(this.itemsCount); 139 | } 140 | this.ensureHighlightVisible(); 141 | } 142 | 143 | handleUpArrow() { 144 | if (this.selectedItemIndex === null) { 145 | this.goToBottom(this.itemsCount); 146 | return false; 147 | } 148 | this.goToPrevious(); 149 | } 150 | 151 | handleDownArrow() { 152 | // Initialize to zero if first time results are shown 153 | if (this.selectedItemIndex === null) { 154 | this.goToTop(); 155 | return false; 156 | } 157 | this.goToNext(); 158 | } 159 | 160 | selectItem(itemIndex?: number): void { 161 | let itemToEmit = itemIndex ? this.items[itemIndex] : this.items[this.selectedItemIndex]; 162 | if (itemToEmit) { 163 | this.itemSelected.emit(itemToEmit); 164 | } 165 | } 166 | 167 | ngOnChanges(changes) { 168 | if (this.selectFirstItem && this.itemsCount > 0) { 169 | this.goToTop(); 170 | } 171 | } 172 | 173 | ngOnDestroy() { 174 | this.keySubscription.unsubscribe(); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /lib/components/tag-input-item/tag-input-item.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, HostBinding, Input, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'rl-tag-input-item', 5 | template: ` 6 | {{text}} 7 | × 10 | `, 11 | styles: [` 12 | :host { 13 | font-family: "Roboto", "Helvetica Neue", sans-serif; 14 | font-size: 16px; 15 | height: 32px; 16 | line-height: 32px; 17 | display: inline-block; 18 | background: #e0e0e0; 19 | padding: 0 12px; 20 | border-radius: 90px; 21 | margin-right: 10px; 22 | transition: all 0.12s ease-out; 23 | } 24 | 25 | :host .ng2-tag-input-remove { 26 | background: #a6a6a6; 27 | border-radius: 50%; 28 | color: #e0e0e0; 29 | cursor: pointer; 30 | display: inline-block; 31 | font-size: 17px; 32 | height: 24px; 33 | line-height: 24px; 34 | margin-left: 6px; 35 | margin-right: -6px; 36 | text-align: center; 37 | width: 24px; 38 | } 39 | 40 | :host.ng2-tag-input-item-selected { 41 | color: white; 42 | background: #0d8bff; 43 | } 44 | 45 | :host.ng2-tag-input-item-selected .ng2-tag-input-remove { 46 | background: white; 47 | color: #0d8bff; 48 | } 49 | `] 50 | }) 51 | export class TagInputItemComponent { 52 | @Input() selected: boolean; 53 | @Input() text: string; 54 | @Input() index: number; 55 | @Output() tagRemoved: EventEmitter = new EventEmitter(); 56 | @HostBinding('class.ng2-tag-input-item-selected') get isSelected() { return !!this.selected; } 57 | 58 | constructor() { } 59 | 60 | removeTag(): void { 61 | this.tagRemoved.emit(this.index); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/components/tag-input/tag-input.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ElementRef, 4 | EventEmitter, 5 | forwardRef, 6 | HostBinding, 7 | HostListener, 8 | Input, 9 | OnDestroy, 10 | OnInit, 11 | Output, 12 | ViewChild 13 | } from '@angular/core'; 14 | import { AbstractControl, ControlValueAccessor, NG_VALUE_ACCESSOR, FormBuilder, FormGroup } from '@angular/forms'; 15 | import { Subscription } from 'rxjs'; 16 | 17 | import { KEYS } from '../../shared/tag-input-keys'; 18 | 19 | /** 20 | * Taken from @angular/common/src/facade/lang 21 | */ 22 | function isBlank(obj: any): boolean { 23 | return obj === undefined || obj === null; 24 | } 25 | 26 | export interface AutoCompleteItem { 27 | [index: string]: any; 28 | } 29 | 30 | @Component({ 31 | selector: 'rl-tag-input', 32 | template: ` 33 | 39 | 40 |
41 | 51 | 52 |
53 | 58 | 59 |
60 |
61 | `, 62 | styles: [` 63 | :host { 64 | font-family: "Roboto", "Helvetica Neue", sans-serif; 65 | font-size: 16px; 66 | display: block; 67 | box-shadow: 0 1px #ccc; 68 | padding: 8px 0 6px 0; 69 | will-change: box-shadow; 70 | transition: box-shadow 0.12s ease-out; 71 | } 72 | 73 | :host .ng2-tag-input-form { 74 | display: inline; 75 | } 76 | 77 | :host .ng2-tag-input-field { 78 | font-family: "Roboto", "Helvetica Neue", sans-serif; 79 | font-size: 16px; 80 | display: inline-block; 81 | width: auto; 82 | box-shadow: none; 83 | border: 0; 84 | padding: 8px 0; 85 | } 86 | 87 | :host .ng2-tag-input-field:focus { 88 | outline: 0; 89 | } 90 | 91 | :host .rl-tag-input-autocomplete-container { 92 | position: relative; 93 | z-index: 10; 94 | } 95 | 96 | :host.ng2-tag-input-focus { 97 | box-shadow: 0 2px #0d8bff; 98 | } 99 | `], 100 | providers: [ 101 | {provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TagInputComponent), multi: true}, 102 | ] 103 | }) 104 | export class TagInputComponent implements ControlValueAccessor, OnDestroy, OnInit { 105 | @HostBinding('class.ng2-tag-input-focus') isFocused; 106 | @Input() addOnBlur: boolean = true; 107 | @Input() addOnComma: boolean = true; 108 | @Input() addOnEnter: boolean = true; 109 | @Input() addOnPaste: boolean = true; 110 | @Input() addOnSpace: boolean = false; 111 | @Input() allowDuplicates: boolean = false; 112 | @Input() allowedTagsPattern: RegExp = /.+/; 113 | @Input() autocomplete: boolean = false; 114 | @Input() autocompleteItems: string[] = []; 115 | @Input() autocompleteMustMatch: boolean = true; 116 | @Input() autocompleteSelectFirstItem: boolean = true; 117 | @Input() pasteSplitPattern: string = ','; 118 | @Input() placeholder: string = 'Add a tag'; 119 | @Output('addTag') addTag: EventEmitter = new EventEmitter(); 120 | @Output('removeTag') removeTag: EventEmitter = new EventEmitter(); 121 | @ViewChild('tagInputElement') tagInputElement: ElementRef; 122 | 123 | private canShowAutoComplete: boolean = false; 124 | private tagInputSubscription: Subscription; 125 | private splitRegExp: RegExp; 126 | private get tagInputField(): AbstractControl { 127 | return this.tagInputForm.get('tagInputField'); 128 | } 129 | private get inputValue(): string { 130 | return this.tagInputField.value; 131 | } 132 | 133 | public tagInputForm: FormGroup; 134 | public autocompleteResults: string[] = []; 135 | public tagsList: string[] = []; 136 | public selectedTag: number; 137 | 138 | @HostListener('document:click', ['$event', '$event.target']) onDocumentClick(event: MouseEvent, target: HTMLElement) { 139 | if (!target) { 140 | return; 141 | } 142 | 143 | if (!this.elementRef.nativeElement.contains(target)) { 144 | this.canShowAutoComplete = false; 145 | } 146 | } 147 | 148 | constructor( 149 | private fb: FormBuilder, 150 | private elementRef: ElementRef) {} 151 | 152 | ngOnInit() { 153 | this.splitRegExp = new RegExp(this.pasteSplitPattern); 154 | 155 | this.tagInputForm = this.fb.group({ 156 | tagInputField: '' 157 | }); 158 | 159 | this.tagInputSubscription = this.tagInputField.valueChanges 160 | .do(value => { 161 | this.autocompleteResults = this.autocompleteItems.filter(item => { 162 | /** 163 | * _isTagUnique makes sure to remove items from the autocompelte dropdown if they have 164 | * already been added to the model, and allowDuplicates is false 165 | */ 166 | return item.toLowerCase().indexOf(value.toLowerCase()) > -1 && this._isTagUnique(item); 167 | }); 168 | }) 169 | .subscribe(); 170 | } 171 | 172 | onKeydown(event: KeyboardEvent): void { 173 | let key = event.keyCode; 174 | switch (key) { 175 | case KEYS.backspace: 176 | this._handleBackspace(); 177 | break; 178 | 179 | case KEYS.enter: 180 | if (this.addOnEnter && !this.showAutocomplete()) { 181 | this._addTags([this.inputValue]); 182 | event.preventDefault(); 183 | } 184 | break; 185 | 186 | case KEYS.comma: 187 | if (this.addOnComma) { 188 | this._addTags([this.inputValue]); 189 | event.preventDefault(); 190 | } 191 | break; 192 | 193 | case KEYS.space: 194 | if (this.addOnSpace) { 195 | this._addTags([this.inputValue]); 196 | event.preventDefault(); 197 | } 198 | break; 199 | 200 | default: 201 | break; 202 | } 203 | } 204 | 205 | onInputBlurred(event): void { 206 | if (this.addOnBlur) { this._addTags([this.inputValue]); } 207 | this.isFocused = false; 208 | } 209 | 210 | onInputFocused(): void { 211 | this.isFocused = true; 212 | setTimeout(() => this.canShowAutoComplete = true); 213 | } 214 | 215 | onInputPaste(event): void { 216 | let clipboardData = event.clipboardData || (event.originalEvent && event.originalEvent.clipboardData); 217 | let pastedString = clipboardData.getData('text/plain'); 218 | let tags = this._splitString(pastedString); 219 | this._addTags(tags); 220 | setTimeout(() => this._resetInput()); 221 | } 222 | 223 | onAutocompleteSelect(selectedItem) { 224 | this._addTags([selectedItem]); 225 | this.tagInputElement.nativeElement.focus(); 226 | } 227 | 228 | onAutocompleteEnter() { 229 | if (this.addOnEnter && this.showAutocomplete() && !this.autocompleteMustMatch) { 230 | this._addTags([this.inputValue]); 231 | } 232 | } 233 | 234 | showAutocomplete(): boolean { 235 | return ( 236 | this.autocomplete && 237 | this.autocompleteItems && 238 | this.autocompleteItems.length > 0 && 239 | this.canShowAutoComplete && 240 | this.inputValue.length > 0 241 | ); 242 | } 243 | 244 | private _splitString(tagString: string): string[] { 245 | tagString = tagString.trim(); 246 | let tags = tagString.split(this.splitRegExp); 247 | return tags.filter((tag) => !!tag); 248 | } 249 | 250 | private _isTagValid(tagString: string): boolean { 251 | return this.allowedTagsPattern.test(tagString) && 252 | this._isTagUnique(tagString); 253 | } 254 | 255 | private _isTagUnique(tagString: string): boolean { 256 | return this.allowDuplicates ? true : this.tagsList.indexOf(tagString) === -1; 257 | } 258 | 259 | private _isTagAutocompleteItem(tagString: string): boolean { 260 | return this.autocompleteItems.indexOf(tagString) > -1; 261 | } 262 | 263 | private _emitTagAdded(addedTags: string[]): void { 264 | addedTags.forEach(tag => this.addTag.emit(tag)); 265 | } 266 | 267 | private _emitTagRemoved(removedTag): void { 268 | this.removeTag.emit(removedTag); 269 | } 270 | 271 | private _addTags(tags: string[]): void { 272 | let validTags = tags.map(tag => tag.trim()) 273 | .filter(tag => this._isTagValid(tag)) 274 | .filter((tag, index, tagArray) => tagArray.indexOf(tag) === index) 275 | .filter(tag => (this.showAutocomplete() && this.autocompleteMustMatch) ? this._isTagAutocompleteItem(tag) : true); 276 | 277 | this.tagsList = this.tagsList.concat(validTags); 278 | this._resetSelected(); 279 | this._resetInput(); 280 | this.onChange(this.tagsList); 281 | this._emitTagAdded(validTags); 282 | } 283 | 284 | private _removeTag(tagIndexToRemove: number): void { 285 | let removedTag = this.tagsList[tagIndexToRemove]; 286 | this.tagsList.splice(tagIndexToRemove, 1); 287 | this._resetSelected(); 288 | this.onChange(this.tagsList); 289 | this._emitTagRemoved(removedTag); 290 | } 291 | 292 | private _handleBackspace(): void { 293 | if (!this.inputValue.length && this.tagsList.length) { 294 | if (!isBlank(this.selectedTag)) { 295 | this._removeTag(this.selectedTag); 296 | } else { 297 | this.selectedTag = this.tagsList.length - 1; 298 | } 299 | } 300 | } 301 | 302 | private _resetSelected(): void { 303 | this.selectedTag = null; 304 | } 305 | 306 | private _resetInput(): void { 307 | this.tagInputField.setValue(''); 308 | } 309 | 310 | /** Implemented as part of ControlValueAccessor. */ 311 | onChange: (value: any) => any = () => { }; 312 | 313 | onTouched: () => any = () => { }; 314 | 315 | writeValue(value: any) { 316 | this.tagsList = value; 317 | } 318 | 319 | registerOnChange(fn: any) { 320 | this.onChange = fn; 321 | } 322 | 323 | registerOnTouched(fn: any) { 324 | this.onTouched = fn; 325 | } 326 | 327 | ngOnDestroy() { 328 | this.tagInputSubscription.unsubscribe(); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /lib/shared/tag-input-keys.ts: -------------------------------------------------------------------------------- 1 | export const KEYS = { 2 | backspace: 8, 3 | comma: 188, 4 | downArrow: 40, 5 | enter: 13, 6 | esc: 27, 7 | space: 32, 8 | upArrow: 38 9 | }; 10 | -------------------------------------------------------------------------------- /lib/tag-input.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | import 'rxjs/add/observable/fromEvent'; 5 | import 'rxjs/add/operator/filter'; 6 | import 'rxjs/add/operator/do'; 7 | 8 | import { TagInputAutocompleteComponent } from './components/tag-input-autocomplete/tag-input-autocomplete.component'; 9 | import { TagInputComponent } from './components/tag-input/tag-input.component'; 10 | import { TagInputItemComponent } from './components/tag-input-item/tag-input-item.component'; 11 | 12 | @NgModule({ 13 | imports: [ 14 | CommonModule, 15 | FormsModule, 16 | ReactiveFormsModule 17 | ], 18 | declarations: [ 19 | TagInputAutocompleteComponent, 20 | TagInputComponent, 21 | TagInputItemComponent 22 | ], 23 | exports: [ 24 | TagInputComponent 25 | ] 26 | }) 27 | export class RlTagInputModule {} 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular2-tag-input", 3 | "version": "1.2.3", 4 | "description": "Tag input component for Angular 2", 5 | "main": "dist/index.js", 6 | "keywords": [ 7 | "angular2", 8 | "tag input", 9 | "taginput", 10 | "tags", 11 | "chips", 12 | "chips input" 13 | ], 14 | "scripts": { 15 | "clean": "./node_modules/.bin/rimraf ./dist/", 16 | "package:aot": "npm run clean && npm run build:aot", 17 | "build:aot": "./node_modules/.bin/ngc -p tsconfig-aot.json", 18 | "serve": "webpack-dev-server --config config/webpack.dev.js --progress --profile --watch --content-base demo/", 19 | "lint": "tslint lib/**.*.ts" 20 | }, 21 | "author": "TEAM COOL", 22 | "license": "ISC", 23 | "peerDependencies": { 24 | "@angular/common": "2.2.4", 25 | "@angular/core": "2.2.4", 26 | "@angular/forms": "2.2.4" 27 | }, 28 | "devDependencies": { 29 | "@angular/common": "2.2.4", 30 | "@angular/compiler": "2.2.4", 31 | "@angular/compiler-cli": "2.2.4", 32 | "@angular/core": "2.2.4", 33 | "@angular/forms": "2.2.4", 34 | "@angular/platform-browser": "2.2.4", 35 | "@angular/platform-browser-dynamic": "2.2.4", 36 | "@angular/platform-server": "2.2.4", 37 | "@types/node": "^6.0.45", 38 | "awesome-typescript-loader": "^2.2.3", 39 | "codelyzer": "~0.0.26", 40 | "core-js": "^2.4.1", 41 | "node-sass": "^3.10.1", 42 | "postcss-loader": "^0.13.0", 43 | "raw-loader": "^0.5.1", 44 | "rimraf": "^2.5.4", 45 | "rxjs": "5.0.0-beta.12", 46 | "sass-loader": "^4.0.2", 47 | "source-map-loader": "^0.1.5", 48 | "tslint": "3.13.0", 49 | "typescript": "2.0.10", 50 | "webpack": "^2.1.0-beta.21", 51 | "webpack-dev-server": "^2.1.0-beta.0", 52 | "zone.js": "^0.6.25" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tsconfig-aot.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "noEmitHelpers": false, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "pretty": true, 12 | "allowUnreachableCode": true, 13 | "allowUnusedLabels": true, 14 | "noImplicitAny": false, 15 | "noImplicitReturns": false, 16 | "noImplicitUseStrict": false, 17 | "noFallthroughCasesInSwitch": false, 18 | "allowSyntheticDefaultImports": true, 19 | "suppressExcessPropertyErrors": true, 20 | "suppressImplicitAnyIndexErrors": true, 21 | "outDir": "dist", 22 | "declarationDir": "dist", 23 | "lib": ["es6", "dom"], 24 | "skipLibCheck": true 25 | }, 26 | "files": [ 27 | "index.ts" 28 | ], 29 | "exclude": [ 30 | "node_modules", 31 | "demo" 32 | ], 33 | "compileOnSave": false, 34 | "buildOnSave": false, 35 | "awesomeTypescriptLoaderOptions": { 36 | "forkChecker": false 37 | }, 38 | "angularCompilerOptions": { 39 | "genDir": "dist/", 40 | "skipMetadataEmit": false 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "removeComments": true, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "lib": ["es6", "dom"], 13 | "typeRoots": [ 14 | "./node_modules/@types" 15 | ] 16 | }, 17 | "files": [ 18 | "./index.ts" 19 | ], 20 | "include": [ 21 | "lib" 22 | ], 23 | "exclude": [ 24 | "node_modules" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "class-name": true, 7 | "comment-format": [ 8 | true, 9 | "check-space" 10 | ], 11 | "curly": true, 12 | "eofline": true, 13 | "forin": true, 14 | "indent": [ 15 | true, 16 | "spaces" 17 | ], 18 | "label-position": true, 19 | "label-undefined": true, 20 | "max-line-length": [ 21 | true, 22 | 140 23 | ], 24 | "member-access": false, 25 | "member-ordering": [ 26 | true, 27 | "static-before-instance", 28 | "variables-before-functions" 29 | ], 30 | "no-arg": true, 31 | "no-bitwise": true, 32 | "no-console": [ 33 | true, 34 | "debug", 35 | "info", 36 | "time", 37 | "timeEnd", 38 | "trace" 39 | ], 40 | "no-construct": true, 41 | "no-debugger": true, 42 | "no-duplicate-key": true, 43 | "no-duplicate-variable": true, 44 | "no-empty": false, 45 | "no-eval": true, 46 | "no-inferrable-types": true, 47 | "no-shadowed-variable": true, 48 | "no-string-literal": false, 49 | "no-switch-case-fall-through": true, 50 | "no-trailing-whitespace": true, 51 | "no-unused-expression": true, 52 | "no-unused-variable": true, 53 | "no-unreachable": true, 54 | "no-use-before-declare": true, 55 | "no-var-keyword": true, 56 | "object-literal-sort-keys": false, 57 | "one-line": [ 58 | true, 59 | "check-open-brace", 60 | "check-catch", 61 | "check-else", 62 | "check-whitespace" 63 | ], 64 | "quotemark": [ 65 | true, 66 | "single" 67 | ], 68 | "radix": true, 69 | "semicolon": [ 70 | "always" 71 | ], 72 | "triple-equals": [ 73 | true, 74 | "allow-null-check" 75 | ], 76 | "typedef-whitespace": [ 77 | true, 78 | { 79 | "call-signature": "nospace", 80 | "index-signature": "nospace", 81 | "parameter": "nospace", 82 | "property-declaration": "nospace", 83 | "variable-declaration": "nospace" 84 | } 85 | ], 86 | "variable-name": false, 87 | "whitespace": [ 88 | true, 89 | "check-branch", 90 | "check-decl", 91 | "check-operator", 92 | "check-separator", 93 | "check-type" 94 | ], 95 | 96 | "directive-selector-prefix": [true, "rl"], 97 | "component-selector-prefix": [true, "rl"], 98 | "directive-selector-name": [true, "camelCase"], 99 | "component-selector-name": [true, "kebab-case"], 100 | "directive-selector-type": [true, "attribute"], 101 | "component-selector-type": [true, "element"], 102 | "use-input-property-decorator": true, 103 | "use-output-property-decorator": true, 104 | "use-host-property-decorator": true, 105 | "no-input-rename": true, 106 | "no-output-rename": true, 107 | "use-life-cycle-interface": true, 108 | "use-pipe-transform-interface": true, 109 | "component-class-suffix": true, 110 | "directive-class-suffix": true 111 | } 112 | } 113 | --------------------------------------------------------------------------------