// Copyright (C) Plan-A Software Ltd. All Rights Reserved.
//
// Written by Kiran Lakhotia <kiran@plan-a-software.co.uk>, 2014
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
'use strict';
goog.provide('plana.ui.tags.TagsInput');
goog.provide('plana.ui.tags.TagsInput.EventType');
goog.require('goog.events.Event');
goog.require('goog.events.EventType');
goog.require('goog.events.KeyHandler.EventType');
goog.require('goog.fx.Animation.EventType');
goog.require('goog.fx.dom.FadeIn');
goog.require('goog.fx.dom.FadeOut');
goog.require('goog.ui.Component');
goog.require('goog.ui.ac.AutoComplete.EventType');
goog.require('plana.ui.ac.AutoComplete');
goog.require('plana.ui.ac.RemoteObject');
/**
* This class provides a component to manage tags. It was inspired by the the
* jquery plugin from Tim Schlechter.
* @see https://github.com/timschlechter/bootstrap-tagsinput
*
* It requires bootstrap css for proper rendering of tags.
*
* @constructor
* @extends {goog.ui.Component}
* @param {goog.Uri} uri The server resources to use for fetching a list of
* existing tags. You can add custom parameters to uri to pass to the
* server with every request
* @param {boolean=} opt_createNew Whether to allow a user to create new tags
* if the autocomplete didn't match anything. Default: false
* @param {plana.ui.ac.AutoCompleteRenderer=} opt_autocompleteRenderer The
* renderer for the autocomplete input
* @param {string=} opt_inputId Optional id to use for the autocomplete input
* element. Default: ''
* @param {goog.net.XhrIo=} opt_xhrIo Optional XhrIo object to use. By default
* we create a new instance
* @param {goog.net.XmlHttpFactory=} opt_xmlHttpFactory Optional factory to use
* when creating XMLHttpRequest objects
* @param {boolean=} opt_useSimilar Use similar matches. e.g. "gost" => "ghost".
* This option is passed along to the server
* @param {goog.dom.DomHelper=} opt_domHelper The dom helper
*/
plana.ui.tags.TagsInput = function(
uri, opt_createNew, opt_autocompleteRenderer, opt_inputId,
opt_xhrIo, opt_xmlHttpFactory, opt_useSimilar, opt_domHelper) {
goog.ui.Component.call(this, opt_domHelper);
/**
* The autocomplete to create new tags
* @type {plana.ui.ac.AutoComplete}
* @private
*/
this.autocomplete_ = new plana.ui.ac.AutoComplete(uri, false,
opt_autocompleteRenderer, opt_inputId,
opt_xhrIo, opt_xmlHttpFactory, opt_useSimilar, opt_domHelper);
/**
* The div element containing the tags and the autocomplete
* input
* @type {?Element}
* @private
*/
this.tagsContainer_ = null;
/**
* Array of displayed tag elements
* @type {Array.<Element>}
* @private
*/
this.tags_ = [];
/**
* Flag whether we should be case insensitive when checking
* if a tag exists already
* @type {boolean}
* @private
*/
this.caseInsensitive_ = true;
/**
* The default width to use for the autocomplete input
* @type {number}
* @private
*/
this.inputSize_ = 8;
/**
* The fade in effect to use for duplicate tags
* @type {?goog.fx.dom.FadeIn}
* @private
*/
this.duplicateFadeInFx_ = null;
/**
* The fade out effect to use for duplicate tags
* @type {?goog.fx.dom.FadeOut}
* @private
*/
this.duplicateFadeOutFx_ = null;
/**
* Flag whether the user is allowed to create tags if
* the autocomplete didn't find a match
* @type {boolean}
* @private
*/
this.allowCreateTags_ = opt_createNew || false;
};
goog.inherits(plana.ui.tags.TagsInput, goog.ui.Component);
/**
* The default css class to use for rendering tags
* @type {string}
*/
plana.ui.tags.TagsInput.DEFAULT_TAG_CSS = 'label-info';
/**
* @desc
* The placeholder message to display in the input. Set
* to an empty string to disable placeholder text
*/
plana.ui.tags.TagsInput.MSG_ADD_TAG_PLACEHOLDER = goog.getMsg('Add tag');
/**
* The default speed to use for the fade-in/out animations
* @type {number}
*/
plana.ui.tags.TagsInput.FADE_SPEED = 100;
/**
* @override
* @suppress {checkTypes}
*/
plana.ui.tags.TagsInput.prototype.disposeInternal = function() {
plana.ui.tags.TagsInput.superClass_.disposeInternal.call(this);
this.autocomplete_.dispose();
this.autocomplete_ = null;
this.tagsContainer_ = null;
this.tags_.length = 0;
this.tags_ = null;
this.caseInsensitive_ = null;
this.inputSize_ = null;
if (this.duplicateFadeInFx_ != null) {
this.duplicateFadeInFx_.dispose();
this.duplicateFadeInFx_ = null;
}
if (this.duplicateFadeOutFx_ != null) {
this.duplicateFadeOutFx_.dispose();
this.duplicateFadeOutFx_ = null;
}
this.allowCreateTags_ = null;
};
/**
* This component does not support decoration for now, because the underlying
* autocomplete doesn't support it
* @param {Element} element Element to decorate
* @return {boolean} Return false
* @override
*/
plana.ui.tags.TagsInput.prototype.canDecorate = function(element) {
return false;
};
/**
* @override
*/
plana.ui.tags.TagsInput.prototype.createDom = function() {
var dom = this.dom_;
var root = dom.createDom('div');
this.setElementInternal(root);
this.tagsContainer_ = dom.createDom('div', {
'class': 'tagsinput'
});
dom.appendChild(root, this.tagsContainer_);
this.autocomplete_.setPlaceholder(
plana.ui.tags.TagsInput.MSG_ADD_TAG_PLACEHOLDER);
this.autocomplete_.render(this.tagsContainer_);
var input = this.autocomplete_.getInputHandler().getInput();
dom.setProperties(input, {
'size': this.inputSize_
});
//make sure the autocomplete is shown inline with the tags
var divAutocomplete = this.autocomplete_.getElement();
goog.style.setInlineBlock(divAutocomplete);
goog.dom.classes.add(divAutocomplete, 'tag-input-container');
};
/**
* @override
*/
plana.ui.tags.TagsInput.prototype.enterDocument = function() {
plana.ui.tags.TagsInput.superClass_.enterDocument.call(this);
this.renderTags_();
var handler = this.getHandler();
handler.listen(this.autocomplete_, goog.ui.ac.AutoComplete.EventType.UPDATE,
this.onCheckAddTag_, false);
handler.listen(this.autocomplete_.getInputHandler(),
goog.events.KeyHandler.EventType.KEY,
this.onKey_, false);
};
/**
* @override
*/
plana.ui.tags.TagsInput.prototype.exitDocument = function() {
this.removeTags_();
var handler = this.getHandler();
handler.unlisten(this.autocomplete_, goog.ui.ac.AutoComplete.EventType.UPDATE,
this.onCheckAddTag_, false);
handler.unlisten(this.autocomplete_.getInputHandler(),
goog.events.KeyHandler.EventType.KEY,
this.onKey_, false);
plana.ui.tags.TagsInput.superClass_.exitDocument.call(this);
};
/**
* @param {*} model A tag list or null to
* clear all tags
* @override
*/
plana.ui.tags.TagsInput.prototype.setModel = function(model) {
var old = /**@type {?Array.<string|Object>} */ (this.getModel());
if (old != null) {
old.length = 0;
}
plana.ui.tags.TagsInput.superClass_.setModel.call(this,
/**@type {?Array.<string|Object>} */
(model));
if (this.tagsContainer_ != null) {
this.renderTags_();
//clear any input
this.autocomplete_.setModel(null);
}
};
/**
* This function removes all tags from the DOM
* @private
*/
plana.ui.tags.TagsInput.prototype.removeTags_ = function() {
var dom = this.dom_;
var handler = this.getHandler();
var numTags = this.tags_.length;
for (var i = numTags - 1; i >= 0; --i) {
this.removeTag_(this.tags_[i], i);
}
};
/**
* This function removes a specific tag from the DOM
* @param {Element} tag The HTML element for a tag
* @param {number} index The index of the array that stores
* the list of rendered tags
* @private
*/
plana.ui.tags.TagsInput.prototype.removeTag_ = function(tag, index) {
var dom = this.dom_;
var handler = this.getHandler();
var btn = dom.getFirstElementChild(tag);
handler.unlisten(btn, goog.events.EventType.CLICK,
this.onRemoveTag_, false);
dom.removeNode(tag);
this.tags_[index] = null;
this.tags_.splice(index, 1);
};
/**
* This function renders the DOM for a tag and adds it to the
* list of tags
* @param {string|Object} tag The tag to add
* @param {number} index The index in the list to add the tag at
* @private
*/
plana.ui.tags.TagsInput.prototype.renderTag_ = function(tag, index) {
var dom = this.dom_;
var tagObj = new plana.ui.ac.RemoteObject(tag);
var removeBtn = dom.createDom('span', {
'data-role': 'remove'
});
var handler = this.getHandler();
handler.listen(removeBtn, goog.events.EventType.CLICK,
this.onRemoveTag_, false);
/**
* @type {string}
*/
var css;
if (goog.isString(tag) || !goog.isDefAndNotNull(tag['tagClass']) ||
goog.isString(tag['tagClass']) == false) {
css = plana.ui.tags.TagsInput.DEFAULT_TAG_CSS;
} else {
css = /**@type {string}*/ (tag['tagClass']);
}
var tagSpn = dom.createDom('span', {
'class': 'tag label ' + css
}, tagObj.toString(), removeBtn);
this.tags_[index] = tagSpn;
dom.insertChildAt(this.tagsContainer_, tagSpn, index);
};
/**
* This function renders a list of tags
* @private
*/
plana.ui.tags.TagsInput.prototype.renderTags_ = function() {
var dom = this.dom_;
var handler = this.getHandler();
this.removeTags_();
var tags = /** @type {?Array.<string|Object>} */ (this.getModel());
if (tags == null) return;
for (var i = 0, tag; tag = tags[i]; ++i) {
this.renderTag_(tag, i);
}
};
/**
* Callback for clicking the remove icon on a tag
* @param {goog.events.BrowserEvent} e
* @private
*/
plana.ui.tags.TagsInput.prototype.onRemoveTag_ = function(e) {
var tags = /** @type {Array.<string|Object>} */ (this.getModel());
goog.asserts.assert(tags != null, 'model cannot be null when removing tags');
var dom = this.dom_;
for (var i = 0, el; el = this.tags_[i]; ++i) {
var btn = dom.getFirstElementChild(el);
if (btn == e.target) {
this.removeTag_(el, i);
var removed = tags.splice(i, 1);
this.onChange_(plana.ui.tags.TagsInput.EventType.REMOVED, removed[0]);
break;
}
}
};
/**
* Callback for events from the autocomplete component. This function either
* adds a new tag, or it dispatches an error message
* @param {goog.events.Event} e
* @private
*/
plana.ui.tags.TagsInput.prototype.onCheckAddTag_ = function(e) {
/**
@type {{
type: string,
data: (Object|string|null)
}}
*/
var eventData = /**@type {{type: string, data: (Object|string|null)}}*/ (e);
if (eventData.data == null) {
var tokens = this.autocomplete_.getNonMatches();
if (tokens.length == 0) {
//empty input, so ignore event
e.preventDefault();
e.stopPropagation();
return;
}
if (!this.allowCreateTags_) {
e.preventDefault();
e.stopPropagation();
//send error
this.dispatchEvent({
type: plana.ui.tags.TagsInput.EventType.INVALID,
tag: tokens[0]
});
return;
}
eventData.data = tokens[0];
}
//add tag to model
var tags = /** @type {?Array.<string|Object>} */ (this.getModel());
if (tags == null) {
//this will render the tags
this.setModel([ /**@type {string|Object}*/ (eventData.data)]);
this.onChange_(plana.ui.tags.TagsInput.EventType.ADDED,
/**@type {string|Object}*/
(eventData.data));
} else {
var dom = this.dom_;
var tagObj = new plana.ui.ac.RemoteObject(eventData.data);
var tagStr = tagObj.toString();
tagObj.dispose();
//check tag doesn't exist already
var duplicateIndex = -1;
for (var i = 0, el; el = this.tags_[i]; ++i) {
var text = dom.getTextContent(el);
if (this.caseInsensitive_) {
if (goog.string.caseInsensitiveCompare(tagStr, text) == 0) {
duplicateIndex = i;
break;
}
} else {
if (tagStr == text) {
duplicateIndex = i;
break;
}
}
}
if (duplicateIndex == -1) {
//render tag manually
var numTags = this.tags_.length;
this.renderTag_(eventData.data, numTags);
tags.push(eventData.data);
this.onChange_(plana.ui.tags.TagsInput.EventType.ADDED,
/**@type {string|Object}*/
(eventData.data));
} else {
//fade out then back in
var tag = this.tags_[duplicateIndex];
if (this.duplicateFadeOutFx_ != null) {
this.duplicateFadeOutFx_.dispose();
}
if (this.duplicateFadeInFx_ != null) {
this.duplicateFadeInFx_.dispose();
}
this.duplicateFadeOutFx_ = new goog.fx.dom.FadeOut(tag,
plana.ui.tags.TagsInput.FADE_SPEED);
this.duplicateFadeInFx_ = new goog.fx.dom.FadeIn(tag,
plana.ui.tags.TagsInput.FADE_SPEED);
var handler = this.getHandler();
handler.listenOnce(this.duplicateFadeOutFx_,
goog.fx.Animation.EventType.END, function(fxe) {
this.duplicateFadeInFx_.play();
this.duplicateFadeOutFx_.dispose();
this.duplicateFadeOutFx_ = null;
}, false);
handler.listenOnce(this.duplicateFadeInFx_,
goog.fx.Animation.EventType.END, function(fxe) {
this.duplicateFadeInFx_.dispose();
this.duplicateFadeInFx_ = null;
}, false);
this.duplicateFadeOutFx_.play();
}
}
//clear the input
this.autocomplete_.setModel(null);
};
/**
* Callback for key events fired from the autocomplete input. This function
* checks if the key was a backspace character and thus should delete the
* last tag
* element
* @param {goog.events.KeyEvent} e The key event
* @private
*/
plana.ui.tags.TagsInput.prototype.onKey_ = function(e) {
if (e.keyCode === goog.events.KeyCodes.BACKSPACE) {
var input = this.autocomplete_.getInputHandler().getInput();
if (goog.string.isEmptySafe(input.value)) {
//delete
var numTags = this.tags_.length;
if (numTags > 0) {
var tags = /** @type {Array.<string|Object>} */ (this.getModel());
goog.asserts.assert(tags != null,
'model cannot be null when removing tags via backspace');
var index = numTags - 1;
var tag = this.tags_[index];
this.removeTag_(tag, index);
var removed = tags.splice(index, 1);
this.onChange_(plana.ui.tags.TagsInput.EventType.REMOVED, removed[0]);
e.preventDefault();
e.stopPropagation();
}
}
}
};
/**
* This function dispatches change events for listeners
* @param {string} type The event that caused the change
* @param {string|Object} tag Tag information
* @private
*/
plana.ui.tags.TagsInput.prototype.onChange_ = function(type, tag) {
this.dispatchEvent({
type: type,
tag: tag
});
this.dispatchEvent(
new goog.events.Event(
goog.events.EventType.CHANGE, this
)
);
};
/**
* This function focuses the autocomplete input of this component
*/
plana.ui.tags.TagsInput.prototype.focus = function() {
this.autocomplete_.focus();
};
/**
* Setter for the width of the input element for the autocomplete input
* @param {!number} width The width to set the input element to
*/
plana.ui.tags.TagsInput.prototype.setInputSize = function(width) {
this.inputSize_ = width;
var input = this.autocomplete_.getInputHandler().getInput();
if (input) {
var dom = this.dom_;
dom.setProperties(input, {
'size': this.inputSize_
});
}
};
/**
* Setter for whether we should be case sensitive when checking for existing
* tags
* @param {boolean} newValue The value to set
*/
plana.ui.tags.TagsInput.prototype.setCaseInsensitive = function(newValue) {
this.caseInsensitive_ = newValue;
};
/**
* Setter for whether we allow the creation of new tags or not
* @param {boolean} newValue The value to set to
*/
plana.ui.tags.TagsInput.prototype.allowCreateTags = function(newValue) {
this.allowCreateTags_ = newValue;
};
/**
* Getter for the autocomplete component
* @return {plana.ui.ac.AutoComplete}
*/
plana.ui.tags.TagsInput.prototype.getAutoComplete = function() {
return this.autocomplete_;
};
/**
* List of events dispatched by this component
* @enum {string}
*/
plana.ui.tags.TagsInput.EventType = {
/**
* @desc The event dispatched if the component does not allow a user to
* create tags and the token entered does not match any existing tags
*/
INVALID: goog.events.getUniqueId('invalid'),
/**
* @desc The event dispatched after a tag was added
*/
ADDED: goog.events.getUniqueId('added'),
/**
* @desc The event dispatched after a tag was removed
*/
REMOVED: goog.events.getUniqueId('removed')
};