(function($) { function _arrayToVal( array, ele ) { var tags_array = []; $.each( array, function(key) { tags_array.push( key ); }); ele.val( tags_array.join(",") ); ele.trigger("change"); } // this doesn't act on the autocompletewithunknown widget. Instead, it's called on our input field with autocompletion function _handleComplete(e, ui, $oldElement) { var self = $(this).data("autocompletewithunknown"); var ele = self.element; var curtagslist = self.cachemap[self.currentCache]; var items = ui.item.value.toLowerCase(); $.each( items.split(","), function() { var tag = $.trim(this); // check if the tag is empty, or if we had previously used it if ( tag == "" ) return; // TODO: don't put it in if the word exceeds the maximum length of a tag if ( ! self.tagslist[tag] ) { self.tagslist[tag] = true; var tokentype = curtagslist && curtagslist[tag] ? self.options.curTokenClass : self.options.newTokenClass; var $text = $("") .addClass(self.options.tokenTextClass) .text(tag); var $a = $("").addClass(self.options.tokenRemoveClass).html("" + "Remove " + tag + ""); var $li = $("
  • ").addClass(self.options.tokenClass + " " + tokentype) .append($text).append($a).append(" ") .attr( "title", tokentype == self.options.curTokenClass ? "tag: " + tag : "new tag: " + tag); if ( $oldElement ) $oldElement.replaceWith($li); else $li.appendTo(self.uiAutocompletelist); } }); $("#"+self.options.id+"_count_label").text("characters per tag:"); $(this).val(""); if ( self.options.grow ) { // shrink $(this).height((parseInt($(this).css('lineHeight').replace(/px$/,''), 10)||20) + 'px'); if ( self.options.maxlength ) $("#"+self.options.id+"_count").text(self.options.maxlength); } _arrayToVal(self.tagslist, ele) if ( $.browser.opera ) { var keyCode = $.ui.keyCode; if( e.which == keyCode.ENTER || e.which == keyCode.TAB ) self.justCompleted = true; } // this prevents the default behavior of having the // form field filled with the autocompleted text. // Target the event type, to avoid issues with selecting using TAB if ( e.type == "autocompleteselect" ) { e.preventDefault(); return false; } } $.widget("ui.autocompletewithunknown", { options: { id: null, tokenClass: "token", tokenTextClass: "token-text", tokenRemoveClass: "token-remove", newTokenClass: "new", curTokenClass: "autocomplete", numMatches: 20, populateSource: null, // initial location to populate from populateId: "", // initial identifier for the list to be cached grow: false // whether to automatically grow the autocomplete textarea or not }, _filterTags: function( array, term ) { var self = this; var startsWithTerm = []; term = $.trim(term.toLowerCase()); var matcher = new RegExp( $.ui.autocomplete.escapeRegex(term) ); var filtered = $.grep( array, function(value) { var val = value.label || value.value || value; if ( !self.tagslist[val] && matcher.test( val ) ) { if ( val.indexOf(term) == 0 ) { // ugly, but we'd like to handle terms that start with // in a different manner startsWithTerm.push({ value: val, label: val.replace(term, ""+term+"")}); return false; } return true; } }); // second step, because we first need to find out how many // total start with the term, and so need to be in the list // those that only contain the term fill in any remaining slots var responseArray = startsWithTerm.slice(0, self.options.numMatches); $.each(filtered, function(index, value) { if ( responseArray.length >= self.options.numMatches ) return false; responseArray.push({ value: value, label: value.replace(term, ""+term+"")}); }) return responseArray; }, _create: function() { var self = this; if (!self.options.id) self.options.id = "autocomplete_"+self.element.attr("id"); self.uiAutocomplete = self.element.wrap("").parent().attr("id", self.options.id).addClass(self.element.attr("class")); self.uiAutocompletelist = $("").appendTo(self.uiAutocomplete).attr( "aria-live", "assertive" ); // this is just frontend; will use JS to transfer contents to the original field (now hidden) self.uiAutocompleteInput = $("") .appendTo(self.uiAutocomplete) .data("autocompletewithunknown", self) .focus(function(e) { $(this).closest(".autocomplete-container").addClass("focus"); }) .blur(function(e) { $(this).closest(".autocomplete-container").removeClass("focus"); }); if ( self.options.grow ) { self.uiAutocomplete.closest(".row").parent().find(".tagselector-controls") .append("
    characters per tag: 50
    "); $(self.uiAutocompleteInput).vertigro(self.options.maxlength, self.options.id + "_count"); } self.element.hide(); self.tagslist = {}; self.cache = {}; self.cachemap = {}; if ( self.options.populateSource ) self.populate(self.options.populateSource, self.options.populateId); self.uiAutocompleteInput.autocomplete({ source: function (request, response) { if ( self.cache[self.currentCache] != null ) return response( self._filterTags( self.cache[self.currentCache], request.term ) ); }, autoFocus: true, select: _handleComplete }).bind("keydown.autocompleteselect", function( event ) { var keyCode = $.ui.keyCode; var $input = $(this); $("#"+self.options.id+"_count_label").text("characters left:"); switch( event.which ) { case keyCode.ENTER: _handleComplete.apply( $input, [event, { item: { value: $input.val() } } ]); self.justCompleted = true; $input.autocomplete("close"); event.preventDefault(); return; case keyCode.TAB: if ($input.val()) { _handleComplete.apply( $input, [event, { item: { value: $input.val() } } ]); self.justCompleted = true; event.preventDefault(); } return; case keyCode.BACKSPACE: if( ! $input.val() ) { $("."+self.options.tokenRemoveClass + ":last", self.uiAutocomplete).focus(); event.preventDefault(); } return; } }).bind("keyup.autocompleteselect", function( event ) { var $input = $(this); if ( $input.val().indexOf(",") > -1 ) _handleComplete.apply( $input, [event, { item: { value: $input.val() } } ]); }).change(function(event){ // if we have the menu open, let that handle the autocomplete var $menu = $(this).data("ui-autocomplete").menu; if ( $menu.element.is(":visible") ) return; _handleComplete.apply( $(this), [event, { item: { value: $(event.currentTarget).val() } } ]); // workaround for autocompleting with TAB in opera if ( $.browser.opera && self.justCompleted ) { $(this).focus(); self.justCompleted = false; } }) .data("ui-autocomplete")._renderItem = function( ul, item ) { return $( "
  • " ) .data( "ui-autocomplete-item", item ) .append( "" + item.label + "" ) .appendTo( ul ); }; // so other things can reinitialize the widget with their own text $(self.element).bind("autocomplete_inittext", function( event, new_text ) { self.tagslist = {}; self.uiAutocompletelist.empty(); _handleComplete.apply( self.uiAutocompleteInput, [ event, { item: { value: new_text } } ] ); }); $(self.element).trigger("autocomplete_inittext", self.element.val()); // replace one text $(self.element).bind("autocomplete_edittext", function ( event, $element, new_text ) { _handleComplete.apply( self.uiAutocompleteInput, [ event, { item: { value: new_text } }, $element ] ); }); $(".autocomplete-container").click(function() { self.uiAutocompleteInput.focus(); }); $("span."+self.options.tokenTextClass, self.uiAutocomplete.get(0)) .live("click", function(event) { delete self.tagslist[$(this).text()]; _arrayToVal(self.tagslist, self.element); var $input = $("") .addClass(self.options.tokenTextClass) .val($(this).text()) .width($(this).width()+5); $(this).replaceWith($input); $input.focus(); event.stopPropagation(); }); $("input."+self.options.tokenTextClass,self.uiAutocomplete.get(0)) .live("blur", function(event) { $(self.element).trigger("autocomplete_edittext", [ $(this).closest("li"), $(this).val() ] ); }); $("."+self.options.tokenRemoveClass, self.uiAutocomplete.get(0)).live("click", function(e) { var $token = $(this).closest("."+self.options.tokenClass); delete self.tagslist[$token.children("."+self.options.tokenTextClass).text()]; _arrayToVal(self.tagslist, self.element); $token.fadeOut(function() {$(this).remove()}); e.preventDefault(); e.stopPropagation(); }).live("focus", function(event) { $(this).parent().addClass("focus"); }).live("blur", function(event) { $(this).parent().removeClass("focus"); }).live("keydown", function(event) { if ( event.which == $.ui.keyCode.BACKSPACE ) { event.preventDefault(); var $prevToken = $(this).closest("."+self.options.tokenClass).prev("."+self.options.tokenClass); $(this).click(); if ($prevToken.length == 1) $prevToken.find("."+self.options.tokenRemoveClass).focus(); else self.uiAutocompleteInput.focus(); } else if (event.which != $.ui.keyCode.TAB) { $(this).siblings("."+self.options.tokenTextClass).click(); } }); // workaround for autocompleting with ENTER in opera self.justCompleted = false; $.browser.opera && $(self.element.get(0).form).bind("submit.autocomplete", function(e) { // this tries to make sure that we don't try to validate crossposting, if we only hit enter // to autocomplete. Workaround for opera. // Sort of like a lock, to mark which handler last prevented the form submission. // TODO: refactor this out into something that we're sure works. We are at the mercy // of the way that Opera and other browsers order the handlers. if ( self.element.data("preventedby") == self.options.id) self.element.data("preventedby", null) if( self.justCompleted ) { if ( ! self.element.data("preventedby") ) self.element.data("preventedby", self.options.id); self.justCompleted = false; return false; } }); }, // store this in an array, and in a hash, so that we can easily look up presence of tags _cacheData: function(array, key) { this.currentCache=key; this.cache[this.currentCache] = array; this.cachemap[this.currentCache] = {}; for( var i in array ) { this.cachemap[this.currentCache][array[i]] = true; } }, tagstatus: function(key) { var self = this; if ( !self.cachemap[key] ) return; // recheck tags status in case tags list loaded slowly // or we switched comms $("."+self.options.tokenClass, self.uiAutocomplete).each(function() { var exists = self.cachemap[key][$(this).find("."+self.options.tokenTextClass).text()]; $(this).toggleClass(self.options.newTokenClass, !exists).toggleClass(self.options.curTokenClass, exists); }); }, populate: function(source, id) { var self = this; // Source is a URL endpoint we need to fetch if (typeof source === 'string' || source instanceof String) { $.getJSON(source, function(data) { if ( !data ) return; self._cacheData(data.tags, id); self.tagstatus(id); }); } else if (Array.isArray(source)) { // Source is a static array, just store it. self._cacheData(source, id); self.tagstatus(id); } }, clear: function () { var self = this; self._cacheData([], ""); self.tagstatus(""); } }); })(jQuery);