In the last tutorial https://serversideup.net/tagging-with-laravel/ we built some tagging API routes for Laravel. Now we need to build the front end. Since we are doing everything in VueJS this will be a VueJS Tag Input component. It can get a little complicated with some of the UX such as showing a tag once it has been added, not allowing users to add the same tag twice, and a few other weird scenarios, but we will go through all of them.

Step 1: Add the TagsInput.vue Component

First, we will need to add the folder /resources/assets/js/components/global/forms and add a TagsInput.vue file. In this file, begin with the standard Vue Component layout:

<style lang="scss">

</style>

<template>
  <div class="tags-input">

  </div>
</template>

<script>
  export default {

  }
</script>

Step 2: Begin Planning Our VueJS Tag Input

So we have a simple component stubbed out, we need to think about everything we want to do.

  1. We will want to auto complete of existing tags.
  2. Ability to add new tags
  3. No duplicate tags added
  4. Ensure the tags get the same transformation into proper form as we do on the PHP side
  5. Style the display to look like an actual tag input as tags get added and allow the user to remove tags.
  6. Ability to remove tags before submitting

Let’s go down the list and add each piece of functionality as we go.

The way that we will approach this tutorial is a little different than the last few. Since we have so many features to the TagInput.vue component, I’m going to put the component here and then talk about each functionality:

<style lang="scss">
  @import '[email protected]/abstracts/_variables.scss';

  div.tags-input-container{
    position: relative;

    div.tags-input{
      display: block;
      -webkit-box-sizing: border-box;
      box-sizing: border-box;
      width: 100%;
      height: auto;
      min-height: 100px;
      padding-top: 4px;
      border: 1px solid #cacaca;
      border-radius: 0;
      background-color: #FFFFFF;
      -webkit-box-shadow: inset 0 1px 2px rgba(17, 17, 17, 0.1);
      box-shadow: inset 0 1px 2px rgba(17, 17, 17, 0.1);
      font-family: inherit;
      font-size: 1rem;
      font-weight: normal;
      line-height: 1.5;
      color: #111111;

      div.selected-tag{
        border: 1px solid $dark-color;
            background: $highlight-color;
            font-size: 18px;
            color: $dark-color;
            padding: 3px;
            margin: 5px;
            float: left;
            border-radius: 3px;

        span.remove-tag{
            margin: 0 0 0 5px;
            padding: 0;
            border: none;
            background: none;
            cursor: pointer;
            vertical-align: middle;
            color: $dark-color;
        }
      }

      input[type="text"].new-tag-input{
        border: 0px;
        margin: 0px;
        float: left;
        width: auto;
        min-width: 100px;
        -webkit-box-shadow: none;
        box-shadow: none;
        margin: 5px;

        &.duplicate-warning{
          color: red;
        }

        &:focus{
          box-shadow: none;
        }
      }
    }

    div.tag-autocomplete{
      position: absolute;
      background-color: white;
      width: 100%;
      padding: 5px 0;
      z-index: 99999;
      border: 1px solid rgba(0,0,0,0.2);
      -webkit-box-shadow: 0 5px 10px rgba(0,0,0,0.2);
      -moz-box-shadow: 0 5px 10px rgba(0,0,0,0.2);
      box-shadow: 0 5px 10px rgba(0,0,0,0.2);

      div.tag-search-result{
        padding: 5px 10px;
        cursor: pointer;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        color: $dark-color;
        font-size: 14px;
        background-color: white;

        &:hover{
          background-color: $highlight-color;
        }
        &.selected-search-index{
          background-color: $highlight-color;
        }
      }
    }
  }
</style>

<template>
  <div class="tags-input-container">
    <label>Tags</label>
    <div class="tags-input" v-on:click="focusTagInput()">
      <div class="selected-tag" v-for="(selectedTag, key) in tagsArray">{{ selectedTag }} <span class="remove-tag" v-on:click="removeTag( key )">&times;</span> </div>
      <input type="text" v-bind:id="unique" class="new-tag-input" v-model="currentTag" v-on:keyup="searchTags" v-on:keyup.enter="addNewTag" v-on:keydown.up="changeIndex( 'up' )" v-on:keydown.delete="handleDelete" v-on:keydown.down="changeIndex( 'down' )" v-bind:class="{ 'duplicate-warning' : duplicateFlag }" placeholder="Add a tag"/>
    </div>
    <div class="tag-autocomplete" v-show="showAutocomplete">
      <div class="tag-search-result" v-for="(tag, key) in tagSearchResults" v-bind:class="{ 'selected-search-index' : searchSelectedIndex == key }" v-on:click="selectTag( tag.tag )">{{ tag.tag }}</div>
    </div>
  </div>
