Make your own tagging system from scratch

Build a tagging tool from scratch rather than using one that is pre-made. You get more control, there isn't unused code, and you learn something too. In this article I will go through the process of building a tagging system from scratch.

Our finished product populated with animal types.

Much of the css will be left as an exercise for the reader.

Generally one looks like a regular text input at the bottom of a form, but contained inside are pill shaped elements representing relevant tags. To the right of the tags is a place to add new ones. When you start typing tag suggestions appear and if you click on one the tag is added, or if you type your own and press enter, a new tag will be added.

First I'll introduce you to the interface our module uses to keep track of each suggestion in Typescript.

interface ISuggestion {
    value: string;
    count: number;
    element?: HTMLElement;
}

We store a value which will be the tag, count indicating how common the tag is, and an element. Suggestion data will be stored on objects structured this way.

class ItemsInput {

    // ui elements
    element: HTMLElement;
    parts: {
        items: HTMLElement,
        input: HTMLInputElement,
        fields: HTMLElement;
        suggestions?: HTMLElement
    };

    // data
    items: number[] = [];
    suggestions: ISuggestion[] = [];

    constructor (element: HTMLElement)
    {
        // find important elements
        this.element = element;
        this.parts = {
            items: <HTMLElement>this.element.querySelector('.items'),
            input: <HTMLInputElement>this.element.querySelector('.items input'),
            fields: <HTMLElement>this.element.querySelector('.fields')
        };
    }

}

document.addEventListener('DOMContentLoaded', () => {

    // relevant elements
    let elements = document.querySelectorAll('.items-input');
    for (let i = 0; i < elements.length; i++) {
        new ItemsInput(<HTMLElement>elements.item(i));
    }

});

We see a simple structure that looks for items-input elements throughout our document and builds a new instance of ItemsInput for each. A few key data objects are created and references to significant elements are stored.

At this stage we need starter html.

<div class="items-input" data-name="tags">
    <div class="items"><input placeholder="Tags"></div>
    <div class="fields"></div>
</div>

As stated earlier css should remain an exercise for the reader but we have here a container named items-input which is unstyled. A items element which is intended to look like any other text input. And a real input within it, which should blend seamlessly into the background.

All content of items will be floated left, so set overflow: hidden; to ensure page flow.

We use the generic name items quite a bit. This is so that in general we can add a number of similar fields if we wanted. Data attribute name should be treated like you would the name attribute on an input. In this case we will expect the form to return an array of values named tags.

The fields element stores your hidden fields which reflect added tags, they are generated by our script. It is marked display: none; in your css. We are handling creation of the suggestions element ourselves. It will be dynamically built when we need it and contain suggestions for the user to choose from.

suggest (value: string)
{
    if (!this.suggestions.length)
        return;
    // suggest possible matches
    this._populateSuggestionsElement(value);
    this.parts.suggestions.classList.remove('is-hidden');
}

unsuggest ()
{
    // hide
    if (this.parts.suggestions)
        this.parts.suggestions.classList.add('is-hidden');
}

We trigger suggest when we want suggestions to appear or change.

private _populateSuggestionsElement (value: string)
{
    this._buildSuggestionsElement();
    // clear list
    while (this.parts.suggestions.firstChild) {
        this.parts.suggestions.removeChild(this.parts.suggestions.firstChild);
    }
    // TODO: find suggestions for value
}

private _buildSuggestionsElement ()
{
    if (this.parts.suggestions)
        return;
    // create suggestions element
    this.parts.suggestions = document.createElement('div');
    this.parts.suggestions.classList.add('suggestions', 'is-hidden');
    this.element.appendChild(this.parts.suggestions);
}

We will be adding event listeners all over the place in a little bit. This is the generation code which runs whenever the list of suggestions is to be updated. Our suggestions element is only built once, the first time it is needed. It is cleared out and filled again with relevant tags as often as we call the suggest method.

private _getSuggestionElement (suggestion: ISuggestion): HTMLElement
{
    if (!suggestion.element) {
        // create suggestion element
        let element = document.createElement('div');
        element.classList.add('suggestion');
        element.appendChild(document.createTextNode(suggestion.value));
        element.addEventListener('click', () => {
            this.addItem(suggestion.value);
            this.parts.input.value = "";
            this.parts.input.focus();
            this.unsuggest();
        });
        suggestion.element = element;
    }
    return suggestion.element;
}

