Source: jquery.tagger.js

/*
 * jQuery UI Tagger
 *
 * @version v0.7.1 (11/2015)
 *
 * Copyright 2015, Fivium Ltd.
 * Released under the BSD 3-Clause license.
 * https://github.com/fivium/jquery-tagger/blob/master/LICENSE
 *
 * Homepage:
 *   https://github.com/fivium/jquery-tagger/
 *
 * Authors:
 *   Nick Palmer
 *   Ben Basson
 *
 * Maintainer:
 *   Nick Palmer - nick.palmer@fivium.co.uk
 *
 * Dependencies:
 *   jQuery v1.9+
 *   jQuery UI v1.10+
 */

// Can be run through JSDoc (https://github.com/jsdoc3/jsdoc) and JSHint (http://www.jshint.com/)

/*jshint laxcomma: true, laxbreak: true, strict: false */

/**
 * See (http://jquery.com/).
 * @name jQuery
 * @class
 * See the jQuery Library  (http://jquery.com/) for full details. This just
 * documents the function and classes that are added to jQuery by this plug-in.
 */

/**
 * See (http://jquery.com/)
 * @name widget
 * @class
 * See the jQuery Library  (http://jquery.com/) for full details. This just
 * documents the function and classes that are added to jQuery by this plug-in.
 * @memberOf jQuery
 */

/**
 * See (http://jquery.com/)
 * @name ui
 * @class
 * See the jQuery Library  (http://jquery.com/) for full details. This just
 * documents the function and classes that are added to jQuery by this plug-in.
 * @memberOf jQuery.widget
 */

