Implementing a text box for entering tags in a dojo web app

I needed a text box for entering tags on a dojo web app. I ended up making my own – it was only a hundred or so lines of code, but I’m sharing it here as it might be useful to others.

The text box needed to provide auto-complete when you start typing something that matches an existing tag. Dojo already has a text box widget that does auto-complete : dijit.form.ComboBox – so I started from there, modifying it’s behaviour so that

  • the options it offers are based on the current tag you’re typing (instead of the whole contents of the text box)
  • if you pick one of the options, it only replaces the current tag you’re typing with what you select (instead of replacing the whole contents)

See it in action in this short video clip.

Because I’ve based it on dijit.form.ComboBox, I also get a bunch of features for free, including that options it offers are based on the contents of a data store, which can be backed by a REST API.

This supports paging, which means my REST API doesn’t have to return all of the tags – just enough to populate the visible bit of the drop-down list. I’m using Lucene to implement filtering in the REST API, so it can quickly return a subset of tags that matches what the user has started typing. I don’t need to download everything and filter it client-side – it can be smarter and more efficient than that.

That said, this might be overkill for some needs – you can easily create a client-side store in memory, without needing to write a REST API to back it.

The source to implement it is:

define(["dojo/_base/declare", "dijit/form/ComboBox", "dojo/_base/lang", "dojo/dom-attr"],
  function (declare, ComboBox, lang, domAttr) {
    
    /**
     * Modifies dijit's ComboBox for use when entering comma-separated tags.
     *
     * @author Dale Lane
     */
    return declare("myapp.ui.form.TagsComboBox", [ComboBox], {
      
      /**
       * Character to use to split strings
       */
      tagSeparator : ",",
      
      
      /**
       * Overrides the behaviour of the ComboBox when submitting a fetch query
       *  to the store.
       *
       * Modifies the key being searched for so that we only search for the
       *  tag currently being entered, rather than the whole comma-separated
       *  string. We assume the tag currently being entered is the one where
       *  the cursor position currently is.
       */
      _startSearch: function(/*String*/ key){
        var cursorPosition = this._getCaretPos(this.focusNode);
        key = lang.trim(this._getCurrentTag(key, cursorPosition).current);
        this.inherited(arguments);
      },
      
      
      /**
       * Overrides the behaviour of the ComboBox when the user selects an
       *  item from the drop-down list. Prevents the cursor position being
       *  moved to the end of the text box, which is the default behaviour
       *  when something is selected.
       */
      _selectOption: function(/*DomNode*/ target){
        this.closeDropDown();
        if(target){
          this._announceOption(target);
        }
        this._handleOnChange(this.value, true);
      },
      
      
      /**
       * Overrides the behaviour of the ComboBox when the user selects an
       *  item from the drop-down list.
       *
       * Instead of overwriting the contents with the selected item, we want
       *  to overwrite the contents of the current tag only.
       */
      _announceOption: function(/*Node*/ node){
        if(!node){
          return;
        }


        // get the current cursor position - which we use to
        //  work out which tag is being edited
        var cursorPosition = this._getCaretPos(this.focusNode);
        
        // pull the text value from the item attached to the DOM node
        var newValue;
        if(node == this.dropDown.nextButton ||
          node == this.dropDown.previousButton){
          newValue = node.innerHTML;
          this.item = undefined;
          this.value = '';
        }else{
          newValue = (this.store._oldAPI ?  // remove getValue() for 2.0 (old dojo.data API)
            this.store.getValue(node.item, this.searchAttr) : node.item[this.searchAttr]).toString();
          var newValueLength = newValue.length;
          
          // identify the boundaries of the word that the
          //  user is currently editing
          var tagInfo = this._getCurrentTag();
          // insert the selected value into the current string
          newValue = tagInfo.before + newValue + tagInfo.after;
          this.set('item', node.item, false, newValue);
          // move the cursor to the end of the inserted word
          cursorPosition = tagInfo.before.length + newValueLength;
        }
        // get the text that the user manually entered (cut off autocompleted text)
        this.focusNode.value = this.focusNode.value.substring(0, this._lastInput.length);
        // set up ARIA activedescendant
        this.focusNode.setAttribute("aria-activedescendant", domAttr.get(node, "id"));
        // autocomplete the rest of the option to announce change
        this._autoCompleteText(newValue);
        // reset the cursor position
        this._setCaretPos(this.focusNode, cursorPosition);
      },
      
      
      /**
       * Assumes the key string is divided into comma-separated tags.
       *  Returns the substring which is the tag contained within the
       *  commas separating the provided current cursor position.
       */
      _getCurrentTag : function (){
        var cursorPosition = this._getCaretPos(this.focusNode);
        var results = {
          "before"  : "",
          "after"   : "",
          "current" : ""
        };
        var ptrComma = 0;
        var key = this.focusNode.value;
        while (ptrComma <= key.length){
          var ptrCommaNext = key.indexOf(this.tagSeparator, ptrComma);
        
          if (ptrCommaNext === -1){
            results.before  = key.substr(0, ptrComma);
            results.current = key.substr(ptrComma);
            break;
          }
          else if (ptrCommaNext < cursorPosition) {
            ptrComma = ptrCommaNext + this.tagSeparator.length;
          }
          else {
            results.before  = key.substr(0, ptrComma);
            results.after   = key.substr(ptrCommaNext);
            results.current = key.substr(ptrComma, (ptrCommaNext - ptrComma));
            break;
          }
        }
        return results;
      }
    });
  }
);

How do you use it? You can use it as you would a ComboBox – so anything in the documentation and examples for ComboBox should work with this.

Finally, if you’re familiar with dojo, you might be aware that dojo actually has an experimental widget for implementing a combo box for entering tags : dojox.form.MultiComboBox. You might be wondering why I didn’t just use that instead.

You can try the current version of it for yourself at archive.dojotoolkit.org.

Basically, it doesn’t work quite how I needed. For example, it assumes that you’ll always want to edit the last tag in the list – it bases the auto-complete options it offers on what comes after the last comma in the text. I wanted to be able to go back and edit tags in the middle of the text box, and still be offered sensible options.

And there are a few other differences, too. So I wrote my own based on ComboBox. MultiComboBox is also implemented by modifying ComboBox, so I wouldn’t have gained much by modifying MultiComboBox instead of ComboBox itself.

Tags: , , , , ,

Comments are closed.