</template>

<script>
  /*
    Imports the Roast API URL from the config.
  */
  import { ROAST_CONFIG } from '../../../config.js';

  /*
    Imports the Event Bus to pass events on tag updates
  */
  import { EventBus } from '../../../event-bus.js';

  /*
    Exports the default components.
  */
  export default {
    props: ['unique'],

    /*
      Defines the data used by the component.
    */
    data(){
      return {
        currentTag: '',
        tagsArray: [],
        tagSearchResults: [],
        duplicateFlag: false,
        searchSelectedIndex: -1,
        pauseSearch: false
      }
    },

      /*
      Clear tags
    */
    mounted(){
      EventBus.$on('clear-tags', function( unique ){
        this.currentTag = '';
        this.tagsArray = [];
        this.tagSearchResults = [];
        this.duplicateFlag = false;
        this.searchSelectedIndex = -1;
        this.pauseSearch = false;
      }.bind(this));
    },

    /*
      Defines the computed data.
    */
    computed: {
      /*
        Determines if we should show the autocomplete or not.
      */
            showAutocomplete: function(){
                return this.tagSearchResults.length == 0 ? false : true;
            }
        },

    /*
      Defines the methods used by the component.
    */
    methods: {
      /*
        Handles the selection of a tag from the autocomplete.
      */
      selectTag( tag ){
        /*
          Check if there are duplicates in the array.
        */
        if( !this.checkDuplicates( tag ) ){
          /*
            Clean the tag name and add it to the array.
          */
          tag = this.cleanTagName( tag );
          this.tagsArray.push( tag );

          /*
            Emit the tags array and reset the inputs.
          */
          EventBus.$emit( 'tags-edited', { unique: this.unique, tags: this.tagsArray } );

          this.resetInputs();
        }else{
          /*
            Flag as duplicate
          */
          this.duplicateFlag = true;
        }
      },

      /*
        Adds a new tag from the input
      */
      addNewTag(){
        /*
          If the tag is not a duplicate, continue.
        */
        if( !this.checkDuplicates( this.currentTag ) ){
          var newTagName = this.cleanTagName( this.currentTag );
          this.tagsArray.push( newTagName );

          /*
            Emit the tags have been edited.
          */
          EventBus.$emit( 'tags-edited', { unique: this.unique, tags: this.tagsArray } );

          /*
            Reset the inputs
          */
          this.resetInputs();
        }else{
          this.duplicateFlag = true;
        }
      },

      /*
        Remove the tag from the tags array.
      */
      removeTag( tagIndex ){
        this.tagsArray.splice( tagIndex, 1 );

        /*
          Emit that the tags have been edited.
        */
        EventBus.$emit( 'tags-edited', { unique: this.unique, tags: this.tagsArray } );
      },

      /*
        Allows the user to select a tag going up or down on the
        autocomplete.
      */
      changeIndex( direction ){
        /*
          Flags to pause the search since we don't want to search on arrows up
          or down.
        */
                this.pauseSearch = true;

        /*
          If the direction is up and we are not at the beginning of the tags array,
          we move the index up and set the current tag to that in the autocomplete.
        */
                if( direction == 'up' && ( this.searchSelectedIndex -1 > -1 ) ){
                    this.searchSelectedIndex = this.searchSelectedIndex - 1;
                    this.currentTag = this.tagSearchResults[this.searchSelectedIndex].tag;
                }

        /*
          If the direction is down and we are not at the end of the tags array, we
          move the index down and set the current tag to that of the autocomplete.
        */
                if( direction == 'down' && ( this.searchSelectedIndex + 1 <= this.tagSearchResults.length - 1 ) ){
                    this.searchSelectedIndex = this.searchSelectedIndex + 1;
                    this.currentTag = this.tagSearchResults[this.searchSelectedIndex].tag;
                }
            },

      /*
        Searches the API route for tags with the autocomplete.
      */
      searchTags(){
        if( this.currentTag.length > 2 && !this.pauseSearch ){
          this.searchSelectedIndex = -1;
          axios.get( ROAST_CONFIG.API_URL + '/tags' , {
            params: {
              search: this.currentTag
            }
          }).then( function( response ){
            this.tagSearchResults = response.data;
          }.bind(this));
        }
      },

      /*
        Check for tag duplicates.
      */
      checkDuplicates( tagName ){
                tagName = this.cleanTagName( tagName );

                return this.tagsArray.indexOf( tagName ) > -1;
            },

      /*
        Cleans the tag to remove any unnecessary whitespace or
        symbols.
      */
      cleanTagName( tagName ){
        /*
          Convert to lower case
        */
        var cleanTag = tagName.toLowerCase();

        /*
          Trim whitespace from beginning and end of tag and
          convert anything not a letter or number to a dash.
        */
        cleanTag = cleanTag.trim().replace(/[^a-zA-Z0-9]/g,'-');

        /*
          Remove multiple instance of '-' and group to one.
        */
        cleanTag = cleanTag.replace(/-{2,}/, '-');

        /*
          Get rid of leading and trailing '-'
        */
        cleanTag = this.trimCharacter(cleanTag, '-');

        /*
          Return the clean tag
        */
        return cleanTag;
      },

      /*
        Remove the tag from the tags array.
      */
      removeTag( tagIndex ){
        this.tagsArray.splice( tagIndex, 1 );
      },

      /*
        Trims any leading or ending characters
      */
      trimCharacter (string, character) {
        if (character === "]") c = "\\]";
        if (character === "\\") c = "\\\\";
        return string.replace(new RegExp(
          "^[" + character + "]+|[" + character + "]+$", "g"
        ), "");
      },

      /*
        Reset the inputs for the tags input
      */
      resetInputs(){
        this.currentTag = '';
        this.tagSearchResults = [];
        this.duplicateFlag = false;
        this.searchSelectedIndex = -1;
                this.pauseSearch = false;
      },

      /*
        Focus on the tag input.
      */
      focusTagInput(){

                document.getElementById( this.unique ).focus();
            },

      /*
        Handles the deletion in the tag input.
      */
            handleDelete(){
                this.duplicateFlag = false;
                this.pauseSearch = false;
                this.searchSelectedIndex = -1;

        /*
          If the current tag has no data, we remove the last tag.
        */
                if( this.currentTag.length == 0 ){
                    this.tagsArray.splice( this.tagsArray.length - 1, 1);
          /*
            Emit that the tags have been edited.
          */
          EventBus.$emit( 'tags-edited', { unique: this.unique, tags: this.tagsArray } );               }
            }
    }
  }
