[jQuery] New plugin: Autocompleter

[jQuery] New plugin: Autocompleter

Here's a new plugin I wrote called Autocompleter. Guess what it does?
I can't upload it to my demo site now, so I'll just give you the source code.
Usage $("select_an_input_element").autocomplete(url, options);
url = url to autocomplete function, will receive a get parameter named
"q" (sans quotes) with the partial text to be completed. the page
should output each result on a single lines (empty lines are ignored),
any extra information should be preceded by a pipe | symbol.
options = lots of stuff for caching, extra parameters, delays, min
number of keys, etc, all optional
known issues:
1) i'm stripping all whitespace from the response because I was
squashing a bug and din't need whitespace. this should be improved on
:-)
2) I had trouble with the input element so I used a hack to get a DIV around it
3) probably some other stuff, since I just got it working like i
wanted to today, and haven't really tested it
Have fun, and please let me know what you think of it.
Dylan
suggested css:
.ac_wrapper {
    width: 200px;
}
.ac_wrapper input {
    width: 100%;
}
.ac_wrapper ul {
    list-style: none;
    padding: 0;
    margin: 0;
}
.ac_results {
    background: #ccc;
    cursor: pointer;
    position: absolute;
}
.ac_results li {
    padding: 2px 5px;
}
.ac_loading {
    background : url('../img/indicator.gif') right center no-repeat;
}
.over {
    background: yellow;
}
source code:
$.autocomplete = function(wrapper, options) {
    $(wrapper).removeClass(options.removeClass);
    var results = document.createElement("div");
    $(results).hide();
    wrapper.appendChild(results);
    $(results).set("class", options.resultsClass);
    var input = $(wrapper).find("input").get(0);
    input.autocomplete = this;
    
    var timeout = null;
    var prev = "";
    var active = -1;
    var cache = {};
    $(input).keypress(function(e) {
        switch(e.keyCode) {
            case 38: // up
                e.preventDefault();
                moveSelect(-1);
                break;
            case 40: // down
                e.preventDefault();
                moveSelect(1);
                break;                    
            case 9: // tab
            case 13: // return
                if (selectCurrent()) {
                    e.preventDefault();
                    selectCurrent();
                }
                break;
            default:
                if (timeout) clearTimeout(timeout);
                timeout = setTimeout(onChange, options.delay);
                break;
        }
    });
    
    $(input).blur(hideResults);
    // $(document).click(hideResults);
    hideResultsNow();
    
    function onChange() {
        var v = $(input).val();
        if (v == prev) return;
        if (prev = v && v.length >= options.minChars) {
            $(input).addClass(options.loadingClass);
            requestData(v);
        } else {
            $(input).removeClass(options.loadingClass);
            $(results).hide();
        }
    };
    function moveSelect(step) {
        var lis = $(results).find("li");
        if (!lis) return;
        
        active += step;
        if (active < 0) {
            active = 0;
        } else if (active >= lis.size()) {
            active = lis.size() - 1;
        }
        
        lis.removeClass("over");
        $(lis.get(active)).addClass("over");
    };
    function selectCurrent() {
        var li = $(results).find("li.over").get(0);
        if (li) {
            selectItem(li);
            return true;
        } else {
            return false;
        }
    };
    
    function selectItem(li) {
        var v = $.cleanSpaces(li.innerHTML);
        input.lastSelected = v;
        prev = v;
        $(results).html("");
        $(input).val(v);
        hideResultsNow();
        if (options.onItemSelect) setTimeout(function() {
options.onItemSelect(li) }, 1);
    };
    
    function hideResults() {
        if (timeout) clearTimeout(timeout);
        timeout = setTimeout(hideResultsNow, 100);
    };
    
    function hideResultsNow() {
        if (timeout) clearTimeout(timeout);
        if ($(results).is(":visible")) {
            $(results).hide();
            if (options.mustMatch) {
                var v = $(input).val();
                if (v != input.lastSelected) {
                    selectItem(document.createElement("li"));
                }
            }
        }
    };
    
    function receiveData(q, data) {
        if (data) {
            $(input).removeClass(options.loadingClass);
            results.innerHTML = "";
            results.appendChild(dataToDom(data));
            $(results).show();
        } else {
            hideResultsNow();
        }
    };
    function parseData(data) {
        if (!data) return null;
        var parsed = [];
        var rows = data.split(options.lineSeparator);
        for (var i=0; i < rows.length; i++) {
            var row = rows[i].replace(/\s/g, "");
            if (row) parsed[parsed.length] = row.split(options.cellSeparator);
        }
        return parsed;
    };
    
    function dataToDom(data) {
        var ul = document.createElement("ul");
        for (var i=0; i < data.length; i++) {
            var row = data[i];
            if (!row) continue;
            var li = document.createElement("li");
            li.innerHTML = row[0];
            li.extra = row.length > 1 ? row[1] : null;
            $(li).hover(
                function() { $(this).addClass("over"); },
                function() { $(this).removeClass("over"); }
            ).click(function(e) { selectItem(this) });
            ul.appendChild(li);
        }
        return ul;
    };
    
    function requestData(q) {
        if (!options.matchCase) q = q.toLowerCase();
        var data = options.cacheLength ? loadFromCache(q) : null;
        if (data) {
            receiveData(q, data);
        } else {
            $.get(makeUrl(q), function(data) {
                data = parseData(data)
                addToCache(q, data);
                receiveData(q, data);
            });
        }
    };
    
    function makeUrl(q) {
        var url = options.url + "?q=" + q;
        for (var i in options.extraParams) {
            url += "&" + i + "=" + options.extraParams[i];
        }
        return url;
    };
    
    function loadFromCache(q) {
        if (!q) return null;
        if (cache[q]) return cache[q];
        if (options.matchSubset) {
            for (var i = q.length - 1; i >= options.minChars; i--) {
                var qs = q.substr(0, i);
                var c = cache[qs];
                if (c) {
                    var csub = [];
                    for (var j = 0; j < c.length; j++) {
                        var x = c[j];
                        var x0 = x[0];
                        if (matchSubset(x0, q)) {
                            csub[csub.length] = x;
                        }
                    }
                    return csub;
                }
            }
        }
        return null;
    };
    
    function matchSubset(s, sub) {
        if (!options.matchCase) s = s.toLowerCase();
        var i = s.indexOf(sub);
        if (i == -1) return false;
        return i == 0 || options.matchContains;
    };
    
    function flushCache() {
        cache = {};
    };
    function addToCache(q, data) {
        if (!data || !q || !options.cacheLength) return;
        if (!cache.length || cache.length > options.cacheLength) {
            flushCache();
            cache.length = 1; // we know we're adding something
        } else if (!cache[q]) {
            cache.length++;
        }
        cache[q] = data;
    };
}
$.fn.autocomplete = function(url, options) {
    // Make sure options exists
    options = options || {};
    // Set url as option
    options.url = url;
    // Set removeClass as option
    options.removeClass = "ac___remove___this___class";
    // Set default values for required options
    options.wrapperClass = options.wrapperClass || "ac_wrapper";
    options.resultsClass = options.resultsClass || "ac_results";
    options.lineSeparator = options.lineSeparator || "\n";
    options.cellSeparator = options.cellSeparator || "|";
    options.minChars = options.minChars || 1;
    options.delay = options.delay || 200;
    options.matchCase = options.matchCase || 0;
    options.matchSubset = options.matchSubset || 1;
    options.matchContains = options.matchContains || 0;
    options.cacheLength = options.cacheLength || 1;
    options.mustMatch = options.mustMatch || 0;
    options.extraParams = options.extraParams || {};
    options.loadingClass = options.loadingClass || "ac_loading";
    // Wrap our input elements with a DIV
    this.wrap("<div class='" + options.wrapperClass + " " +
options.removeClass + "'></div>");
    // Find all the newly created DIVs and create an autcompleter for each of them
    $("." + options.removeClass).each(function() { new
$.autocomplete(this, options); });
    // Don't break the chain
    return this;
}
_______________________________________________
jQuery mailing list
discuss@jquery.com
http://jquery.com/discuss/