{"id":2232,"date":"2012-08-19T15:50:47","date_gmt":"2012-08-19T15:50:47","guid":{"rendered":"http:\/\/dalelane.co.uk\/blog\/?p=2232"},"modified":"2012-08-19T19:43:20","modified_gmt":"2012-08-19T19:43:20","slug":"implementing-a-text-box-for-entering-tags-in-a-dojo-web-app","status":"publish","type":"post","link":"https:\/\/dalelane.co.uk\/blog\/?p=2232","title":{"rendered":"Implementing a text box for entering tags in a dojo web app"},"content":{"rendered":"<p>I needed a text box for entering tags on a <a href=\"http:\/\/dojotoolkit.org\/\">dojo<\/a> web app. I ended up making my own &#8211; it was only a hundred or so lines of code, but I&#8217;m sharing it here as it might be useful to others.<\/p>\n<p>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 : <a href=\"http:\/\/dojotoolkit.org\/reference-guide\/1.8\/dijit\/form\/ComboBox.html\">dijit.form.ComboBox<\/a> &#8211; so I started from there, modifying it&#8217;s behaviour so that<\/p>\n<ul>\n<li>the options it offers are based on the current tag you&#8217;re typing (instead of the whole contents of the text box)\n<\/li>\n<li>if you pick one of the options, it only replaces the current tag you&#8217;re typing with what you select (instead of replacing the whole contents)<\/li>\n<\/ul>\n<p><iframe loading=\"lazy\" width=\"450\" height=\"253\" src=\"http:\/\/www.youtube.com\/embed\/K1TwRt2jQK8?rel=0\" frameborder=\"0\" allowfullscreen><\/iframe><\/p>\n<p>See it in action in <a href=\"http:\/\/youtu.be\/K1TwRt2jQK8\">this short video clip<\/a>.<\/p>\n<p>Because I&#8217;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.<\/p>\n<p>This supports paging, which means my REST API doesn&#8217;t have to return all of the tags &#8211; just enough to populate the visible bit of the drop-down list. I&#8217;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&#8217;t need to download everything and filter it client-side &#8211; it can be smarter and more efficient than that.<\/p>\n<p>That said, this might be overkill for some needs &#8211; you can easily create a client-side store in memory, without needing to write a REST API to back it.<\/p>\n<p><!--more-->The source to implement it is:<\/p>\n<pre style=\"border: thin solid silver; background-color: #eeeeee; padding: 0.7em; font-size: 1em; overflow: auto;\">define([\"dojo\/_base\/declare\", \"dijit\/form\/ComboBox\", \"dojo\/_base\/lang\", \"dojo\/dom-attr\"],\r\n  function (declare, ComboBox, lang, domAttr) {\r\n    \r\n    \/**\r\n     * Modifies dijit's ComboBox for use when entering comma-separated tags.\r\n     *\r\n     * @author Dale Lane\r\n     *\/\r\n    return declare(\"myapp.ui.form.TagsComboBox\", [ComboBox], {\r\n      \r\n      \/**\r\n       * Character to use to split strings\r\n       *\/\r\n      tagSeparator : \",\",\r\n      \r\n      \r\n      \/**\r\n       * Overrides the behaviour of the ComboBox when submitting a fetch query\r\n       *  to the store.\r\n       *\r\n       * Modifies the key being searched for so that we only search for the\r\n       *  tag currently being entered, rather than the whole comma-separated\r\n       *  string. We assume the tag currently being entered is the one where\r\n       *  the cursor position currently is.\r\n       *\/\r\n      _startSearch: function(\/*String*\/ key){\r\n        var cursorPosition = this._getCaretPos(this.focusNode);\r\n        key = lang.trim(this._getCurrentTag(key, cursorPosition).current);\r\n        this.inherited(arguments);\r\n      },\r\n      \r\n      \r\n      \/**\r\n       * Overrides the behaviour of the ComboBox when the user selects an\r\n       *  item from the drop-down list. Prevents the cursor position being\r\n       *  moved to the end of the text box, which is the default behaviour\r\n       *  when something is selected.\r\n       *\/\r\n      _selectOption: function(\/*DomNode*\/ target){\r\n        this.closeDropDown();\r\n        if(target){\r\n          this._announceOption(target);\r\n        }\r\n        this._handleOnChange(this.value, true);\r\n      },\r\n      \r\n      \r\n      \/**\r\n       * Overrides the behaviour of the ComboBox when the user selects an\r\n       *  item from the drop-down list.\r\n       *\r\n       * Instead of overwriting the contents with the selected item, we want\r\n       *  to overwrite the contents of the current tag only.\r\n       *\/\r\n      _announceOption: function(\/*Node*\/ node){\r\n        if(!node){\r\n          return;\r\n        }\r\n\r\n\r\n        \/\/ get the current cursor position - which we use to\r\n        \/\/  work out which tag is being edited\r\n        var cursorPosition = this._getCaretPos(this.focusNode);\r\n        \r\n        \/\/ pull the text value from the item attached to the DOM node\r\n        var newValue;\r\n        if(node == this.dropDown.nextButton ||\r\n          node == this.dropDown.previousButton){\r\n          newValue = node.innerHTML;\r\n          this.item = undefined;\r\n          this.value = '';\r\n        }else{\r\n          newValue = (this.store._oldAPI ?  \/\/ remove getValue() for 2.0 (old dojo.data API)\r\n            this.store.getValue(node.item, this.searchAttr) : node.item[this.searchAttr]).toString();\r\n          var newValueLength = newValue.length;\r\n          \r\n          \/\/ identify the boundaries of the word that the\r\n          \/\/  user is currently editing\r\n          var tagInfo = this._getCurrentTag();\r\n          \/\/ insert the selected value into the current string\r\n          newValue = tagInfo.before + newValue + tagInfo.after;\r\n          this.set('item', node.item, false, newValue);\r\n          \/\/ move the cursor to the end of the inserted word\r\n          cursorPosition = tagInfo.before.length + newValueLength;\r\n        }\r\n        \/\/ get the text that the user manually entered (cut off autocompleted text)\r\n        this.focusNode.value = this.focusNode.value.substring(0, this._lastInput.length);\r\n        \/\/ set up ARIA activedescendant\r\n        this.focusNode.setAttribute(\"aria-activedescendant\", domAttr.get(node, \"id\"));\r\n        \/\/ autocomplete the rest of the option to announce change\r\n        this._autoCompleteText(newValue);\r\n        \/\/ reset the cursor position\r\n        this._setCaretPos(this.focusNode, cursorPosition);\r\n      },\r\n      \r\n      \r\n      \/**\r\n       * Assumes the key string is divided into comma-separated tags.\r\n       *  Returns the substring which is the tag contained within the\r\n       *  commas separating the provided current cursor position.\r\n       *\/\r\n      _getCurrentTag : function (){\r\n        var cursorPosition = this._getCaretPos(this.focusNode);\r\n        var results = {\r\n          \"before\"  : \"\",\r\n          \"after\"   : \"\",\r\n          \"current\" : \"\"\r\n        };\r\n        var ptrComma = 0;\r\n        var key = this.focusNode.value;\r\n        while (ptrComma &lt;= key.length){\r\n          var ptrCommaNext = key.indexOf(this.tagSeparator, ptrComma);\r\n        \r\n          if (ptrCommaNext === -1){\r\n            results.before  = key.substr(0, ptrComma);\r\n            results.current = key.substr(ptrComma);\r\n            break;\r\n          }\r\n          else if (ptrCommaNext &lt; cursorPosition) {\r\n            ptrComma = ptrCommaNext + this.tagSeparator.length;\r\n          }\r\n          else {\r\n            results.before  = key.substr(0, ptrComma);\r\n            results.after   = key.substr(ptrCommaNext);\r\n            results.current = key.substr(ptrComma, (ptrCommaNext - ptrComma));\r\n            break;\r\n          }\r\n        }\r\n        return results;\r\n      }\r\n    });\r\n  }\r\n);<\/pre>\n<p>How do you use it? You can use it as you would a ComboBox &#8211; so anything in <a href=\"http:\/\/dojotoolkit.org\/reference-guide\/1.7\/dijit\/form\/ComboBox.html\">the documentation and examples for ComboBox<\/a> should work with this.<\/p>\n<p>Finally, if you&#8217;re familiar with dojo, you might be aware that dojo actually has an experimental widget for implementing a combo box for entering tags : <a href=\"http:\/\/dojotoolkit.org\/reference-guide\/1.7\/dojox\/form\/MultiComboBox.html\">dojox.form.MultiComboBox<\/a>. You might be wondering why I didn&#8217;t just use that instead.<\/p>\n<p>You can try the current version of it for yourself at <a href=\"http:\/\/archive.dojotoolkit.org\/nightly\/dojotoolkit\/dojox\/form\/tests\/test_MultiComboBox.html\">archive.dojotoolkit.org<\/a>.<\/p>\n<p>Basically, it doesn&#8217;t work quite how I needed. For example, it assumes that you&#8217;ll always want to edit the last tag in the list &#8211; 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. <\/p>\n<p>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&#8217;t have gained much by modifying MultiComboBox instead of ComboBox itself.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I needed a text box for entering tags on a dojo web app. I ended up making my own &#8211; it was only a hundred or so lines of code, but I&#8217;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 [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[540,539,514,541,542,572],"class_list":["post-2232","post","type-post","status-publish","format-standard","hentry","category-code","tag-combobox","tag-dijit","tag-dojo","tag-tags","tag-ui","tag-web"],"_links":{"self":[{"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/posts\/2232","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=2232"}],"version-history":[{"count":0,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=\/wp\/v2\/posts\/2232\/revisions"}],"wp:attachment":[{"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=2232"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=2232"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/dalelane.co.uk\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=2232"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}