</script>

Step 3: Explain the Autocomplete

The first functionality of the tag input we will run through is the autocomplete. This is one of the few places where we will be making API calls outside of a Vuex structure. The functionality will run through the searchTags() method:

searchTags(){
  if( this.currentTag.length > 2 && !this.pauseSearch ){
    this.searchSelectedIndex = -1;
    axios.get( ROAST_CONFIG.API_URL + '/tags' , {
      params: {
        search: this.currentTag
      }
    }).then( function( response ){
      this.tagSearchResults = response.data;
    }.bind(this));
  }
},

This method does a few things. First, it checks to see if the model for our tag input is greater than two characters in length. We don’t want to start right away because we don’t have enough characters to make a helpful search. Second, we check to see if the search is paused. This only happens in the changeIndex() method:

/*
  Allows the user to select a tag going up or down on the
  autocomplete.
*/
changeIndex( direction ){
  /*
    Flags to pause the search since we don't want to search on arrows up
    or down.
  */
  this.pauseSearch = true;

  /*
    If the direction is up and we are not at the beginning of the tags array,
    we move the index up and set the current tag to that in the autocomplete.
  */
  if( direction == 'up' && ( this.searchSelectedIndex -1 > -1 ) ){
    this.searchSelectedIndex = this.searchSelectedIndex - 1;
    this.currentTag = this.tagSearchResults[this.searchSelectedIndex].tag;
  }

  /*
    If the direction is down and we are not at the end of the tags array, we
    move the index down and set the current tag to that of the autocomplete.
  */
  if( direction == 'down' && ( this.searchSelectedIndex + 1 <= this.tagSearchResults.length - 1 ) ){
    this.searchSelectedIndex = this.searchSelectedIndex + 1;
    this.currentTag = this.tagSearchResults[this.searchSelectedIndex].tag;
  }
},

