Wednesday, September 21, 2011

Find-As-You-Type

There's an enhancement request at my client's office to add a find-as-you-type search feature to one of their forms. They have two applications, one is an ASP.NET website and one is a WPF thick client, and they need this feature added to the corresponding form on both. I'll be developing the former and a colleague will be developing the latter.

(Side note: The former makes use of a single line of code in the UI from a jQuery plugin, whereas the latter will apparently require writing a custom control and a few days of work. Man, I love being a web developer.)

Naturally, this functionality is easily implemented on the web page with the jQuery UI Autocomplete plugin:

$('#searchText').autocomplete({
  source: 'AJAXHandler.ashx'
});

Behind the AJAX handler will be a call to the same back-end code that the thick client uses. In this case the handler will simply translate the returned models into strings to return to the AJAX call. Very simple. Since it took all of a few minutes to write this, I found myself with a lot of extra time. So I wondered what would be involved in actually writing this UI functionality myself (were I to live in a world where jQuery UI didn't exist, but at least for the sake of this exercise jQuery itself does).

Since this is a custom implementation for this specific page, and since I only had about an hour to work on it, it's not nearly as robust and universally pluggable/applicable as the jQuery UI version. But it was fun to write. I started with the div to hold the results:



And, of course, style it (to include making it invisible by default):

#searchResults
{
  background: white; 
  border: 1px solid black; 
  position: relative; 
  top: 0px; 
  left: 0px; 
  display: none; 
  text-align: left;
  float: right;
}

Next, we need to populate the results into the div when we type:

$('#searchText').keyup(function(key) {
  if ($('#searchText').val() != '') {
    // The search string isn't empty,
    // so remove any current results and search again
    $('#searchResults').empty();
    $.ajax({
      cache: false,
      url: 'AJAXHandler.ashx?term=' + $('#searchText').val(),
      success: function(data) {
        // Build the HTML results from the response
        var options = '';
        for (var i = 0; i < data.length; i++) {
          options += '' + data[i] + '';
        }
        // Add the HTML results to the element and show the element
        $('#searchResults').html(options);
        $('#searchResults').show();
      },
      error: function(msg) {
        // Currently do nothing.  The user can continue
        // to search by editing the text again.
      },
      dataType: 'json'
    });
  }
  // If the text is empty, clear and hide the element.
  else {
    $('#searchResults').empty();
    $('#searchResults').hide();
  }
});

Works like a charm. Well, it doesn't have the delay functionality that the jQuery UI plugin has, and I'm not going to bother to add it in this exercise. But the concept would be simple. Create a value to hold the timer and on each keystroke reset it. Perform the server request when the timer elapses. I'm not 100% sure of the most elegant way to do that in JavaScript, but if I had more time I'm sure it wouldn't be difficult.

Anyway, now we need to make the list navigable. Let's start by defining a CSS class for the results and another one to indicate that something is currently selected:

.searchResult
{
  display: block;
  cursor: pointer;
  width: 100%;
}
.searchResultHover
{
  background: #FBEC88;
}

I used a class for "hovering" instead of the CSS :hover pseudo-class because we're going to use the existence of this class on an element to indicate that it's currently selected, so we'll need it in a jQuery selector later. Now we need to manually set the hover effect on the search results:

$('.searchResult').live({
  mouseenter: function() {
    // Remove the hover effect from all of the elements
    $('#searchResults').children().each(function() {
      $(this).removeClass('searchResultHover');
    });
    // Add the hover effect to this element
    $(this).addClass('searchResultHover');
  },
  mouseleave: function() {
    // Remove the hover effect from this element
    $(this).removeClass('searchResultHover');
  }
});

As always, there's likely a more elegant way to do this. But it gets the job done. I've sort of doubled-up the logic of removing the hover effect just in case there's some weirdness with the mouse. The idea is that at any given time no more than one element (but potentially none) should have this effect.

We also need to make the elements clickable and have them set the text into the input:

$('.searchResult').live('click', function() {
  $('#searchText').blur();
  $('#searchText').val($(this).text());
  $('#searchResults').empty();
  $('#searchResults').hide();
});

By this point I still had a little bit of time left, so I figured I'd add keyboard navigation functionality. So when the focus is on the search text input, we need to handle the arrow keys. We'll do this by adding some more conditionals to the keyup event binding from earlier:

// If the user presses the down arrow, navigate down the current list.
if (key.keyCode == 40) {
  var selection = $('.searchResultHover').first();
  if (selection.length) {
    // A selected element was found.  Move down one.
    selection.removeClass('searchResultHover');
    selection.next().addClass('searchResultHover');
  }
  else {
    // No selected element was found.  Select the first one.
    $('#searchResults').children().first().addClass('searchResultHover');
  }
}
// If the user presses the up arrow, navigate up the current list.
else if (key.keyCode == 38) {
  var selection = $('.searchResultHover').first();
  if (selection.length) {
    // A selected element was found.  Move up one.
    selection.removeClass('searchResultHover');
    selection.prev().addClass('searchResultHover');
  }
  else {
    // No selected element was found.  Select the first one.
    $('#searchResults').children().last().addClass('searchResultHover');
  }
}

Finally, our end users are going to want to be able to select an element by hitting return when they're navigating with the arrows like this. (I would recommend tab instead, but there were two barriers to that. First, the users want to use return. They don't know of tab completion standards or anything like that. Second, tab keyboard events are kind of weird in JavaScript. I tried using tab for a while and it didn't quite work right between browsers. IE in particular, which is the client's browser of choice, was a little wonky with me hijacking the tab key.)

$('#searchText').keypress(function(key) {
  // If the user presses the return key, "click" the current list element.
  // This is happening in keypress instead of keyup for a number of reasons.
  // Not the lease of which is because I was trying to use tab instead,
  // which throws a keyup event when tabbing into the field.
  if (key.keyCode == 13) {
    $('.searchResult').first().click();
    // Explicitly set the focus, just in case
    $('#searchText').focus();
    // Return false so we don't accidentally submit a form or anything.
    return false;
  }
});

Well that was fun. Again, it's not nearly as mature a solution as the jQuery UI Autocomplete plugin. But, again, it gets the job done. A lot more polishing can be done here and it can be generalized quite a bit, but all in all not bad for an hour's work.

No comments:

Post a Comment