The savvy among you will see we are building an element for every suggestion only once and storing it on our suggestion object. These are generated on an as needed basis, they can handily be added and removed from the DOM quickly.

A click event handler is attached which triggers an addItem method call, we should look at it.

addItem (value: string)
{
    if (!value)
        return;
    // add the item
    let index = this._getSuggestionIndex(value);
    if (this.items.indexOf(index) <= -1) {
        // new tag
        this._addItemElements(index);
        this.items.push(index);
    }
}

private _addItemElements (index: number)
{
    let value = this.suggestions[index].value;

    // physically add item to form
    let item = document.createElement('div');
    item.classList.add('item');
    item.appendChild(document.createTextNode(value));
    item.addEventListener('click', () => {
        // ability to remove again
        this.removeItem(value);
        this.parts.input.select();
    });
    this.parts.items.insertBefore(item, this.parts.input);

    // hidden input too
    let field = document.createElement('input');
    field.setAttribute('type', 'hidden');
    field.setAttribute('name', (this.element.dataset['name'] || "items") + "[]");
    field.setAttribute('value', value);
    this.parts.fields.appendChild(field);
}

removeItem (value: string)
{
    if (!value)
        return;
    // physically remove item from form
    let index = this.items.indexOf(this._getSuggestionIndex(value)); 
    if (index > -1) {
        // found
        this.parts.items.removeChild(this.parts.items.children.item(index));
        this.parts.fields.removeChild(this.parts.fields.children.item(index));
        this.items.splice(index, 1);
    }
}

The savvy among you will be asking "what the heck?"

When adding a tag we store only an index. Our items data array references a suggestion in every case. Therefore a local suggestion is found or created every time we add a tag.

Adding and removing tags happens not nearly often as suggestions are requested, so we are comfortable creating and destroying those elements rather than keeping them in memory.

private _getSuggestionIndex (value: string): number
{
    // find suggestion by value
    for (let index = 0; index < this.suggestions.length; index++) {
        if (this.suggestions[index].value.toLowerCase() == value.toLowerCase()) {
            // found
            return index;
        }
    }
    // doesn't exist add one
    this.suggestions.push({ value: value, count: 0 });
    return this.suggestions.length - 1;
}

The reason we store a count on suggestions is for popularity of the tag. Ideally the most heavily used relevant tags would soar to the first set of results. In cases where the tag is new, a count of 0 is accurate.

We haven't actually populated our suggestions pool with any data yet. We're getting there, but if the software were to work at this stage. Adding then removing a tag, would offer that tag in the suggestions list.

Lets add some event handlers so that our input is functional.

private _isHovering: boolean = false;

constructor (element: HTMLElement)
{
    // ... previous code

    // div behaves similar to input
    this.parts.items.addEventListener('click', () => { this.parts.input.select(); });
    // prevent default behaviour on real input
    this.parts.input.addEventListener('click', (e: Event) => { e.stopPropagation(); });
    this.parts.input.addEventListener('keyup', this._preventSubmit.bind(this));
    this.parts.input.addEventListener('keypress', this._preventSubmit.bind(this));
    // check input value
    this.parts.input.addEventListener('keyup', this._onKeyup.bind(this));
    this.parts.input.addEventListener('focus', this.checkValue.bind(this));
    this.parts.input.addEventListener('blur', this._onBlur.bind(this));
}

private _preventSubmit (e: KeyboardEvent)
{
    if ([13,169].indexOf(e.which || e.charCode || e.keyCode) > -1) {
        // enter key pressed
        e.preventDefault();
        this.addItem(this.parts.input.value);
        this.parts.input.value = "";
        this.parts.input.focus();
        this.unsuggest();
    }
}

private _onBlur ()
{
    if (!this._isHovering) {
        // removed focus
        if (this.parts.input.value) {
            this.addItem(this.parts.input.value);
            this.parts.input.value = "";
        }
        this.unsuggest();
    }
}

private _onKeyup (e: KeyboardEvent)
{
    if ([13,169,37,38,39,40].indexOf(e.which || e.charCode || e.keyCode) <= -1) {
        // non-arrow non-enter key pressed
        this.checkValue();
    }
}

checkValue ()
{
    // see if there are suggestions
    if (!this.parts.input.value)
        this.unsuggest();
    else
        this.suggest(this.parts.input.value);
}