What the change index method does with respect to auto complete is when the user presses up or down it selects the autocomplete at the index. We pause the search during this selection so no unnecessary API calls are made on keydown with the arrow buttons.

In the searchTags() method, we make an axios get request to our autocomplete API with the value of the current tag. When we receive the successful response, we set the tagSearchResults variable to the response from the API.

There’s a few helpers that go along with the auto complete. This includes the selectTag() method and showAutocomplete computed method.

The selectTag() method occurs when the user selects a tag from the autocomplete and adds it to the tag array:

/*
  Handles the selection of a tag from the autocomplete.
*/
selectTag( tag ){
  /*
    Check if there are duplicates in the array.
  */
  if( !this.checkDuplicates( tag ) ){
    /*
      Clean the tag name and add it to the array.
    */
    tag = this.cleanTagName( tag );
    this.tagsArray.push( tag );

    /*
      Emit the tags array and reset the inputs.
    */
    EventBus.$emit( 'tags-edited', { unique: this.unique, tags: this.tagsArray } );

    this.resetInputs();
  }else{
    /*
      Flag as duplicate
    */
    this.duplicateFlag = true;
  }
},

First it checks for duplicates (explained in next step) and then it cleans the tag name (also explained in next step). Then, the tag is added to the tagsArray and the EventBus emits a tags-edited event. This is so wherever we implement the tags input we have access to the data when it’s changed.

Step 4: Explain Check Duplicates and Clean Tags

Part of the validations when selecting a tag (or adding a tag) is checking for duplicates and cleaning the tag.

We check for duplicates so the user can’t add more than one of the same tag to the cafe. The method is extremely simple:

/*
  Check for tag duplicates.
*/
checkDuplicates( tagName ){
  tagName = this.cleanTagName( tagName );

  return this.tagsArray.indexOf( tagName ) > -1;
},

First, we clean the tag name then we check to see if the clean tag name has an index on the tagsArray. This is represented if the index is greater than -1.

The next method we will explain is the cleanTagName() method which formats a tag appropriately:

/*
  Cleans the tag to remove any unnecessary whitespace or
  symbols.
*/
cleanTagName( tagName ){
  /*
    Convert to lower case
  */
  var cleanTag = tagName.toLowerCase();

  /*
    Trim whitespace from beginning and end of tag and
    convert anything not a letter or number to a dash.
  */
  cleanTag = cleanTag.trim().replace(/[^a-zA-Z0-9]/g,'-');

  /*
    Remove multiple instance of '-' and group to one.
  */
  cleanTag = cleanTag.replace(/-{2,}/, '-');

  /*
    Get rid of leading and trailing '-'
  */
  cleanTag = this.trimCharacter(cleanTag, '-');

  /*
    Return the clean tag
  */
  return cleanTag;
},

We want all of the tags to have lower case characters and special characters replaced with -. We then need to replace any grouping of more than one - with a single -. Then we trim the leading and trailing -.

This is the exact same approach we take on the PHP side explained in https://serversideup.net/tagging-with-laravel/. It ensures we are consistent with our naming conventions.

The trimCharacter() method simply trims any character from the leading or ending part of the string:

/*
  Trims any leading or ending characters
*/
trimCharacter (string, character) {
  if (character === "]") c = "\\]";
  if (character === "\\") c = "\\\\";
  return string.replace(new RegExp(
    "^[" + character + "]+|[" + character + "]+$", "g"
  ), "");
},

thanks to javascript – Trim specific character from a string – Stack Overflow.

Step 5: Explain AddNewTag()

The addNewTag() method gets called when a user hits enter on the tag input:

