How to add AJAX suggestions to yairEO's Tagify tag input component

John Avis by | May 25, 2018 | Web Development

yairEO's Tagify is a great implementation of a tag component, being very lightweight and using in-built browser behaviour where possible. Here's how I get get suggestions via AJAX rather than a fixed whitelist.
When looking for a tag input component, like the one used on Stack Overflow, I came across Tagify.

This is a great implementation of a tag component, being very lightweight and using in-built browser behaviour where possible, for example rather than creating an auto suggest function like most other similar components, the creator uses a HTML 5 datalist element.

I chose to use this component for one of the websites I am developing, but I need to make some changes to make it work how I want it to.

The first change is to get suggestions via AJAX rather than a fixed whitelist. A fixed whitelist is fine for most applications, but in my case there could be thousands of possible tags and I was concerned about putting the entire list on the client.

Some other changes that I need to make will be covered in a future post. For example, as my website uses Bootstrap I want the tag input component to look like a native Bootstrap control.

Note that if you use this component with Bootstrap that you will have to hide the original INPUT or TEXTAREA control yourself as Tagify attempts to hide it using CSS (tags input, tags textarea { display: none; }) which is overriden by Bootstrap's CSS.

For graceful degradation, hiding this with CSS is not ideal, so using JavaScript is better anyway. I simply use the following after initialising Tagify on the control:

$("#myinput").tagify(); //initialise Tagify
$("#myinput").hide(); // Hide the original control

My modified version of Tagify is at the end of this article. I've added comments to indicate additions and modifications to the original code.

There's an additional setting, suggestionsUrl for the URL of the service that will provide suggestions via HTTP GET. The search term will be appended to this, so you may need to include part of your querystring in the URL, eg. "https://domain.com/services/tagify.php?tag=".

Tagify's autocomplete setting also needs to be set to true, and you should not include values for both whitelist and suggestionsUrl.

Note that the HTML 5 datalist auto suggest works by finding the search term anywhere inside the list of suggestions, so you should do the same in your service that provides suggestions. ie. that means you shouldn't provide suggestions based on the first characters only.

Your service should return a JSON result similar to this example (based on input of "ab"):

["ABAP","ABC","ABC ALGOL","ABSET","ABSYS"]

Okay, here's the code...