private _buildSuggestionsElement ()
{
    // ... previous code

    this.parts.suggestions.addEventListener('mouseenter', () => { this._isHovering = true; });
    this.parts.suggestions.addEventListener('mouseleave', () => {
        this._isHovering = false;
        if (this.parts.input != document.activeElement)
            this._onBlur();
    });
}

We track whether the user is hovering our suggestions, because we don't want suggestions to disappear as soon as they they remove focus from input. Clicking on the items element selects text in the input. Clicking the input directly allows the user to change their cursor position without selecting all text.

Suggestions are presented whenever text is entered in the input. If the input loses focus with text still in it, adds it as a tag.

There is still one thing missing. We added a TODO way up there within the all so important _populateSuggestionsElement function. We'll do that now.

private _populateSuggestionsElement (value: string)
{
    // ... previous code

    // find suggestions for value
    let results: ISuggestion[] = [];
    for (let index = 0; index < this.suggestions.length; index++) {
        if (this.items.indexOf(index) <= -1) {
            // not already added
            let suggestion = this.suggestions[index];
            // match
            if (suggestion.value.toLowerCase().indexOf(value.toLowerCase()) > -1)
                results.push(suggestion);
        }
    }

    // common or alphabetical order
    results.sort((a, b) => {
        let alphabetical = ((a.value < b.value) ? -1 : ((a.value > b.value) ? 1 : 0));
        return ((a.count > b.count) ? -1 : ((a.count < b.count) ? 1 : alphabetical));
    });

    // populate suggestions element
    for (let suggestion of results) {
        this.parts.suggestions.appendChild(this._getSuggestionElement(suggestion));
    }
}

That doesn't seem so complicated. We don't show suggestions for tags that have already been added. Otherwise it's fair game to match the value of input with any available suggestions.

Once we have a list of suggestions we want to show, we sort them by popularity and then alphabetically. Here we see the first use of _getSuggestionElement which is used to populate suggestions.

You have something which is fully functional at this point.

But there are still two things missing. We would like to be able to populate our specialised field with already selected tags. As well, we still haven't delivered our module a set of valid suggestions to use.

You may choose to populate already selected fields however you like, I have opted to include a data attribute during page load. With something like data-values="tag1,tag2,tag33" on element you can do the following in your constructor.

if (this.element.dataset['values']) {
    let values = this.element.dataset['values'].split(',');
    for (let value of values) {
        this.addItem(value);
    }
}

Populating suggestions is a little bit more involved, you will need something server side which delivers a list of valid tags. I'll leave this as an exercise for the reader. In my example I've returned a JSON result set in the format of { value: string, count: number }[].

I've chosen to implement the addition of suggestions as follows, overwriting our DOMContentLoaded event from earlier. Sorry for overwriting existing code it's easier to show this way.

document.addEventListener('DOMContentLoaded', () => {

    let request = new XMLHttpRequest();
    request.responseType = 'json';
    request.open("GET", "/tag-suggestions", true);

    // relevant elements
    let elements = document.querySelectorAll('.items-input');
    for (let i = 0; i < elements.length; i++) {
        let instance = new ItemsInput(<HTMLElement>elements.item(i));
        request.addEventListener('load', () => {
            if (request.readyState == 4 && request.status == 200)
                instance.addSuggestions(request.response);
        });
    }

    request.send();

});

And the addSuggestions method which is now needed in our class.

private _addSuggestion (value: string, count: number)
{
    // update count if exists
    for (let suggestion of this.suggestions) {
        if (suggestion.name.toLowerCase() == name.toLowerCase()) {
            // exists
            suggestion.count = count;
            return;
        }
    }
    // doesn't exist
    this.suggestions.push({ value: value, count: count });
}

addSuggestions (suggestions: { value: string, count: number }[] = [])
{
    for (let suggestion of suggestions) {
        this._addSuggestion(suggestion.value, suggestion.count);
    }
}

We now have a full tagging system on the client side. You can use this for tags, or anything else your devious underhanded programmers' mind may decide. For example I'm using something similar in my references system on this blog. Those are the links you see along the bottom of the article.

It's refreshing to build systems like this yourself, as it puts the destiny of your product wholly into your own hands. Instead of falling at the mercy of the developer who made your plugin or extension.

Hopefully this tutorial will be of use to you.