Live Search with Quicksilver Style
Live search? C’mon guys, this has been done plenty of times before, you say. Yes, it has but I promise you we’ve added a bit of a twist to it.
When working on the latest incarnation of orderedlist.com, Steve mentioned that the typical year and month layout of archives was overkill. He thought a better way to present them would be to list them all and let live search sort ’em out. That simple thought fired a neuron in my brain. I am a fan of QuickSilver and I remembered seeing a port of the QuickSilver string ranking algorithm in JavaScript. A side note: anytime I say “algorithm” in a sentence I feel really smart. The fact that in the previous sentence “algorithm” was preceded by “string ranking” made me feel doubly smart. Ahem…back to the point at hand.
Demos
Typically, when I am reading a tutorial of any sort, I want to see the end product because if that is not impressive, I do not want to waste my time reading it. Feel free to open up a new tab with the archives page or the simplified demo I created just for this post and try a few searches.
Type This, Get That
Sure you can search ‘sidebar creative’ to find posts titled that, but I am lazy. I’d rather type ‘sdbcv’ because it is shorter. Plus, vowels are so 2007. You know who else is cool? Yeah, that guy Nunemaker, but holy crap is that name a keyboardful. Why don’t we just hit a few of the important characters like ‘nnmkr’. Bing, bang, boom. Welcome John Nunemaker is right there at the top. Cool, eh?
The How
First things first, we need a JS library to take away the hurt of cross-browserness. For this exercise, we used Prototype.
Second, we drop in the JS QS string ranking algorithm (I still feel smart).
Third, I made the simple prototype class featured below. It is commented pretty well with what is going on. Also, note that this is not the live version on Ordered List but a slightly simplified one that I made for the demo. Go ahead and give it a read through.
var QuicksilverLiveSearch = Class.create({ /** * Sets up the caches and adds observers */ initialize: function(field, list) { this.field = $(field); this.list = $(list); if (this.field && this.list) { this.rows = $A([]); this.cache = $A([]); this.setupCache(); // kill normal submit of form since it's live this.form = this.field.up('form'); this.form.observe('submit', function(e) { e.stop(); }); // setup observer on the search field to run the filter when typing this.field.observe('keyup', this.filter.bindAsEventListener(this)); // run the filter initially for any text that may be in it this.filter(); } }, /** * Caches inner html of children in array for later manipulation. */ setupCache: function() { // loop through immediate descendents (in this case li's) and push // their lowercase text to the cache and the li to the rows this.list.immediateDescendants().each(function(child) { this.cache.push(child.innerHTML.toLowerCase()); this.rows.push(child); }.bind(this)); this.cache_length = this.cache.length; }, /** * Runs the filter that only shows the rows * that have a score based on the search term. */ filter: function() { // if nothing is in the field show all the rows if (!this.field.present()) { this.rows.invoke('show'); return; } // get the scores and hide the low scoring items this.displayResults(this.getScores($F(this.field).toLowerCase())); }, /** * Hides all the rows and shows on the ones with a score over 0 */ displayResults: function(scores) { // hide all rows default this.rows.invoke('hide'); // show each row that had a score scores.each(function(score) { this.rows[score[1]].show(); }.bind(this)) }, /** * Get the score of each row in the cache and return sorted * result set of [score, index of row in this.rows] */ getScores: function(term) { var scores = $A([]); // loop through the cache and get the score for each item // appending them to the return set if they have a score // greater than 0; basically building an array like this: // [[0.69, 2], [0.33, 34], ...] where the first element is // the string score and the second is the index of the item // that scored that for (var i=0; i < this.cache_length; i++) { var score = this.cache[i].score(term); if (score > 0) { scores.push([score, i]); } } // sort the scores descending by the algorithm score (the first element in the array) return scores.sort(function(a, b) { return b[0] - a[0]; }); } });
Now all that is left is to create a new instance of this class once the dom has loaded.
document.observe('dom:loaded', function() { new QuicksilverLiveSearch('q', 'posts'); });
As you can see, most of the heavy lifting is done by prototype and the quicksilver string ranker. All I do is build a cache of all the strings I want to search through and then show only the rows that have strings with a score greater than 0. Simple implementation with pretty cool results so I thought I would share it here.