/**
* Tagify (v 1.3.1)- tags input component
* By Yair Even-Or (2016)
* Don't sell this code. (c)
* https://github.com/yairEO/tagify

* Modifications for AJAX suggestions by John Avis 2018-05-25
*/
;(function($){
// just a jQuery wrapper for the vanilla version of this component
$.fn.tagify = function(settings){
return this.each(function() {
var $input = $(this),
tagify;

if( $input.data("tagify") ) // don't continue if already "tagified"
return this;

tagify = new Tagify($input[0], settings);
tagify.isJQueryPlugin = true;
$input.data("tagify", tagify);
});
}

function Tagify( input, settings ){
// protection
if( !input ){
console.warn('Tagify: ', 'invalid input element ', input)
return this;
}

this.settings = this.extend({}, settings, this.DEFAULTS);
this.settings.readonly = input.hasAttribute('readonly'); // if "readonly" do not include an "input" element inside the Tags component

this.legacyFixes();

if( input.pattern )
try {
this.settings.pattern = new RegExp(input.pattern);
} catch(e){}

if( settings && settings.delimiters ){
try {
this.settings.delimiters = new RegExp("[" + settings.delimiters + "]");
} catch(e){}
}

this.id = Math.random().toString(36).substr(2,9), // almost-random ID (because, fuck it)
this.value = []; // An array holding all the (currently used) tags
this.DOM = {}; // Store all relevant DOM elements in an Object
this.extend(this, new this.EventDispatcher());
this.build(input);
this.events();
this.lastSuggestion = ""; //***Added***
}

Tagify.prototype = {
DEFAULTS : {
delimiters : ",", // [regex] split tags by any of these delimiters
pattern : "", // pattern to validate input by
callbacks : {}, // exposed callbacks object to be triggered on certain events
duplicates : false, // flag - allow tuplicate tags
enforceWhitelist : false, // flag - should ONLY use tags allowed in whitelist
autocomplete : true, // flag - show native suggeestions list as you type
whitelist : [], // is this list has any items, then only allow tags from this list
blacklist : [], // a list of non-allowed tags
maxTags : Infinity, // maximum number of tags
suggestionsMinChars : 2, // minimum characters to input to see sugegstions list
maxSuggestions : 10, // ***Modified***
suggestionsUrl : "" // ***Added***

},

/**
* Fixes which require backword support
*/
legacyFixes : function(){
var _s = this.settings;

// For backwards compatibility with older versions, which use 'enforeWhitelist' instead of 'enforceWhitelist'.
if( _s.hasOwnProperty("enforeWhitelist") && !_s.hasOwnProperty("enforceWhitelist") ){
_s["enforceWhitelist"] = _s["enforeWhitelist"];
delete _s["enforeWhitelist"];
console.warn("Please update your Tagify settings. The 'enforeWhitelist' property is deprecated and you should be using 'enforceWhitelist'.");
}
},

/**
* builds the HTML of this component
* @param {Object} input [DOM element which would be "transformed" into "Tags"]
*/
build : function( input ){
var that = this,
value = input.value,
inputHTML = '<div><input list="tagifySuggestions'+ this.id +'" class="placeholder"/><span>'+ input.placeholder +'</span></div>';
this.DOM.originalInput = input;
this.DOM.scope = document.createElement('tags');
input.className && (this.DOM.scope.className = input.className); // copy any class names from the original input element to the Tags element
this.DOM.scope.innerHTML = inputHTML;
this.DOM.input = this.DOM.scope.querySelector('input');

if( this.settings.readonly )
this.DOM.scope.classList.add('readonly')

input.parentNode.insertBefore(this.DOM.scope, input);
this.DOM.scope.appendChild(input);

// if "autocomplete" flag on toggeled & "whitelist" has items, build suggestions list
if( this.settings.autocomplete && (this.settings.whitelist.length || this.settings.suggestionsUrl != '') ){ // ***Modified***
if( "suggestions" in this )
this.suggestions.init();
else
this.DOM.datalist = this.buildDataList();
}

// if the original input already had any value (tags)
if( value )
this.addTags(value).forEach(function(tag){
tag && tag.classList.add('tagify--noAnim');
});
},

/**
* Reverts back any changes made by this component
*/
destroy : function(){
this.DOM.scope.parentNode.appendChild(this.DOM.originalInput);
this.DOM.scope.parentNode.removeChild(this.DOM.scope);
},

/**
* Merge two objects into a new one
*/
extend : function(o, o1, o2){
if( !(o instanceof Object) ) o = {};

if( o2 ){
copy(o, o2)
copy(o, o1)
}
else
copy(o, o1)

function copy(a,b){
// copy o2 to o
for( var key in b )
if( b.hasOwnProperty(key) )
a[key] = b[key];
}

return o;
},

/**
* A constructor for exposing events to the outside
*/
EventDispatcher : function(){
// Create a DOM EventTarget object
var target = document.createTextNode('');

// Pass EventTarget interface calls to DOM EventTarget object
this.off = target.removeEventListener.bind(target);
this.on = target.addEventListener.bind(target);
this.trigger = function(eventName, data){
var e;
if( !eventName ) return;

if( this.isJQueryPlugin )
$(this.DOM.originalInput).triggerHandler(eventName, [data])
else{
try {
e = new CustomEvent(eventName, {"detail":data});
}
catch(err){
e = document.createEvent("Event");
e.initEvent("toggle", false, false);
}
target.dispatchEvent(e);
}
}
},

/**
* DOM events listeners binding
*/
events : function(){
var that = this,
events = {
// event name / event callback / element to be listening to
paste : ['onPaste' , 'input'],
focus : ['onFocusBlur' , 'input'],
blur : ['onFocusBlur' , 'input'],
input : ['onInput' , 'input'],
keydown : ['onKeydown' , 'input'],
click : ['onClickScope' , 'scope']
},
customList = ['add', 'remove', 'duplicate', 'maxTagsExceed', 'blacklisted', 'notWhitelisted'];

for( var e in events )
this.DOM[events[e][1]].addEventListener(e, this.callbacks[events[e][0]].bind(this));

customList.forEach(function(name){
that.on(name, that.settings.callbacks[name])
})

if( this.isJQueryPlugin )
$(this.DOM.originalInput).on('tagify.removeAllTags', this.removeAllTags.bind(this))
},

/**
* DOM events callbacks
*/
callbacks : {
onFocusBlur : function(e){
var text = e.target.value.trim();

if( e.type == "focus" )
e.target.className = 'input';
else if( e.type == "blur" && text ){
if( this.addTags(text).length )
e.target.value = '';
}
else{
e.target.className = 'input placeholder';
this.DOM.input.removeAttribute('style');
}
},

onKeydown : function(e){
var s = e.target.value,
lastTag,
that = this;

if( e.key == "Backspace" && (s == "" || s.charCodeAt(0) == 8203) ){
lastTag = this.DOM.scope.querySelectorAll('tag:not(.tagify--hide)');
lastTag = lastTag[lastTag.length - 1];
this.removeTag( lastTag );
}
if( e.key == "Escape" ){
e.target.value = '';
e.target.blur();
}
if( e.key == "Enter" ){
e.preventDefault(); // solves Chrome bug - http://stackoverflow.com/a/20398191/104380
if( this.addTags(s).length )
e.target.value = '';
return false;
}
else{
if( this.noneDatalistInput ) clearTimeout(this.noneDatalistInput);
this.noneDatalistInput = setTimeout(function(){ that.noneDatalistInput = null }, 50);
}
},

onInput : function(e){
var value = e.target.value,
lastChar = value[value.length - 1],
isDatalistInput = !this.noneDatalistInput && value.length > 1,
showSuggestions = value.length >= this.settings.suggestionsMinChars,
datalistInDOM;

e.target.style.width = ((e.target.value.length + 1) * 7) + 'px';


// if( value.indexOf(',') != -1 || isDatalistInput ){
if( value.slice().search(this.settings.delimiters) != -1 || isDatalistInput ){
if( this.addTags(value).length )
e.target.value = ''; // clear the input field's value
}
else if (this.settings.autocomplete && (this.settings.whitelist.length || this.settings.suggestionsUrl != '') ){ // ***Modified***
datalistInDOM = this.DOM.input.parentNode.contains( this.DOM.datalist );

// if sugegstions should be hidden
if( !showSuggestions && datalistInDOM )
this.DOM.input.parentNode.removeChild(this.DOM.datalist)
else if( showSuggestions ){ // ***Modified***

// ***Added/Modified***
var suggestion = value.substr(0, this.settings.suggestionsMinChars);

if (!datalistInDOM || suggestion !== this.lastSuggestion) {
this.lastSuggestion = suggestion;

var dom = this.DOM;

$(dom.datalist).find('select').empty();

if (this.settings.suggestionsUrl != '') {
$.getJSON(this.settings.suggestionsUrl + this.lastSuggestion, function (data) {
$.each(data, function (index, item) {
console.log(item);
$(dom.datalist).find('select').append('<option>' + item + '</option>');
});

if (datalistInDOM) dom.input.parentNode.removeChild(dom.datalist);
dom.input.parentNode.appendChild(dom.datalist);
});
}
}
// ***End of Added***


}
}
},

onPaste : function(e){
var that = this;
if( this.noneDatalistInput ) clearTimeout(this.noneDatalistInput);
this.noneDatalistInput = setTimeout(function(){ that.noneDatalistInput = null }, 50);
},

onClickScope : function(e){
if( e.target.tagName == "TAGS" )
this.DOM.input.focus();
if( e.target.tagName == "X" ){
this.removeTag( e.target.parentNode );
}
}
},

/**
* Build tags suggestions using HTML datalist
* @return {[type]} [description]
*/
buildDataList : function(){
var OPTIONS = "",
i,
datalist = document.createElement('datalist');

datalist.id = 'tagifySuggestions' + this.id;
datalist.innerHTML = "<label>
select from the list:
<select>
<option value=''></option>
[OPTIONS]
</select>
</label>";

if (this.settings.whitelist.length) { // ***Added***
for( i=this.settings.whitelist.length; i--; )
OPTIONS += "<option>"+ this.settings.whitelist[i] +"</option>";
} // ***Added***

datalist.innerHTML = datalist.innerHTML.replace('[OPTIONS]', OPTIONS); // inject the options string in the right place

// this.DOM.input.insertAdjacentHTML('afterend', datalist); // append the datalist HTML string in the Tags

return datalist;
},

getNodeIndex : function( node ){
var index = 0;
while( (node = node.previousSibling) )
if (node.nodeType != 3 || !/^s*$/.test(node.data))
index++;
return index;
},

/**
* Searches if any tag with a certain value already exis
* @param {String} s [text value to search for]
* @return {boolean} [found / not found]
*/
isTagDuplicate : function(s){
return this.value.some(function(item){ return s.toLowerCase() === item.value.toLowerCase() });
},

/**
* Mark a tag element by its value
* @param {String / Number} value [text value to search for]
* @param {Object} tagElm [a specific "tag" element to compare to the other tag elements siblings]
* @return {boolean} [found / not found]
*/
markTagByValue : function(value, tagElm){
var tagsElms, tagsElmsLen;

if( !tagElm ){
tagsElms = this.DOM.scope.querySelectorAll('tag');
for( tagsElmsLen = tagsElms.length; tagsElmsLen--; ){
if( tagsElms[tagsElmsLen].textContent.toLowerCase().includes(value.toLowerCase()) )
tagElm = tagsElms[tagsElmsLen];
}
}

// check AGAIN if "tagElm" is defined
if( tagElm ){
tagElm.classList.add('tagify--mark');
setTimeout(function(){ tagElm.classList.remove('tagify--mark') }, 2000);
return true;
}

else{

}

return false;
},

/**
* make sure the tag, or words in it, is not in the blacklist
*/
isTagBlacklisted : function(v){
v = v.split(' ');
return this.settings.blacklist.filter(function(x){ return v.indexOf(x) != -1 }).length;
},

/**
* make sure the tag, or words in it, is not in the blacklist
*/
isTagWhitelisted : function(v){
return this.settings.whitelist.indexOf(v) != -1;
},

/**
* add a "tag" element to the "tags" component
* @param {String/Array} tagsItems [A string (single or multiple values with a delimiter), or an Array of Objects]
* @return {Array} Array of DOM elements (tags)
*/
addTags : function( tagsItems ){
var that = this,
tagElems = [];

this.DOM.input.removeAttribute('style');

/**
* pre-proccess the tagsItems, which can be a complex tagsItems like an Array of Objects or a string comprised of multiple words
* so each item should be iterated on and a tag created for.
* @return {Array} [Array of Objects]
*/
function normalizeTags(tagsItems){
var whitelistWithProps = this.settings.whitelist[0] instanceof Object,
isComplex = tagsItems instanceof Array && "value" in tagsItems[0], // checks if the value is a "complex" which means an Array of Objects, each object is a tag
result = tagsItems; // the returned result

// no need to continue if "tagsItems" is an Array of Objects
if( isComplex )
return result;

// search if the tag exists in the whitelist as an Object (has props), to be able to use its properties
if( !isComplex && typeof tagsItems == "string" && whitelistWithProps ){
var matchObj = this.settings.whitelist.filter(function(item){
return item.value.toLowerCase() == tagsItems.toLowerCase();
})

if( matchObj[0] ){
isComplex = true;
result = matchObj; // set the Array (with the found Object) as the new value
}
}

// if the value is a "simple" String, ex: "aaa, bbb, ccc"
if( !isComplex ){
tagsItems = tagsItems.trim();
if( !tagsItems ) return [];

// go over each tag and add it (if there were multiple ones)
result = tagsItems.split(this.settings.delimiters).map(function(v){
return { value:v.trim() }
});
}

return result.filter(function(n){ return n }); // cleanup the array from "undefined", "false" or empty items;
}

/**
* validate a tag object BEFORE the actual tag will be created & appeneded
* @param {Object} tagData [{"value":"text", "class":whatever", ...}]
* @return {Boolean/String} ["true" if validation has passed, String or "false" for any type of error]
*/
function validateTag( tagData ){
var value = tagData.value.trim(),
maxTagsExceed = this.value.length >= this.settings.maxTags,
isDuplicate,
eventName__error,
tagAllowed;

// check for empty value
if( !value )
return "empty";

// check if pattern should be used and if so, use it to test the value
if( this.settings.pattern && !(this.settings.pattern.test(value)) )
return "pattern";

// check if the tag already exists
if( this.isTagDuplicate(value) ){
this.trigger('duplicate', value);

if( !this.settings.duplicates ){
// this.markTagByValue(value, tagElm)
return "duplicate";
}
}

// check if the tag is allowed by the rules set
tagAllowed = !this.isTagBlacklisted(value) && (!this.settings.enforceWhitelist || this.isTagWhitelisted(value)) && !maxTagsExceed;

// Check against blacklist & whitelist (if enforced)
if( !tagAllowed ){
tagData.class = tagData.class ? tagData.class + " tagify--notAllowed" : "tagify--notAllowed";

// broadcast why the tag was not allowed
if( maxTagsExceed ) eventName__error = 'maxTagsExceed';
else if( this.isTagBlacklisted(value) ) eventName__error = 'blacklisted';
else if( this.settings.enforceWhitelist && !this.isTagWhitelisted(value) ) eventName__error = 'notWhitelisted';

this.trigger(eventName__error, {value:value, index:this.value.length});

return "notAllowed";
}

return true;
}

/**
* appened (validated) tag to the component's DOM scope
* @return {[type]} [description]
*/
function appendTag(tagElm){
this.DOM.scope.insertBefore(tagElm, this.DOM.input.parentNode);
}

//////////////////////
tagsItems = normalizeTags.call(this, tagsItems);

tagsItems.forEach(function(tagData){
var isTagValidated = validateTag.call(that, tagData);

if( isTagValidated === true || isTagValidated == "notAllowed" ){
// create the tag element
var tagElm = that.createTagElem(tagData);

// add the tag to the component's DOM
appendTag.call(that, tagElm);

// remove the tag "slowly"
if( isTagValidated == "notAllowed" ){
setTimeout(function(){ that.removeTag(tagElm, true) }, 1000);
}

else{
// update state
that.value.push(tagData);
that.update();
that.trigger('add', that.extend({}, tagData, {index:that.value.length, tag:tagElm}));

tagElems.push(tagElm);
}
}
})

return tagElems
},

/**
* creates a DOM tag element and injects it into the component (this.DOM.scope)
* @param Object} tagData [text value & properties for the created tag]
* @return {Object} [DOM element]
*/
createTagElem : function(tagData){
var tagElm = document.createElement('tag');

// for a certain Tag element, add attributes.
function addTagAttrs(tagElm, tagData){
var i, keys = Object.keys(tagData);
for( i=keys.length; i--; ){
var propName = keys[i];
if( !tagData.hasOwnProperty(propName) ) return;
tagElm.setAttribute(propName, tagData[propName] );
}
}

// The space below is important - http://stackoverflow.com/a/19668740/104380
tagElm.innerHTML = "<x></x><div><span title='"+ tagData.value +"'>"+ tagData.value +" </span></div>";

// add any attribuets, if exists
addTagAttrs(tagElm, tagData);

return tagElm;
},

/**
* Removes a tag
* @param {Object} tagElm [DOM element]
* @param {Boolean} silent [A flag, which when turned on, does not removes any value and does not update the original input value but simply removes the tag from tagify]
*/
removeTag : function( tagElm, silent ){
var tagData,
tagIdx = this.getNodeIndex(tagElm);

if( !tagElm) return;

tagElm.style.width = parseFloat(window.getComputedStyle(tagElm).width) + 'px';
document.body.clientTop; // force repaint for the width to take affect before the "hide" class below
tagElm.classList.add('tagify--hide');

// manual timeout (hack, since transitionend cannot be used because of hover)
setTimeout(function(){
tagElm.parentNode.removeChild(tagElm);
}, 400);

if( !silent ){
tagData = this.value.splice(tagIdx, 1)[0]; // remove the tag from the data object
this.update(); // update the original input with the current value
this.trigger('remove', this.extend({}, tagData, {index:tagIdx, tag:tagElm}));
}
},

removeAllTags : function(){
this.value = [];
this.update();
Array.prototype.slice.call(this.DOM.scope.querySelectorAll('tag')).forEach(function(elm){
elm.parentNode.removeChild(elm);
});
},

/**
* update the origianl (hidden) input field's value
*/
update : function(){
var tagsAsString = this.value.map(function(v){ return v.value }).join(',');
this.DOM.originalInput.value = tagsAsString;
}
}

})(jQuery);

