/* * © 2017 Stratio Big Data Inc., Sucursal en España. * * This software is licensed under the Apache License, Version 2.0. * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * See the terms of the License for more details. * * SPDX-License-Identifier: Apache-2.0. */ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, HostBinding, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms'; import { StDropDownMenuGroup, StDropDownMenuItem } from '../st-dropdown-menu/st-dropdown-menu.interface'; /** * @description {Component} Tag Input * * This component is a text input box that automatically creates tags out of a typed text. * * @example * * {html} * * ``` * * * * * ``` */ @Component({ selector: 'st-tag-input', templateUrl: './st-tag-input.component.html', styleUrls: ['./st-tag-input.component.scss'], host: { 'class': 'st-tag-input' }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StTagInputComponent), multi: true }, { provide: NG_VALIDATORS, useExisting: forwardRef(() => StTagInputComponent), multi: true }], changeDetection: ChangeDetectionStrategy.OnPush }) export class StTagInputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { /** @input {string | null} [label=null] Label to show over the input. It is empty by default */ @Input() label: string | null = null; /** @input {string | null} [tooltip=null] The tooltip to show over the label. It is empty by default */ @Input() tooltip: string | null = null; /** @input {string | null} [placeholder=null] The text that appears as placeholder of the input. It is empty by default */ @Input() placeholder: string | null = null; /** @input {string | null} [errorMessage=null] Error message to show. It is empty by default */ @Input() errorMessage: string | null = null; /** @input {string | null} [type=null] Type of the items */ @Input() type: string | null = 'text'; /** @input {boolean} [withAutocomplete=false] Enable autocomplete feature. It is false by default */ @Input() withAutocomplete: boolean = false; /** @input {(StDropDownMenuItem | StDropDownMenuGroup)[]} [autocompleteList=Array()] List to be used for autocomplete feature. It is empty by default */ @Input() autocompleteList: (StDropDownMenuItem | StDropDownMenuGroup)[] = []; /** @input {boolean} [charsToShowAutocompleteList=Array()] List to be used for autocomplete feature. It is empty by default */ @Input() charsToShowAutocompleteList: number = 1; /** @input {boolean} [allowFreeText=true] Boolean to allow user to type a free text or not */ @Input() allowFreeText: boolean = true; /** @input {string} [infoMessage=] Message used to inform user about what values he has to introduce */ @Input() infoMessage: string; /** @input {string[]} [forbiddenValues=Array()] A list of values that user can not type and if he types one of them, * tag input will be invalid. It is empty by default */ @Input() forbiddenValues: string[] = []; /** @input {string} [regularExpression=] Regular expression to validate values. It is null by default */ @Input() regularExpression: any | null = null; /** @Input {boolean} [forceValidations=false] If you specify it to 'true', the tag input checks the errors before being modified by user */ @Input() forceValidations: boolean = false; @ViewChild('newElement', {static: true}) newElementInput: ElementRef; @ViewChild('inputElement', {static: false}) inputElement: ElementRef; public expandedMenu: boolean = false; public items: any[] = []; public innerInputContent: string = ''; public isPristine: boolean = true; public showErrorValue: boolean = false; private _focus: boolean = false; private _isDisabled: boolean = false; private _newElementInput: HTMLInputElement | null = null; private _selected: number | null = null; private _regularExp: RegExp; onChange = (_: any) => { } onTouched = () => { } constructor(private _selectElement: ElementRef, private _cd: ChangeDetectorRef) { } /** @input {boolean} [disabled=false] Disable the component. It is false by default */ @Input() get disabled(): boolean { return this._isDisabled; } set disabled(value: boolean) { this._isDisabled = value; } get hasLabel(): boolean { return this.label !== null && this.label.length > 0; } get hasFocus(): boolean { return this._focus; } get hasPlaceholder(): boolean { return !this._focus && !this.items.length && this.placeholder && this.placeholder.length > 0; } @HostBinding('class.st-tag-input--autocomplete') get hasAutocomplete(): boolean { return this.expandedMenu && this.autocompleteList && this.autocompleteList.length > 0; } get disableValue(): string | null { return this._isDisabled === true ? '' : null; } get isValidInput(): boolean { const isForbidden = this.forbiddenValues.length && this.forbiddenValues.indexOf(this.innerInputContent) > -1; const isDuplicated = this.items.indexOf(this.innerInputContent) !== -1; const matchedPattern = this.regularExpression ? this._regularExp.test(this.innerInputContent) : true; return this.innerInputContent.length ? !isForbidden && !isDuplicated && matchedPattern : true; } get tagSelected(): number | null { return this._selected; } get selectId(): string | null { const select: HTMLElement = this._selectElement.nativeElement; return select.getAttribute('id') !== null ? select.id : null; } get inputId(): string | null { return this.selectId !== null ? `${this.selectId}-input` : null; } get labelId(): string | null { return this.selectId !== null ? `${this.selectId}-label` : null; } get tagId(): string | null { return this.selectId !== null ? `${this.selectId}-tag-` : null; } get listId(): string { return this.selectId !== null ? `${this.selectId}-autocomplete` : null; } ngOnInit(): void { this._newElementInput = this.newElementInput.nativeElement; switch (this.type) { case 'number': { this.regularExpression = '^\\d+(\\.\\d+)?$'; break; } case 'integer': { this.regularExpression = '^\\d+$'; break; } default: { break; } } this._regularExp = new RegExp(this.regularExpression); this.showErrorValue = this.showError(); this._cd.markForCheck(); } ngOnChanges(changes: SimpleChanges): void { this.checkAutocompleteMenuChange(changes); if (this.forceValidations) { this.writeValue(this.items); } this._cd.markForCheck(); } writeValue(data: any): void { if (data && Array.isArray(data) && data.length) { this.items = []; for (const value of data) { const parsedValue = this._getParsedTag(value); if (parsedValue || !isNaN(parsedValue)) { this.items.push(parsedValue); } } this.onChange(this.items); this.isPristine = false; this._cd.markForCheck(); } } setDisabledState(disabled: boolean): void { this.disabled = disabled; this._cd.markForCheck(); } validate(control: FormControl): any { } // Registry the change function to propagate internal model changes registerOnChange(fn: (_: any) => void): void { this.onChange = fn; } registerOnTouched(fn: any): void { this.onTouched = fn; } // Input actions onInputText(text: string): void { this.innerInputContent = text; this.showAutocompleteMenu(); } onInputFocusIn(event: Event): void { if (!this._isDisabled) { this._focus = true; this._newElementInput.focus(); this._forceResetAutoCompleteList(); this.showAutocompleteMenu(); } event.stopPropagation(); } onInputFocusOut(event: Event): void { if (!this.expandedMenu) { this._focus = false; this.addCurrentTag(); } event.stopPropagation(); } onInputKeyDown(event: any): void { switch (event.keyCode) { case 188: // Comma case 13: // Enter if (this.innerInputContent.length && this.isValidInput) { this.addTag(this.innerInputContent); this._forceResetAutoCompleteList(); } event.preventDefault(); break; case 9: // Tab if (this.innerInputContent.length && this.isValidInput) { this.addTag(this.innerInputContent); event.preventDefault(); } else if (this.innerInputContent.length) { this.clearInput(); } break; case 46: // Delete if (this.innerInputContent.length) { this.clearInput(); } else if (this.items.length) { event.target.previousElementSibling.focus(); } break; case 8: // Backspace case 37: // Left if (this.items.length && !this.innerInputContent.length) { event.target.previousElementSibling.focus(); } break; default: break; } } // Tag actions onTagKeyDown(event: any, index: number): void { switch (event.keyCode) { case 8: // Backspace case 46: // Delete if (this._selected !== null) { this.deleteTag(this._selected); this._selected = null; this._newElementInput.focus(); } break; case 37: // Left if (this._selected > 0) { event.target.previousElementSibling.focus(); } break; case 39: // Right if (this._selected < this.items.length) { event.target.nextElementSibling.focus(); } break; default: break; } } onTagFocusIn(event: Event, index: number): void { if (!this._isDisabled) { this._focus = true; this.addCurrentTag(); this.expandedMenu = false; this._selected = index; } } onTagFocusOut(event: Event, index: number): void { this._focus = false; this._selected = null; } onTagClick(event: Event, index: number): void { event.stopPropagation(); event.preventDefault(); } // Dropdown actions onListSelect(data: StDropDownMenuItem): void { this._focus = false; if (data.value.length && this.items.indexOf(data.value) === -1) { this.addTag(data.value); } else { this.clearInput(); } } onClickOutside(event: Event): void { if (this.expandedMenu) { this._focus = false; this.addCurrentTag(); } } showError(): boolean { return typeof this.errorMessage === 'string' && (!this.isPristine || this.forceValidations) && !this._focus && !this.disabled; } deleteTag(index: number): void { this.items.splice(index, 1); this.onChange(this.items); this._newElementInput.value = ''; this.innerInputContent = ''; this._newElementInput.dispatchEvent(new Event('input')); } private addTag(tag: string): void { const parsedValue = this._getParsedTag(tag); if (parsedValue || !isNaN(parsedValue)) { this.items.push(parsedValue); this.clearInput(); this.onChange(this.items); this.isPristine = false; } this.showErrorValue = this.showError(); } private addCurrentTag(): void { if (this.innerInputContent.length && this.isValidInput) { this.addTag(this.innerInputContent); } else { this.clearInput(); } } private clearInput(): void { if (this.expandedMenu) { this.expandedMenu = false; } this.innerInputContent = ''; this._newElementInput.innerText = ''; } private showAutocompleteMenu(): void { if (this.withAutocomplete && !this.expandedMenu && this.charsToShowAutocompleteList <= this.innerInputContent.length) { this.expandedMenu = true; } if (this.innerInputContent === '' && this.charsToShowAutocompleteList) { this.expandedMenu = false; } this._cd.markForCheck(); } private checkAutocompleteMenuChange(changes: SimpleChanges): void { if (changes && changes.autocompleteList) { this._cd.markForCheck(); } } private _getParsedTag(tag: string): any { switch (this.type) { case 'number': { return parseFloat(tag); } case 'integer': { return parseInt(tag, 0); } default: return tag; } } private _forceResetAutoCompleteList(): void { if (!this.charsToShowAutocompleteList && this.withAutocomplete) { this._newElementInput.innerText = ''; const event: any = new Event('input', { 'bubbles': true }); event.data = ''; this._newElementInput.dispatchEvent(event); } } }