(function ($) {
  /**
   * tagger - Autocomplete and tagging widget
   *
   * @class tagger
   * @memberOf jQuery.widget.ui
   * @param {object} options - Must pass in the available tags and optionally
   *                           other information also
   * @version 0.7.1
   * @license http://github.com/fivium/jquery-tagger/blob/master/LICENSE
   * @copyright Fivium ltd.
   * @author Nick Palmer
   */
  $.widget('ui.tagger', /** @lends jQuery.widget.ui.tagger */ {

    /**
     * Default options, can be overridden by passing in an object to the constructor with these properties
     * @property {Array}    availableTags       - Array of JSON tag objects
     * @property {Array}    ajaxURL             - URL to autocomplete webservice for updating available tags
     * @property {Array}    preselectedTags     - Array of tag ID's that are selected in the element (helps performance)
     * @property {number}   characterThreshold  - How many characters must be typed before searching
     * @property {number}   characterLimit      - How many characters can be entered into the input box
     * @property {number}   typingTimeThreshold - How many milliseconds to wait after the last keypress before filtering
     * @property {boolean}  caseSensitive       - Case sensitive searching - defaults to false
     * @property {string}   placeholder         - Placeholder text for input area
     * @property {string}   baseURL             - Base URL used for images
     * @property {string}   imgDownArrow        - URL for down arrow image (after baseURL)
     * @property {string}   imgRemove           - URL for remove image (after baseURL)
     * @property {string}   imgSearch           - URL for search image (after baseURL)
     * @property {boolean}  sortedOutput        - Sort the suggestion lists by tag.sort
     * @property {boolean}  displayHierarchy    - Indent suggestions to show hierarchy
     * @property {number}   indentMultiplier    - When indenting suggestions, how much to multiple tag.level by
     * @property {number}   tabindexOffset      - Then creating items it can tab to, what the tabindex should initially be
     * @property {string}   noSuggestText       - Text to show when no suggestions can be found
     * @property {string}   emptyListText       - Text to show when no suggestions in the list
     * @property {string}   searchTooltipText   - Text to show as tooltip for the ajax search icon
     * @property {string}   ajaxErrorFunction   - Function definition to use in the event of an AJAX request error, function(tagger, data)
     * @property {string}   loadingClass        - Class on an sibling to the select used to fill while the js loads the tagger
     * @property {number}   inputExpandExtra    - How many extra pixels to add on to the end of an input when expanding
     * @property {string}   fieldWidth          - Override width e.g. 20em
     * @property {string}   fieldHeight         - Override height e.g. 20em
     * @property {string}   suggestWidth        - Set a hard width for the suggestion list (overrides maxwidth) e.g. 50em
     * @property {string}   suggestMaxWidth     - Max width of the suggestion list (so it can be wider than the field) e.g. 50em
     * @property {string}   suggestMaxHeight    - Max height of the suggestion list e.g. 20em
     * @property {boolean}  mandatorySelection  - Make it mandatory that a value is chosen - defaults to false, no effect in multiselect mode
     * @property {boolean}  clearFilterOnBlur   - Clear the filter text if any was left when the field loses focus (stops users thinking typed in text will be sent)
     * @property {boolean}  freeTextInput       - Enable users to create options not defined in availableTags by hitting enter after typing text
     * @property {string}   freeTextPrefix      - Optional string to prefix all free text option values with (helpful to differentiate server-side)
     * @property {string}   freeTextMessage     - HTML string to show in the suggestions list containing the free text to hint that it can be added e.g. Add <em>%VALUE%</em> to list
     * @property {string}   freeTextSuggest     - Allow free text values in the select to show up in the suggestions list
     */
    options: {
      availableTags       : null
    , ajaxURL             : null
    , preselectedTags     : null
    , characterThreshold  : -1
    , characterLimit      : null
    , typingTimeThreshold : 200
    , caseSensitive       : false
    , placeholder         : null
    , baseURL             : '/img/'
    , imgDownArrow        : 'dropdown.png'
    , imgRemove           : 'remove.png'
    , imgSearch           : 'search.png'
    , sortedOutput        : false
    , displayHierarchy    : false
    , indentMultiplier    : 1
    , tabindexOffset      : null
    , noSuggestText       : 'No suggestions found'
    , emptyListText       : 'All items selected already'
    , limitedText         : 'There are too many results to show, type more characters to filter these results further'
    , searchTooltipText   : 'Enter text to get suggestions'
    , ajaxErrorFunction   : function(self, data){self._showMessageSuggestion('AJAX Search failed', 'error');}
    , loadingClass        : '.tagger-loading'
    , inputExpandExtra    : 14
    , fieldWidth          : '30em'
    , fieldHeight         : null
    , suggestWidth        : null
    , suggestMaxWidth     : null
    , suggestMaxHeight    : null
    , mandatorySelection  : false
    , clearFilterOnBlur   : false
    , freeTextInput       : false
    , freeTextPrefix      : null
    , freeTextMessage     : null
    , freeTextSuggest     : false
    },

    keyCodes: {
      BACKSPACE: 8
    , TAB: 9
    , ENTER: 13
    , ESC: 27
    , SPACE: 32
    , END: 35
    , HOME: 36
    , LEFT: 37
    , UP: 38
    , RIGHT: 39
    , DOWN: 40
    },
    mouseCodes: {
      LEFT: 1
    , MIDDLE: 2
    , RIGHT: 3
    },

    /**
     * Tagger widget constructor
     *
     * Based on the select element it is created on it reads information from it,
     * Creates new elements for the tagger widget, adds event listeners and deals
     * with pre-selected tags.
     */
    _create: function () {
      this.canFireActions = false;

      if (this.element.is('select')) {
        // Check readonly mode
        this.readonly = this.element.prop('readonly');

        // Set tabindexOffset
        if (this.options.tabindexOffset === null) {
          if (this.element.attr('tabindex')) {
            this.tabIndex = this.element.attr('tabindex');
          }
          else {
            this.tabIndex = '0';
          }
        }
        else {
          this.tabIndex = this.options.tabindexOffset;
        }

        // Check cardinality mode
        this.singleValue = !this.element.prop('multiple');

        // Initialise the tag counter
        this.tagCount = 0;

        // Hide select
        this.element.hide();

        // Remove any loading divs
        this.element.siblings(this.options.loadingClass).remove();

        // Construct tagger widget
        this.taggerWidget = $('<div>').addClass('tagger').insertAfter(this.element);
        if (this.readonly) {
          this.taggerWidget.addClass('tagger-readonly');
        }

        // Set dimensions
        if (this.options.fieldWidth !== null) {
          this.taggerWidget.css('width', this.options.fieldWidth);
        }
        else {
          // Default width to the width of the original select element if null passed in
          this.taggerWidget.css('width', this.element.css('width'));
        }
        if (this.options.fieldHeight !== null) {
          this.taggerWidget.css('height', this.options.fieldHeight);
        }

        if (!this.readonly) {
          // Add the suggestion drop arrow and and text input if not readonly
          this.taggerInput = $('<input>').attr('type', 'text').attr('autocomplete', 'off').addClass('intxt').appendTo(this.taggerWidget);
          this.taggerButtonsPanel = $('<div>').addClass('tagger-buttons');
          this.taggerButtonsPanel.appendTo(this.taggerWidget);

          if (!this.options.ajaxURL) {
            this.taggerSuggestionsButton = $('<div>')
              .addClass('droparrow')
              .addClass('hittarget')
              .bind('mouseup keyup', $.proxy(this._handleSuggestionsButtonInteraction, this))
              .appendTo(this.taggerButtonsPanel);
            $('<img>').attr('src', this.options.baseURL + this.options.imgDownArrow).appendTo(this.taggerSuggestionsButton);
          }
          else {
            this.taggerSuggestionsButton = $('<div>')
              .addClass('search')
              .bind('mouseup keyup', $.proxy(this._handleSuggestionsButtonInteraction, this))
              .appendTo(this.taggerButtonsPanel);
            $('<img>').attr('src', this.options.baseURL + this.options.imgSearch).attr('title', this.options.searchTooltipText).appendTo(this.taggerSuggestionsButton);
          }

          this.taggerSuggestionsButton.attr("tabindex", this.tabIndex);

          // Add placeholder text to text input field
          if (this.options.placeholder !== null) {
            this.taggerInput.attr("placeholder", this.options.placeholder);
          }

          if (this.options.characterLimit !== null) {
            this.taggerInput.attr("maxlength", this.options.characterLimit);
          }

          // Set the tab index on the input field
          this.taggerInput.attr("tabindex", this.tabIndex);

          // Esc should hide the tagger suggestions globally
          this.taggerWidget.bind('keydown', $.proxy(function (event) {
            if (event.target && event.which === 27) { // Esc
              this.taggerSuggestions.hide();
            }
          }, this));

          // Capture the keypress event for any child elements - redirect any chars to the current input field
          this.taggerWidget.bind('keypress', $.proxy(this._handleTaggerKeypressRedirect, this));
        }

        // Clearer div makes sure the widget div keeps its height
        $('<div>')
          .addClass('clearer')
          .appendTo(this.taggerWidget);

        if (!this.readonly) {
          // If not readonly, stub out an empty suggestion list
          this.taggerSuggestions = $('<div>')
            .addClass('suggestions')
            .appendTo(this.taggerWidget);

          // Put a filter at the top of the suggestion list in single-select mode
          if (this.singleValue) {
            this.taggerFilterInput = $('<input type="text" class="filtertxt" autocomplete="off"/>').appendTo(this.taggerSuggestions);
            this.taggerFilterInput.attr("tabindex", this.tabIndex);
            // Add placeholder text to text input field
            if (this.options.placeholder !== null) {
              this.taggerFilterInput.attr("placeholder", this.options.placeholder);
            }
            this.taggerFilterInput.hide();
          }

          this.taggerSuggestionsList = $('<ul>').appendTo(this.taggerSuggestions);

          // Event listener to hide suggestions list if clicking outside this tagger widget
          // Using mousedown because IE11 reports the event.target for a mouseup as the HTML
          // root element rather than the original click target, mousedown seems to work
          // cross browser
          $(document.body).bind('mousedown keyup', $.proxy(this._handleDocumentInteraction, this));

          // Bind event to window to resize the suggestion list when the window's resized
          $(window).resize($.proxy(function() {
            this._setSuggestionListDimensions(this);
          }, this));

          // Expand the input field to fit its contents
          this._inputExpand(this.taggerInput);

          // Bind event to text input to expand input to fit contents and deal with key input
          this.taggerInput.bind('keydown keyup mouseup', $.proxy(this._handleFilterInputInteraction, this));

          // If we have a list filter then bind events to it
          if (this.taggerFilterInput) {
            this.taggerFilterInput.bind('keydown keyup mouseup', $.proxy(this._handleFilterInputInteraction, this));
          }

          // If the select was in focus already, make the tagger input focused
          if (this.element.is(':focus')) {
            this._focusWidget();
            this.taggerInput.focus();
          }
          // Capture focus on the underlying element and redirect that focus to the tagger
          this.element.focus($.proxy(function (e) {
            this._focusWidget();
            this.taggerInput.focus();
            e.preventDefault();
          }, this));
          // For some reason the jQuery focus overload doesn't fully work so we need both methods?
          this.element.get(0).focus = $.proxy(function () {
            this._focusWidget();
            this.taggerInput.focus();
          }, this);
          // Add a focus handler to any labels that are for the underlying select
          $('label[for=' + this.element.prop('id') + ']').bind('mouseup', $.proxy(function () {
            this._focusWidget();
            this.taggerInput.focus();
          }, this));
        }

        // Let the available tags be accessed through a nicer name
        if (this.options.availableTags) {
          this.tagsByID = this.options.availableTags;
        }
        // Convert options to JS objects if no JSON is supplied
        else {
          this.tagsByID = {};
          this.element.children("option").each($.proxy(function (index, element) {
            this.tagsByID[$(element).val()] = {
              id: $(element).val(),
              key: $(element).text(),
              hidden: '',
              level: 0,
              suggestable: true,
              historical: false,
              sort: index,
              freetext: (this.options.freeTextInput && $(element).val().startsWith(this.options.freeTextPrefix))};
          }, this));
        }

        var preselectedTags = this.options.preselectedTags;
        if (this.singleValue && this.options.mandatorySelection && preselectedTags === null) {
          preselectedTags = [this.element.children()[0].value];
        }

        // Deal with already selected options
        if (preselectedTags === null) {
          this.element.children("option:selected").each($.proxy(function (index, element) {
            // Set any selected options that aren't in the availableTags as historical entries so they can be displayed and removed but not added
            if (!this.tagsByID[$(element).val()]) {
              this.tagsByID[$(element).val()] = {
                id: $(element).val(),
                key: $(element).text(),
                suggestion: $(element).text(),
                hidden: '',
                level: 0,
                suggestable: false,
                historical: true,
                freetext: (this.options.freeTextInput && $(element).val().startsWith(this.options.freeTextPrefix))};
            }
            // Add tags for any selected options
            this._addTagFromID($(element).val());
          }, this));
        }
        else {
          var preselectedTag = null;
          for (var i = 0; i < preselectedTags.length; i++) {
            preselectedTag = preselectedTags[i];
            // Set any selected options that aren't in the availableTags as historical entries so they can be displayed and removed but not added
            if (!this.tagsByID[preselectedTag]) {
              this.tagsByID[preselectedTag] = {
                id: preselectedTag,
                key: $($('option[value="'+preselectedTag+'"]', this.element)[0]).text(),
                suggestion: '',
                hidden: '',
                level: 0,
                suggestable: false,
                historical: true,
                freetext: (this.options.freeTextInput && preselectedTag.startsWith(this.options.freeTextPrefix))};
            }
            // Add tags for any selected options
            this._addTagFromID(preselectedTag);
          }
        }
        this.canFireActions = true;
      }
      else {
        throw 'Tagger widget only works on select elements';
      }
    },

    /**
     * Handle keydown, keyup and mosueup events on filtering input boxes
     * @param event KeyDown, KeyUp and MosueUp Event
     * @private
     */
    _handleFilterInputInteraction: function(event) {
      var targetInput = $(event.target);
      var isMainInput = targetInput.get(0) === this.taggerInput.get(0);
      switch (event.type) {
        case "keydown":
          // Expand the input field to fit its contents
          if (isMainInput) {
            this._inputExpand(this.taggerInput);
          }

          if (event.target) {
            switch (event.which) {
              case this.keyCodes.ENTER: // Enter key
                // If they hit enter with just one item in the suggestion list, add it, otherwise focus the top item
                if (this.taggerSuggestionsList.children('[suggestion=tag]').length === 1) {
                  this._addTagFromID(this.taggerSuggestionsList.children('[suggestion=tag]').first().data('tagid'));
                  this._selectionReset(true, true);
                }
                else if (this.taggerSuggestionsList.children('[suggestion=tag]').length === 0 && this.options.freeTextInput) {
                  this._addFreeText(targetInput.val());
                  this._selectionReset(true, true);
                }
                else {
                  this.taggerSuggestionsList.children('[tabindex]').first().focus();
                }
                event.preventDefault();
                break;
              case this.keyCodes.BACKSPACE: // Backspace
                if (isMainInput) {
                  if (targetInput.val().length < 1) {
                    // If there is nothing in the input, change focus to the last tag
                    var removeTag = $('.tag', this.taggerWidget).last();
                    // Move focus to last tag if there is one
                    if (removeTag.length > 0) {
                      removeTag.focus();
                    }
                    event.preventDefault();
                  }
                  else if (targetInput.val().length <= this.options.characterThreshold) {
                    // If they're backspacing the last character that puts them over the filter threshold hide the suggestions
                      this._selectionReset(true, false);
                  }
                }
                else {
                  if (targetInput.val().length <= this.options.characterThreshold && this.loadedFiltered) {
                    if (this.singleValue && this.taggerFilterInput) {
                      // In single select mode we don't want to hide the filter input, just the suggestions
                      this._selectionReset(false, false);
                    }
                    else {
                      // Reset selection
                      this._selectionReset(true, false);
                      // Focus the drop arrow
                      this.taggerSuggestionsButton.focus();
                    }
                    //event.preventDefault();
                  }
                }
                break;
              case this.keyCodes.ESC: // Esc
                this.taggerSuggestions.hide();
                event.preventDefault();
                break;
              default:
                break;
            }
          }
          break;
        case "keyup":
          // Expand the input field to fit its contents
          if (isMainInput) {
            this._inputExpand(this.taggerInput);
          }

          if (event.which !== this.keyCodes.ENTER && event.which !== this.keyCodes.DOWN && event.which !== this.keyCodes.ESC) { // key up not enter or down arrow or esc key
            if (targetInput.val().length >= this.options.characterThreshold) {
              // Filter suggestions when they're over the threshold
              this._filterSuggestions(targetInput.val(), false);
            }
          }
          else if (event.which === this.keyCodes.DOWN) { // Down Arrow
            if (isMainInput) {
              if (!this.options.ajaxURL || this.taggerSuggestions.is(":visible")) {
                this._showSuggestions(true);
              }
            }
            else {
              // Focus top item in suggestion list
              this.taggerSuggestionsList.children('[tabindex]').first().focus();
              event.preventDefault();
            }
          }
          break;
        case "mouseup":
          // For now, only show the list automatically on click if we have a single value selected
          // When performance of the suggestion list building is improved, we can enable this functionality
          // for multi selectors and empty taggers - note redundant boolean logic preserved so that the following
          // suggestion parameter is still valid if this check is removed
          if (this.singleValue && this.tagCount === 1) {
            // In single select mode, with a single tag selected already
            // we should focus the first item in the suggestion list (which
            // will be the filter input)
            this._showSuggestions(this.singleValue && this.tagCount === 1);
          }
          break;
        default:
          throw 'Cannot handle interaction of this type on the filter input: ' + event.type + ' - ' + event.target;
      }
    },

    /**
     * Handle mouse and keyup events on the suggestions button (down arrow)
     *
     * @param event MouseUp or KeyUp event
     * @private
     */
    _handleSuggestionsButtonInteraction: function (event) {
      if ((event.type === "mouseup" && event.which === this.mouseCodes.LEFT) // left click
        || (event.type === "keyup" && (event.which === this.keyCodes.ENTER || event.which === this.keyCodes.SPACE || event.which === this.keyCodes.DOWN))) { // enter || space || down arrow
        if (this.options.ajaxURL) {
          this._focusWidget();

          // Just redirect focus in ajax mode
          this.taggerWidget.find("input[tabindex]:visible").first().focus();
        }
        else {
          // If the suggestion list is visible already, then toggle it off
          if (this.taggerSuggestions.is(":visible")) {
            this.taggerSuggestions.hide();
          }
          // otherwise show it
          else {
            this._showSuggestions(true);
          }
        }
        event.preventDefault();
      }
    },

    /**
     * When keypress events fire on the tagger widget redirect them to the filter input
     *
     * @param event KeyPress event
     * @private
     */
    _handleTaggerKeypressRedirect: function (event) {
      if (event.which !== 0 && event.charCode !== 0  && !event.ctrlKey && !event.metaKey && !event.altKey) {
        // If the keypress came from the main input or the filter, ignore this event or we'll potentially
        // just get in the way of the character being inserted and it'll be put at the end, instead of wherever
        // typed
        if (event.target === this.taggerInput.get(0) || (this.taggerFilterInput && event.target === this.taggerFilterInput.get(0))) {
          return;
        }
        this._appendCharAndFilter(event);
        event.preventDefault();
      }
    },

    /**
     * Hide suggestions list if clicking outside this tagger widget
     * (Using mousedown because IE11 reports the event.target for a mouseup as the HTML
     *  root element rather than the original click target, mousedown seems to work
     *  cross browser)
     * Also handling keyup events so that it can lose focus when tabbing away from the widget.
     * @param event MouseDown or KeyUp event
     * @private
     */
    _handleDocumentInteraction: function (event) {
      var selfTaggerWidget = this.taggerWidget.get(0);
      if (event.type === "mousedown") {
        if ($(event.target).parents(".tagger").get(0) !== selfTaggerWidget && event.target !== selfTaggerWidget) {
          // If clicking something which is not in this tagger widget we've effectively lost focus
          this._blurWidget();
        }
        else if (event.target === selfTaggerWidget) {
          this._focusWidget();

          // If clicking through to the parent div, focus the first focusable item
          if (!this.singleValue || this.tagCount === 0) {
            this.taggerWidget.find("input[tabindex]:visible").first().focus();
            event.preventDefault();
          }

          // For now, only show the list automatically on click if we have a single value selected
          // When performance of the suggestion list building is improved, we can enable this functionality
          // for multi selectors and empty taggers - note redundant boolean logic preserved so that the following
          // suggestion parameter is still valid if this check is removed
          if (this.singleValue && this.tagCount === 1) {
            // In single select mode, with a single tag selected already
            // we should focus the first item in the suggestion list (which
            // will be the filter input).
            // NB: Using setTimeout because trying to do this immediately causes
            // the focus to fail, presumably because the corresponding mouseup triggers
            // focus elsewhere.
            setTimeout($.proxy(function () {
              this._showSuggestions(this.singleValue && this.tagCount === 1);
            }, this), 0);
          }
        }
      }
      else if (event.type === "keyup") {
        if (event.which === this.keyCodes.TAB) {
          if ($(event.target).parents(".tagger").get(0) !== selfTaggerWidget) {
            this._blurWidget();
          }
          else if ($(event.target).parents(".tagger").get(0) === selfTaggerWidget) {
            this._focusWidget();
          }
        }
      }
    },

    /**
     * Apply focus to the widget
     *
     * @private
     */
    _focusWidget: function() {
      this.taggerWidget.addClass('focus');
    },

    /**
     * Blur action for the widget
     *
     * @private
     */
    _blurWidget: function() {
      this.taggerWidget.removeClass('focus');

      this.taggerSuggestions.hide();

      // If we're losing focus from the tagger optionally clear any left over filter text
      if (this.options.clearFilterOnBlur && this.taggerInput.val().length > 0) {
        this.taggerInput.addClass('filterCleared');
        setTimeout($.proxy(function () {
          this.taggerInput.removeClass('filterCleared');

          // Clear input
          this.taggerInput.val('');
          if (this.taggerFilterInput) {
            this.taggerFilterInput.val('');
          }

          // Call this so that the input is the right size for the placeholder text
          this._inputExpand(this.taggerInput);

          // Clear filtered suggestions
          this._loadSuggestions(this.tagsByID, true);
          // Set the flag to show it's not loaded filtered results
          this.loadedFiltered = false;
        }, this), 250);
      }
    },

    /**
     * Filter the available tags by the input text and load suggestions into suggestion list
     * @param {string} value the string value to filter by
     */
    filterTags: function (value) {
      var searchString = value;
      var searchStringLowerCase = value.toLowerCase();
      var filteredResults = {};

      // Go through each tag
      for (var tagID in this.tagsByID) {
        if (this.tagsByID.hasOwnProperty(tagID)) {
          var tag = this.tagsByID[tagID];
          if (!tag.suggestable || tag.historical) {
            // Skip non-suggestable tags
            continue;
          }

          // Add tag to filteredResults object if it contains the search string in the key, hidden or suggestion fields
          if (this.options.caseSensitive) {
            if (tag.key.indexOf(searchString) >= 0
              || (tag.hidden && tag.hidden.indexOf(searchString) >= 0)
              || $('<div/>').html(tag.suggestion).text().replace(/<.*?[^>]>/g,'').indexOf(searchString) >= 0) {
              filteredResults[tagID] = $.extend(true, {}, tag);
              filteredResults[tagID].suggestable = true;
            }
          }
          else {
            if (tag.key.toLowerCase().indexOf(searchStringLowerCase) >= 0
              || (tag.hidden && tag.hidden.toLowerCase().indexOf(searchStringLowerCase) >= 0)
              || $('<div/>').html(tag.suggestion).text().replace(/<.*?[^>]>/g,'').toLowerCase().indexOf(searchStringLowerCase) >= 0) {
              filteredResults[tagID] = $.extend(true, {}, tag);
              filteredResults[tagID].suggestable = true;
            }
          }
        }
      }
      // Load filtered results into the suggestion list
      this._loadSuggestions(filteredResults, false);
      this.loadedFiltered = true;
    },

    /**
     * Load suggestions into suggestion list from ajaxURL
     * @param {string} value the string value to filter by
     * @protected
     */
    _ajaxLoadSuggestions: function (value) {
      var searchString = value;
      var self = this;

      // If we already have a filter pending, cancel it before making our new one
      if (this.pendingFilterEvent) {
        clearTimeout(this.pendingFilterEvent);
      }

      // Set a new pending Filter event to fire in this.options.typingTimeThreshold milliseconds
      this.pendingFilterEvent = setTimeout(
        function() {
          $.ajax({
            url: self.options.ajaxURL,
            type: "GET",
            data: {
              elementId: self.element.attr('id')
            , search: searchString
            },
            dataType: 'json',
            success: function (data) {
              // Make sure any tags already displayed are overlaid on their counterparts in the new list
              $.each(self.tagsByID, function(key, tag){
                if (self._isAlreadyDisplayingTag(key)) {
                  data[key] = tag;
                }
              });
              self.tagsByID = data;
              self._loadSuggestions(data, false);
              self.loadedFiltered = true;
              self._showSuggestions(false);
            },
            error: function(data) {
              self.options.ajaxErrorFunction(self, data);
            }
          });
          delete self.pendingFilterEvent;
        }
      , this.options.typingTimeThreshold
      );
    },

    /**
     * Show a message to the user in the suggestions list section instead of results
     * @param {string} msg Message to show
     * @param {string} className Extra classes to add to the message item
     * @protected
     */
    _showMessageSuggestion: function(msg, className) {
      // Set width
      this._setSuggestionListDimensions(this);

      // Show the container
      this.taggerSuggestions.show();

      // Clear out suggestion list
      this.taggerSuggestionsList.children().remove();

      // Add message
      $('<li>').addClass('extra').addClass('message').addClass(className).text(msg).appendTo(this.taggerSuggestionsList);
    },

    /**
     * Returns the tagger input or the tagger filter input depending on which is visible.
     * @return jQuery wrapped InputElement
     * @protected
     */
    _getVisibleInput: function () {
      if (this.taggerFilterInput && this.taggerFilterInput.is(":visible")) {
        return this.taggerFilterInput;
      }
      else {
        return this.taggerInput;
      }
    },

    /**
     * Updates the input or filter input and filters results. Also places focus in the input
     * after updating the value.
     *
     * @param {jQuery} targetInput the jQuery wrapped input element to manipulate and focus
     * @param {string} newValue the new value to set
     * @protected
     */
    _updateInputAndFilter: function (targetInput, newValue) {
      // Set focus and new value - order is important otherwise the cursor can
      // sometimes end up before the text was inserted
      targetInput.focus();
      targetInput.val(newValue);

      // The non-filter input needs to grow with its text content
      if (targetInput === this.taggerInput) {
        this._inputExpand(targetInput);
      }

      this._filterSuggestions(newValue, false);
    },

    /**
     * Diverts the key press event passed to this function to whichever input is currently
     * visible. Should be registered as an event handler for keypress events on elements
     * that may be focused but are not the input being used; i.e. the drop-down arrow,
     * suggestion items, tags, etc.
     *
     * @param {event} event the keypress event to handle
     * @protected
     */
    _appendCharAndFilter: function (event) {
      // Belt and braces
      if (event.type !== 'keypress') {
        throw "Unhandled event type passed to _appendCharAndFilter(), expected keypress): " + event.type;
      }

      // Decode char to concat onto existing filter string
      var newChar = String.fromCharCode(event.charCode);

      var targetInput = this._getVisibleInput();

      // Update the UI and filter
      var newVal = targetInput.val() + newChar;
      this._updateInputAndFilter(targetInput, newVal);
    },

    /**
     * Removes the last character
     * @param {event} event the keypress event to handle
     * @protected
     */
    _removeLastCharAndFilter: function (event) {
      var targetInput = this._getVisibleInput();

      // Update the UI and filter
      var newVal = targetInput.val().substring(0, targetInput.val().length-1);
      this._updateInputAndFilter(targetInput, newVal);
    },

    /**
     * Load tags into the suggestion list
     * @param {object} suggestableTags - Object containing members of tagID to tag object
     * @param {boolean} allowIndent - Allow indenting of suggestion lists if true
     * @protected
     */
    _loadSuggestions: function (suggestableTags, allowIndent) {
      // Clear out suggestion list
      this.taggerSuggestionsList.children().remove();

      // Load suggestions if there are some, or a message if not
      var suggestableTagArray = $.map(suggestableTags, function(n, i) { return [[i, n.sort]];});

      if (this.options.sortedOutput) {
        // Sort based on the sort member of the tag objects passed in, serialised to [1] above
        suggestableTagArray.sort(
          function(a, b) {
            if (a[1] === undefined) {
              return b[1];
            }
            else if (b[1] === undefined) {
              return a[1];
            }
            return a[1] - b[1];
          }
        );
      }

      // Load in all suggestable tags
      for (var i = 0; i < suggestableTagArray.length; i++) {
        var tag = suggestableTags[suggestableTagArray[i][0]];
        // Don't add suggestion if the tag isn't selectable and it's not displaying hierarchy, the tag is historical
        //  or if the tag has no key and id tuple
        if ((!tag.suggestable && !this.options.displayHierarchy) || tag.historical || !(tag.key && tag.id) || (!this.options.freeTextSuggest && tag.freetext)) {
          continue;
        }
        // Create and add the suggestion to the suggestion list
        this._createSuggestionsItem(tag, allowIndent);
      }

      // When free text mode is on let users click this item to add whatever they typed to the selected tags
      if (this.options.freeTextInput && this._getVisibleInput().val().length > 0) {
        var message;
        if (this.options.freeTextMessage) {
          message = this.options.freeTextMessage.replace(/%VALUE%/g, $("<div>").text($.trim(this._getVisibleInput().val())).html());
        }
        else {
          message = this._getVisibleInput().val();
        }

        $('<li>')
          .addClass('extra')
          .addClass('addfreetext')
          .attr("tabindex", this.tabIndex)
          .html(message)
          .data("freetext", this._getVisibleInput().val())
          .bind('mouseup keyup keydown', $.proxy(this._handleSuggestionItemInteraction, this))
          .bind('mouseleave mouseenter blur focus', $.proxy(this._handleSuggestionItemFocus, this))
          .appendTo(this.taggerSuggestionsList);
      }

      if (suggestableTagArray.length === 0) {
        // Add message if filtering meant no items to suggest
        $('<li>')
          .addClass('extra')
          .addClass('missing')
          .text(this.options.noSuggestText)
          .appendTo(this.taggerSuggestionsList);
      }

      // Add message if nothing ended up in the list (e.g. all selectable items selected)
      if (this.taggerSuggestionsList.children().length === 0) {
        $('<li>')
          .addClass('extra')
          .addClass('missing')
          .text(this.options.emptyListText)
          .appendTo(this.taggerSuggestionsList);
      }

      if (suggestableTags.limited) {
        $('<li>')
          .addClass('extra')
          .addClass('limited')
          .text(this.options.limitedText)
          .appendTo(this.taggerSuggestionsList);
      }
    },

    /**
     * Create the suggestion
     * @param {object} tag - Tag object
     * @param {boolean} allowIndent - Allow indenting of suggestion lists if true
     * @private
     */
    _createSuggestionsItem: function(tag, allowIndent) {
      // Create and add the suggestion to the suggestion list
      var suggestion = $('<li>')
        .attr("suggestion", "tag")
        .attr("tabindex", this.tabIndex)
        .appendTo(this.taggerSuggestionsList);

      if (tag.suggestion && tag.suggestion !== null && tag.suggestion !== '') {
        suggestion.html($('<div/>').html(tag.suggestion).text());
      }
      else {
        suggestion.text(tag.key);
      }

      // Bind actions to the suggestion
      suggestion.bind('mouseup keyup keydown', $.proxy(this._handleSuggestionItemInteraction, this));
      suggestion.bind('mouseleave mouseenter blur focus', $.proxy(this._handleSuggestionItemFocus, this));

      // Attach data to it so when it's selected we can reference what it's for
      suggestion.data("tagid", tag.id);

      // Deal with hierarchy view
      if (this.options.displayHierarchy && allowIndent) {
        if (tag.level > 0) {
          // Indent suggestions
          suggestion.css('padding-left', (tag.level * this.options.indentMultiplier) + 'em');
        }
        if (!tag.suggestable) {
          // If it's not suggestable (already selected) then just grey it out, remove it from tabindex and unbind events
          suggestion.addClass('extra');
          suggestion.addClass('disabled');
          suggestion.unbind();
          suggestion.removeAttr('tabindex');
        }
      }
    },

    /**
     * Function to bind to suggestion list elements
     * @param event
     */
    _handleSuggestionItemInteraction: function suggestionBind(event) {
      if (event.type !== "mouseup" && event.type !== "keyup" && event.type !== "keydown") {
        throw "Unhandled event type passed to _handleSuggestionItemInteraction(), expected mouseup, keyup or keydown): " + event.type;
      }

      var prevTargets = $(event.target).prevAll('li[tabindex]');
      var nextTargets = $(event.target).nextAll('li[tabindex]');

      if (  (event.type === "mouseup" && event.which === this.mouseCodes.LEFT)
        ||  (event.type === "keydown" && event.which === this.keyCodes.ENTER)) { // Click or enter
        // Handle suggestion adding
        var suggestionItem = $(event.target).closest('li');
        if (suggestionItem.data('tagid') && !suggestionItem.data('freetext')) {
          this._addTagFromID(suggestionItem.data('tagid'));
          this._selectionReset(true, true);
        }
        else if (suggestionItem.data('freetext') && !suggestionItem.data('tagid')) {
          this._addFreeText(suggestionItem.data('freetext'));
          this._selectionReset(true, true);
        }
        else {
          throw "Suggestion has both freetext and a tag id?";
        }
        event.preventDefault();
      }
      else if (event.type === "keydown" && (event.which === this.keyCodes.UP || (event.which === this.keyCodes.TAB && event.shiftKey))) { // Up arrow / shift+tab (Move selection up and up into the input)
        // Override default browser tab control and allow arrow keys too
        if (prevTargets.first().is('li')) {
          prevTargets.first().focus();
        }
        else if (this.taggerFilterInput && this.taggerFilterInput.is(":visible")) {
          this.taggerFilterInput.focus();
        }
        else {
          this.taggerInput.focus();
        }
        event.preventDefault();
      }
      else if (event.type === "keydown" && (event.which === this.keyCodes.DOWN || (event.which === this.keyCodes.TAB && !event.shiftKey))) { // Down arrow / tab (Move selection down, stop at the end)
        // Override default browser tab control and allow arrow keys too
        if (nextTargets.first().is('li')) {
          nextTargets.first().focus();
          event.preventDefault();
        }
      }
      else if (event.type === "keyup" && event.which === this.keyCodes.HOME) { // Home key
        if (prevTargets.last().is('li')) {
          prevTargets.last().focus();
          event.preventDefault();
        }
      }
      else if (event.type === "keyup" && event.which === this.keyCodes.END) { // End key
        if (nextTargets.last().is('li')) {
          nextTargets.last().focus();
          event.preventDefault();
        }
      }
      else if (event.type === "keydown" && event.which === this.keyCodes.BACKSPACE) { // Backspace
        this._removeLastCharAndFilter(event);
        event.preventDefault();
      }
    },

    /**
     * Deal with setting focus properly and displaying the focus for IE6
     * @param event
     * @private
     */
    _handleSuggestionItemFocus: function(event) {
      if (event.type === "focus") {
        $(event.target).addClass('focus');
      }
      else if (event.type === "blur") {
        $(event.target).removeClass('focus');
      }
      else if (event.type === "mouseenter") {
        $(event.target).addClass('focus');
        $(event.target).focus();
      }
      else if (event.type === "mouseleave") {
        $(event.target).removeClass('focus');
        $(event.target).blur();
        this._getVisibleInput().focus();
      }
    },

    /**
     * Set the dimensions of the suggestion list container
     * @protected
     */
    _setSuggestionListDimensions: function(taggerInstance) {
      // Set width
      if (taggerInstance.options.suggestMaxWidth === null && taggerInstance.options.suggestWidth === null) {
        taggerInstance.taggerSuggestions.width(taggerInstance.taggerWidget.innerWidth());
      }
      else if (taggerInstance.options.suggestWidth !== null) {
        taggerInstance.taggerSuggestions.width(taggerInstance.options.suggestWidth);
      }
      else if (taggerInstance.options.suggestMaxWidth !== null) {
        taggerInstance.taggerSuggestions.css('min-width', taggerInstance.taggerWidget.innerWidth());
        taggerInstance.taggerSuggestions.css('max-width', taggerInstance.options.suggestMaxWidth);

        // Deal with quirks
        if (!jQuery.support.boxModel) {
          if (taggerInstance.taggerSuggestions.width() < taggerInstance.taggerWidget.innerWidth()) {
            taggerInstance.taggerSuggestions.width(taggerInstance.taggerWidget.innerWidth());
          }
          else if (taggerInstance.taggerSuggestions.width() > taggerInstance.options.suggestMaxWidth) {
            taggerInstance.taggerSuggestions.width(taggerInstance.options.suggestMaxWidth);
          }
        }
      }

      // Set height
      if (taggerInstance.options.suggestMaxHeight !== null) {
        taggerInstance.taggerSuggestions.css('max-height', taggerInstance.options.suggestMaxHeight);

        // Deal with quirks
        if (!jQuery.support.boxModel) {
          if (taggerInstance.taggerSuggestions.height() > taggerInstance.options.suggestMaxHeight) {
            taggerInstance.taggerSuggestions.height(taggerInstance.options.suggestMaxHeight);
          }
        }
      }
    },

    /**
     * Filters the suggestions, using a provided value.
     * @param {string} value the text string to filter by
     * @param {boolean} hideSuggestions boolean - should the suggestions be hidden
     *   if the value is less than the required character threshold?
     * @protected
     */
    _filterSuggestions: function (value, hideSuggestions) {
      if (value.length >= this.options.characterThreshold) {
        // If text is longer than the threshold start filtering and showing the filtered results
        if (!this.options.ajaxURL) {
          this.filterTags(value);
          this._showSuggestions(false);
        }
        // If ajaxURL is set, load the suggestions from URL instead of filtering the tag list
        else {
          this._ajaxLoadSuggestions(value);
        }
      }
      // If under the threshold and was previously filtered, reset the list
      else if (this.loadedFiltered) {
        if (hideSuggestions) {
          // Hide it
          this.taggerSuggestions.hide();
        }
        // Reload in all suggestions
        this._loadSuggestions(this.tagsByID, true);
        // Clear the flag
        this.loadedFiltered = false;
      }
    },

    /**
     * Show the suggestions list, making sure it's the correct size. Will initialise contents
     * if necessary. Will focus first list item if requested to do so.
     * @param {boolean} focusFirstItem whether the first item in the suggestion list received focus
     * @protected
     */
    _showSuggestions: function (focusFirstItem) {
      this._focusWidget();

      // Set width
      this._setSuggestionListDimensions(this);

      // Show the container
      this.taggerSuggestions.show();

      // Show the filter if necessary
      if (this.singleValue && this.taggerFilterInput && this.tagCount === 1) {
        this.taggerFilterInput.show();
      }
      else if (this.taggerFilterInput) {
        this.taggerFilterInput.hide();
      }

      var self = this;
      var loadSuggestionsInternal = function () {
        self._loadSuggestions(self.tagsByID, true);
        // Set the flag to show it's not loaded filtered results
        self.loadedFiltered = false;
        // Focus the first item in the list, which may be the filter, or may be an option
        if (focusFirstItem) {
          self.taggerSuggestions.find('[tabindex]:visible').first().focus();
        }
      };

      // Load suggestions on first hit
      if (this.taggerSuggestionsList.children().length === 0) {
        // If there are more than 300 items, show a loading item first as it could take a while
        if ($.map(this.tagsByID, function(n, i) { return i;}).length > 300) {
          $('<li>')
            .addClass('extra')
            .addClass('missing')
            .text('Loading...')
            .appendTo(this.taggerSuggestionsList);
          setTimeout(loadSuggestionsInternal, 300); // Fixed timeout of 300ms for now
        }
        // If less than 300 items just load all suggestions into the suggestions list
        else {
          loadSuggestionsInternal();
        }
      }
      else {
        // Focus the first item in the list, which may be the filter, or may be an option
        if (focusFirstItem) {
          this.taggerSuggestions.find('[tabindex]:visible').first().focus();
        }
      }
    },

    /**
     * Set the width of an input box to fit the value string
     * @param {InputElement} input - HTML Input Element to set the width of
     * @protected
     */
    _inputExpand: function (input) {
      // Create a hidden span to store the value of the input and read actual dimensions from
      var taggerInputSpan = $('<span class="hiddenInputSpan"></span>').appendTo(this.taggerWidget);
      // Make sure the hidden span has the same font properties
      taggerInputSpan.css({
        fontSize: this.taggerInput.css('fontSize'),
        fontFamily: this.taggerInput.css('fontFamily'),
        fontWeight: this.taggerInput.css('fontWeight'),
        letterSpacing: this.taggerInput.css('letterSpacing')
      });

      // Put the input contents (or placeholder) into the hidden span
      if (input.val() !== "") {
        taggerInputSpan.html(input.val().replace(/&/g, '&amp;').replace(/\s/g,'&nbsp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + " ");
      }
      else if (input.attr('placeholder') && input.attr('placeholder') !== "") {
        taggerInputSpan.html(input.attr('placeholder').replace(/&/g, '&amp;').replace(/\s/g,'&nbsp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
      }

      // Set the width of the input to be the width of the span, making sure not to overflow the general widget bounds
      input.width(Math.min(input.parent().innerWidth() - this.taggerSuggestionsButton.outerWidth(), taggerInputSpan.width() + this.options.inputExpandExtra));

      taggerInputSpan.remove();
    },

    /**
     * After selecting a tag from the suggestions, reset the tagger widget
     * @param {boolean} shouldHideMenu should the menu be hidden?
     * @param {boolean} shouldClearInputs should the input fields be cleared?
     * @protected
     */
    _selectionReset: function (shouldHideMenu, shouldClearInputs) {
      // Clear input
      if (shouldClearInputs) {
        this.taggerInput.val('');
        if (this.taggerFilterInput) {
          this.taggerFilterInput.val('');
        }
      }
      // Expand properly
      this._inputExpand(this.taggerInput);
      // Clear filtered suggestions
      this._loadSuggestions(this.tagsByID, true);
      // Set the flag to show it's not loaded filtered results
      this.loadedFiltered = false;
      // Focus input
      this._getVisibleInput().focus();
      // Hide suggestion list
      if (shouldHideMenu) {
        this.taggerSuggestions.hide();
      }
    },

    /**
     * Add a tag, given a tags ID, to the widget and mark it as selected in the
     * underlying select elements option list
     * @param {string} tagID - ID of the tag to add
     * @protected
     */
    _addTagFromID: function (tagID) {
      var self = this;
      var tagData = this.tagsByID[tagID];
      var tag;

      // Check tag not already added
      if (this._isAlreadyDisplayingTag(tagID)) {
        return;
      }

      // Remove any other selected tag if in single mode
      //   Temporarily disable actions while doing the remove as you want to run
      //   the action after the subsequent add
      if (this.singleValue) {
        var tmpActionFireStatus = this.canFireActions;
        this.canFireActions = false;
        $('.tag', this.taggerWidget).each(function () {
          self._removeTagByElem($(this), true, true);
        });
        $('.removetag', this.taggerWidget).each(function () {
          $(this).remove();
        });
        this.canFireActions = tmpActionFireStatus;
      }

      if (!this.readonly) {
        // Select the option in the underlying select element
        if ($('option[value="' + tagID.replace(/"/g, '\\"') + '"]', this.element).length > 0) {
          $('option[value="' + tagID.replace(/"/g, '\\"') + '"]', this.element).prop("selected", true);
        }
        else {
          $('<option>')
            .prop("selected", true)
            .val(tagID)
            .text($('<div>').html(tagData.key).text())
            .appendTo(this.element);
        }
        // Add the HTML to show the tag
        tag = $('<div>')
          .addClass('tag')
          .attr("tabindex", this.tabIndex)
          .text($('<div/>').html(tagData.key).text())
          .data("tagid", tagID)
          .insertBefore(this.taggerInput);

        if (tagData.freetext) {
          tag.addClass('freetext');
        }

        var tagRemover = $('<span class="removetag hittarget"><img src="' + this.options.baseURL + this.options.imgRemove + '" /></span>');

        // Reusable tag removal closure
        var tagRemoveProcessing = function () {
          // If the menu is open, keep it open...
          if (self.taggerSuggestions.is(':visible')) {
            // Check to see if the filter has any value
            var shouldUseFilterValue = self.taggerFilterInput && self.taggerFilterInput.val().length > 0;
            // If the filter has a value, we can keep it, so don't clear the inputs just yet - we'll do that
            // manually here instead in the setTimeout() instead of immediately as part of removing the tag
            self._removeTagByElem(tag, false, !shouldUseFilterValue);
            self._showSuggestions(false);
            // Remove the tag (x) with a timeout, otherwise the suggestions will be hidden. This happens
            // because the mouseup event propagates to the document, and if the element has
            // been removed already, the event.target won't have the tagger div as its ancestor
            // and therefore it is assumed that the user has clicked outside of the tagger
            setTimeout(function(){
              tagRemover.remove();
              self.taggerInput.focus();
              // If the filter has a value we can use, move that value to the main
              // input and filter the suggestions
              if (shouldUseFilterValue) {
                self._updateInputAndFilter(self.taggerInput, self.taggerFilterInput.val());
              }
            }, 0);
          }
          else {
            // Remove the tag
            self._removeTagByElem(tag, false, true);
            tagRemover.remove();
            self.taggerInput.focus();
          }
        };

        // Bind event to the tag remover (x) to deal with mouse click and enter key
        tagRemover.bind('mouseup keyup', $.proxy(function(event) {
          switch (event.type) {
            case "mouseup":
              if (event.which === this.mouseCodes.LEFT) { // Left Mouse Click
                tagRemoveProcessing();
              }
              event.preventDefault();
              break;
            case "keyup":
              if (event.which === this.keyCodes.ENTER) { // Enter key
                tagRemoveProcessing();
              }
              break;
          }
        }, this));

        // Bind event to the whole tag to deal with backspaces, arrow keys
        tag.bind('keydown', $.proxy(function (event) {
          if (event.which === this.keyCodes.BACKSPACE) { // Backspace
            this._removeTagByElem($(event.target), false, true);
            if (tagRemover) {
              tagRemover.remove();
            }
            event.preventDefault();
            this.taggerInput.focus();
          }
          if (event.which === this.keyCodes.LEFT) { // Left arrow
            // Shift focus to previous tab if there is one
            var prevTag = $(event.target).prev('.tag').get(0);
            if (prevTag) {
              prevTag.focus();
            }
          }
          if (event.which === this.keyCodes.RIGHT) { // Right arrow
            // Shift focus to next tab if there is one, otherwise the input field
            var nextTag = $(event.target).next('.tag').get(0);
            if (nextTag) {
              nextTag.focus();
            }
            else {
              this.taggerInput.focus();
            }
          }
        }, this));

        if (this.singleValue) {
          // In single select mode, with a single tag selected already
          // we should focus the first item in the suggestion list (which
          // will be the filter input)
          tag.bind('click', function (event) {
            self._showSuggestions(self.singleValue && self.tagCount === 1);
          });

          // Change the way it is displayed in single-value mode
          this.taggerInput.hide();
          tag.addClass('tag-single');

          // Remove ability to clear the selection if operating in mandatory mode
          if (!this.singleValue || !this.options.mandatorySelection) {
            tagRemover.addClass('removetag-single');
            tagRemover.attr("tabindex", this.tabIndex);
            tagRemover.insertBefore(this.taggerSuggestionsButton);
          }
        }
        else {
          tagRemover.appendTo(tag);
        }
      }
      else {
        tag = $('<div class="tag tag-readonly"></div>').prependTo(this.taggerWidget);
        tag.text($('<div/>').html(tagData.key).text());
        if (this.singleValue) {
          tag.addClass('tag-single');
        }
      }

      this.tagCount++;

      // Remove tag from tags object
      this.tagsByID[tagID].suggestable = false;

      // Mark this tag as being displayed
      this.tagsByID[tagID].displaying = true;

      // Fire onchange action
      if (this.canFireActions) {
        this._fireOnChangeAction();
      }
    },

    /**
     * Add a tag for given free text not specified in the available tags list
     * @param {string} freeTextValue - New text value to add an option for
     * @protected
     */
    _addFreeText: function(freeTextValue) {
      freeTextValue = $("<div>").text($.trim(freeTextValue)).html();

      // Stub in tag JIT
      var newTagID = (this.options.freeTextPrefix ? this.options.freeTextPrefix : '') + freeTextValue;

      this.tagsByID[newTagID] = {
        id: newTagID,
        key: freeTextValue,
        hidden: '',
        level: 0,
        suggestable: true,
        historical: false,
        sort: -1,
        freetext: true};

      this._addTagFromID(newTagID);

      delete this.tagsByID[newTagID];
    },

    /**
     * Check to see if a tag has already been selected
     * @param {string} tagID - ID of the tag to check for
     * @returns {boolean} True if the tag is currently selected
     * @protected
     */
    _isAlreadyDisplayingTag: function (tagID) {
      if (this.tagsByID[tagID].displaying && this.tagsByID[tagID].displaying === true) {
        return true;
      }
      return false;
    },

    /**
     * Remove a tag, given a tags ID, to the widget and mark it as non-selected
     * in the underlying select elements option list
     * @param {Object} tagElem - Div element of the tag clicked in the widget
     * @param {boolean} shouldHideMenu - should the menu be hidden?
     * @param {boolean} shouldClearInputs should the input fields be cleared?
     * @protected
     */
    _removeTagByElem: function (tagElem, shouldHideMenu, shouldClearInputs) {
      // Get ID of tag about to be removed
      var tagID = tagElem.data('tagid');
      // Remove tag div
      tagElem.remove();
      this.tagCount--;
      // Deselect from hidden select
      $('option[value="' + tagID + '"]', this.element).prop("selected", false);

      // In single select mode, make sure no options are selected
      if (this.singleValue) {
        $(this.element).val([]);
      }

      // Add tag back into the suggestable list and mark is as no longer displayed if it's in the list of current tags
      if (this.tagsByID[tagID]) {
        // Add back into the selectable list
        this.tagsByID[tagID].suggestable = true;
        // Mark this tag as no longer being displayed
        this.tagsByID[tagID].displaying = false;
      }

      // Reset input
      this._selectionReset(shouldHideMenu, shouldClearInputs);

      // Show the input if it's in single-select mode
      if (this.singleValue) {
        this.taggerInput.show();
      }

      // Fire onchange action
      if (this.canFireActions) {
        this._fireOnChangeAction();
      }
    },

    /**
     * If there is any onchange function defined on the original element, run it
     * @protected
     */
    _fireOnChangeAction: function () {
      if (this.element[0].onchange) {
        this.element[0].onchange();
      }
    }
  });
})(jQuery);