Related Posts

Web Development

10 things a web developer should be careful of

by John Avis | April 23, 2018

Here's ten things every web developer should be aware of and avoid.


Web Development

Why Microsoft's ASP/ASP.NET may be the safe choice for development

by John Avis | February 15, 2018

Some reasons why developing using Microsoft's ASP/ASP.NET has been a good choice over the years.


Web Development

Is ASP.NET better than PHP?

by John Avis | September 21, 2016

This posts stems from my own curiosity. It's a question not a statement. I've been developing in ASP.NET for many years and I like it. I've dabbled a bit in PHP and there are some things I like and some I don't like, but maybe I haven't immersed myself in it enough to know what's great about it.

Comments

There are no comments yet. Be the first to leave a comment!

Leave a Comment
Tags
ASP.NET Html Forms ASP.NET MVC ASP.NET Web Forms ASP.NET Web Pages Bootstrap C# Classic ASP Cool Websites Databases eBay and PayPal Electrical Repairs General Hardware HTML/CSS Jquery/Javascript Media Center Mobile Phones Responsive Web Design SEO and Social Networking Web Design Web Development Web Security web+db Website Hosting Windows XP

About me

...mostly about web development and programming, with a little bit of anything else related to the Internet, computers and technology.

Subscribe

Get the latest posts delivered to your inbox.