/*
  Adds a new tag from the input
*/
addNewTag(){
  /*
    If the tag is not a duplicate, continue.
  */
  if( !this.checkDuplicates( this.currentTag ) ){
    var newTagName = this.cleanTagName( this.currentTag );
    this.tagsArray.push( newTagName );

    /*
      Emit the tags have been edited.
    */
    EventBus.$emit( 'tags-edited', { unique: this.unique, tags: this.tagsArray } );

    /*
      Reset the inputs
    */
    this.resetInputs();
  }else{
    this.duplicateFlag = true;
  }
},

First the method checks to see if there are duplicate tags. If there aren’t any duplicates, then the tag is cleaned, added to the array, and an event is emitted. Finally, the resetInputs() method is called which sets the inputs back to their initial state for the TagsInput component.

Step 6: Explain Helper Methods

There’s a couple helper methods in the component that need to be explained.

First, we have the resetInputs() method:

this.currentTag = '';
this.tagSearchResults = [];
this.duplicateFlag = false;
this.searchSelectedIndex = -1;
this.pauseSearch = false;

This simply resets the inputs to what they were initialized to.

Next, we have the focusTagInput():

/*
  Focus on the tag input.
*/
focusTagInput(){

  document.getElementById( this.unique ).focus();
},

What this does, is since we are simulating a large text box to be filled with tags, when the user clicks on the text box, it focuses the cursor to the autocomplete. The unique variable is passed as a parameter to the tags input by the user. This focuses the proper input for the tag.

Finally, we have the handleDelete() method. This method is fired when the user clicks delete in the text input box:

/*
  Handles the deletion in the tag input.
*/
handleDelete(){
  this.duplicateFlag = false;
  this.pauseSearch = false;
  this.searchSelectedIndex = -1;

  /*
    If the current tag has no data, we remove the last tag.
  */
  if( this.currentTag.length == 0 ){
    this.tagsArray.splice( this.tagsArray.length - 1, 1);
    /*
      Emit that the tags have been edited.
    */
    EventBus.$emit( 'tags-edited', { unique: this.unique, tags: this.tagsArray } );
  }
}

What we do here is clear out any duplicate flags, pause searching, search selected index since the user pressed delete. Next, we check to see if there is any text entered. If there isn’t then we delete the last tag from the array and emit a tags-edited event.

When mounted, we also add an event listener for the clear-tags event on the EventBus. When this happens, we reset the components back to nothing.

Step 7: Explain Template

The template for the tags component is pretty dense:

<template>
  <div class="tags-input-container">
    <label>Tags</label>
    <div class="tags-input" v-on:click="focusTagInput()">
      <div class="selected-tag" v-for="(selectedTag, key) in tagsArray">{{ selectedTag }} <span class="remove-tag" v-on:click="removeTag( key )">×</span> </div>
      <input type="text" class="new-tag-input" v-model="currentTag" v-on:keyup="searchTags" v-on:keyup.enter="addNewTag" v-on:keydown.up="changeIndex( 'up' )" v-on:keydown.delete="handleDelete" v-on:keydown.down="changeIndex( 'down' )" v-bind:class="{ 'duplicate-warning' : duplicateFlag }" placeholder="Add a tag"/>
    </div>
    <div class="tag-autocomplete" v-show="showAutocomplete">
      <div class="tag-search-result" v-for="(tag, key) in tagSearchResults" v-bind:class="{ 'selected-search-index' : searchSelectedIndex == key }" v-on:click="selectTag( tag.tag )">{{ tag.tag }}</div>
    </div>
  </div>
</template>

We first wrap everything in the tag input container which we style as relative. This allows us to position absolute below the input box our autocomplete for the tags.

Then we have our tags-input which, when clicked on, calls the method to focus on the input for entering the tag.

The tag input itself has a few event handlers. When enter is pressed, we add a new tag with the text entered. When up or down is pressed, we navigate through the auto complete, filling in the new tag.

We also bind the duplicate warning class to the tag if the flag for duplicate warning is set.

The CSS we use simply makes the tags look more like tags:

Conclusion

Tagging components can get very complicated very quickly, hopefully this tutorial helped guide you through the process. If not, feel free to comment below!

Also, check out the source code:
GitHub – serversideup/roastandbrew: Helping the coffee enthusiast find their next cup of coffee. Also, helping aspiring web and mobile app developers build a single page app and converting it to a mobile hybrid. All tutorials can be found at https://serversideup.net

Next, we will be implementing our component into a few pages.