One thing that encourages me in my journey as a designer and builder of interfaces and digital experiences is seeing noticeable growth in my abilities over time. Even better when it’s a relatively short period of time.

Someone at Adaptiva recently asked me about the possibility of adding a search feature to the Adaptiva Academy. It had come up before, but the last time was when we were defining requirements for the page, and I had to say, “Honestly, I don’t know how to do that.”

The cool part is that was six months ago, and this time around, my answer was a lot different. More like, “Oh yeah, I forgot about that. Lemme knock that out real quick.”

The Plan

This search function is kinda weird/unique/fun because it’s entirely run in the front end. Since adaptiva.com is a statically generated site (thanks GitHub Pages), it’s not like I have a database to query anyway.

The Academy is essentially a big grid of tiles that all link to downloadable assets, like product resources, webinar recordings, community tools, etc. Each asset is an <a> element and has a title attribute with the full asset title, which is what we match the query against.

The initial plan was to loop through all the assets to see if any of them match the query. If we get a match, push that jQuery object to an array called results.

Then, I would loop through results and add a modifying class on each object in the array to mark it as a match. Then it’s just a matter of hiding all objects and only showing those with the new mod class.

The one thing I knew would be tricky was the dropdown menu that was already on the page that controlled which asset categories were displayed. What if the user selects a category before searching? Or after? D:

Alpha

It didn’t take long to get some basic functionality up and running.

Inside a .submit() function on the search form, I first created an array called results and a variable query equal to the value of the search input on submission.

search.submit(function(e){

  e.preventDefault();

  var results = [],
      scope = $('.asset.is-showing'),
      query = searchBar.val().toLowerCase();

});

Then I split up the query by word into a new array called q.

q = query.split(' ');

Then, I looped through the assets on the page, and found their title attribute.

$('.asset').each(function() {
  var title = $(this).attr('title').toLowerCase();
});

Nice. Inside that loop, I wrote a regular JavaScript loop for the q array. This loop contains a boolean variable match that determines whether the current object gets pushed to results or not.

Also stored in the variable section is reg, a regex rule for the current word in the for loop.

If the title matches the string for the current word in the loop, it logs the match and sets match to true.

Then, if match is true, push $(this), or the current jQuery object in the .each() loop, to the results array.

for (var i = 0; i < q.length; i++) {
  var reg = new RegExp(q[i], 'g'),
      match = false;

  if (title.match(reg)) {
    console.log('Matched word "' + q[i] +'"');
    match = true;
  }
}

if (match) {
  results.push($(this));
}

match = false; // reset match

At the end, I set match back to false because otherwise it would add everything after the first asset returned true.

Then, the easy jQuery DOM manipulation jazz. Removing mod classes and hiding everything first, then adding the mod class for objects in results, and showing only those.

$asset.removeClass('is-showing is-match').hide();

$.each(results, function(){
  $(this).addClass('is-match');
});

$('.asset.is-match').addClass('is-showing').show();

Voila. At this point, I had a working product. I could type in “webinar” and it would return everything with “webinar” in the title. And damn, it’s lightweight and … nimble if I do say so myself.

But, it still totally sucked because if the user typed in multiple words, the whole experience kinda fell apart.

If the user typed in “[product name or something] webinar”, it would essentially return everything you’d get from “webinar” and whatever product or other word entered in with it. And the results would show up organized by date, so the order in which you typed the query made no difference. Garbage.

But it was late Friday afternoon and I had to go to my wife’s company’s holiday party that night, so further improvements would have to wait until Monday.

Beta

At this point, the goal was to build a scoring system and display the results in order of highest to lowest score.

A slightly buzzed conversation with one of the engineers at the holiday party gave me ideas on how to weight the score based on word order as well.

Inside the first loop, I added a couple variables, score and bonus. bonus is set to the amount of assets currently showing on the page. I also created a new variable in the .submit() function called scope, set to $('.asset.is-showing').

// ...
score = 0,
bonus = scope.length;

Then inside the loop through the q array, I scored the assets that met the match criteria. I subtracted the numeric value of the matched word’s index in the query from the bonus, so that word order would affect scores for multi-word entries.

Basically, if an asset matches the first word in the query, it gets the full bonus minus zero, since the word is the first in the array. Words with an index of greater than zero will subsequently receive less than the full bonus. All matched assets are given a DOM attribute data-score with the value of score.

for (var i = 0; i < q.length; i++) {
  if (title.match(reg)) {
    // ...
    score += (bonus - i);
    score++;
  }
}

if (match) {
  $(this).attr('data-score', score);
  results.push($(this));
}

Next up, .sort() the results by score. I created a variable container set to the HTML element that contained the grid of assets and sorted it based on the data-score attribute.

container.find('.asset.is-match').sort(function(a, b) {
  return ($(b).data('score')) > ($(a).data('score')) ? 1 : -1;
}).appendTo(container);

I knew that rearranging objects would create a need to put them back in order, so I created a loop immediately upon document ready to add another DOM attribute called data-original-index to retain the original order.

// immediately loop through all assets
$('.asset').each(function(index) {
  $(this).attr('data-original-index', index);
});

I knew that would be coming up a lot, so I put the code to reorder assets based on original index in a function expression.

var resetAcademy = function() {
  container.find('.asset.is-showing').sort(function(a, b) {
    return ($(b).data('original-index')) < ($(a).data('original-index')) ? 1 : -1;
  }).appendTo(container);
}

よっしゃ! It worked. Results showed up in order of score, and word order in the query affected result order, and I had a function to put them back in their original order whenever I needed to.

UX time

Scope was the big issue for me here. Inherently, the search only queried the assets that were showing on the page. This meant that if a user ran a search, then ran another search, they would be searching within a search. No Inception jokes, please.

Also, the dropdown at the top of the page hides and displays assets by category, thus affecting the scope if the dropdown’s .change() function runs before the search.

This is the intended design, but none of it was being made clear to the user.

After some discussion with my associate art director Adam Haney, we decided that the design should include “breadcrumbs” of the user’s search path and dynamic placeholder text in the search bar to indicate scope, the number of results found, and a “clear search” button.

The intended functionality of the breadcrumbs was so every time a user entered a search, their query would show up on the front end, and they could see their path to a narrower scope from left to right. Additionally, the user should be able to click on previous breadcrumbs (or ‘tags’ as Adam and I called them) to revert their search scope to a previous state.

All of these elements should appear only after the user submitted a query, and should be removed whenever no searches are active.

Dynamic placeholder text

This was pretty easy, so I did that first by creating another function expression called showScope(). This gets called in the .change() function that I already had on the dropdown.

var showScope = function() {
  var searchScope = dropdown.find('option:selected').text();
  searchBar.attr('placeholder', 'Search ' + searchScope);
}

Number of results found

Also easy! Inside the .submit() function on the search form, I added one line to display the number of results inside a span with the id='resultCount'.

$('#resultCount').text(results.length + ' results found for "' + query + '"');

Okay this part was a little more intensive to implement and required a little bit of bashing my head against my keyboard to figure out.

It started out well enough. I created a new variable called tagContainer to append the tags, which were span elements. I didn’t want the DOM freaking out if the user clicked the most recent tag, so I gave it a differentiating class is-active.

if (tagContainer.text().length == 0) {
  tagContainer.append('<span class="search-tags-tag is-active">' + query + '</span>');
} else {
  $('.search-tags-tag').removeClass('is-active');
  // add subsequent tag
  tagContainer.append('<span class="search-tags-tag is-active is-sub">' + query + '</span>');
}

Now, the click functionality.

In order to keep track of the previous search results, I created a new global array called session and pushed results to it on each form submission.

var session = [];
search.submit(function(e) {
  // ...
  session.push(results);
  // ...
});

Then, I wrote a click function for tags (that weren’t the last/only one) to make them actually do something.

The variable index figures out which index the clicked tag holds in the list of tags. The function then finds the results instance at the corresponding index of session, loops through it, and marks them all as matches again.

Here I removed all the tags after the one that was clicked and clipped off the session array at the value of index. I also consolidated the code to show all matched results into a function expression called showMatched().

Then of course, update the text showing the number of results displayed for the reversion of scope.

tags.not('.is-active').click(function() {
  var index = tags.index($(this)),
      queryTxt = $(this).text();

  $(this).nextAll().remove();
  session.length = index + 1;

  $.each(session[index], function() {
    $(this).addClass('is-match');
  });

  // display updated number of results
  $('#resultCount').text(session[index].length + ' results found for "' + queryTxt + '"');
  showMatched();
});

Whoo! This was actually one of those moments where I couldn’t believe how quickly I got that to work and how little code it actually took.

I did some tests by logging session to the console every time a new search was performed, as well as when the scope was reverted. Sure enough, the session showed a length that matched the number of tags showing, and the corresponding results array(s) inside matched up with what the click function displayed on the page.

Clear button

This one was pretty easy compared to the breadcrumbs. While writing this part, I ended up modifying resetAcademy() to handle hiding and showing all the assets.

resetAcademy = function(sort = true, show = false, hide = false) {

  if (sort) {
    container.find('.asset').sort(function(a, b) { // sort by original index
      return ($(b).data('original-index')) < ($(a).data('original-index')) ? 1 : -1;
    }).appendTo(container);
  }

  if (show) {
    $asset.removeClass('is-match').addClass('is-showing').show();
  }

  if (hide) {
    $asset.removeClass('is-showing is-match').hide();
  }

}

The function expression now takes in parameters sort show and hide. I had noticed that every time I called resetAcademy(), I was finding the same snippets of code in the same scope, so why not build them in dynamically? I made all the actions inside the function optionally accessible through the parameters, including the original .sort() block, allowing me to call only the functionaly needed at the time.

And finally, the click function on the ‘clear’ button.

It hides the div.search-info that shows the tags, number of results, and the button, then empties out the tagContainer and the session array. Then it resets the dropdown and the searchbar and runs showScope() to reset the placeholder text.

$('.js-clear-search').click(function() {

  $('.search-info').hide();
  tagContainer.empty();
  session = [];
  resetAcademy(show = true);
	// reset dropdown and search bar
  dropdown.val('all');
  searchBar.val('');
  showScope();

});

The last thing I had to do was make a couple alterations to the .change() function on the dropdown. I wanted the dropdown to take precedence over the search bar, so whenever the user selected a new category, I ran resetAcademy(), hid the search area, emptied out the search tags, reset the search bar, and ran showScope() to set the placeholder text.

dropdown.change(function() {
  //...
  resetAcademy(sort = true, null, hide = true);
  $('.search-info').hide();
  tagContainer.empty();
  searchBar.val('');
  showScope();
});

Here’s how the UI ended up looking:

I added title attributes to the tags that say "Revert search scope back to..." and then whatever the query string they’re reverting to, so users could get unique mouseover cues for each clickable tag.

The .is-active tags have pointer-events: none; so they don’t show the same cue.

It’s beautiful. * kisses fingertips *

v1.0

Here’s the full script with all the optimizations, variables, etc. I continued to ship new iterations after this, which I’ll talk about below, but if you want to skip straight to the live code, that’s currently available to see here:

See the Code

jQuery(document).ready(function($) { // academy ready pants

	// variables
	var $asset = $('.asset'),
            dropdown = $('select#academy'),
            container = $('.academy').find('.grid-container'),
            session = [], // store user search path
            search = $('form.academy-search'),
            searchBar = search.find('input'),
            tagContainer = $('#searchTags'),
      // function expressions
      resetAcademy = function(sort = true, show = false, hide = false) {

        if (sort) {
          container.find('.asset').sort(function(a, b) { // sort by original index
            return ($(b).data('original-index')) < ($(a).data('original-index')) ? 1 : -1;
          }).appendTo(container);
        }

        if (show) {
          $asset.removeClass('is-match').addClass('is-showing').show();
        }

        if (hide) {
          $asset.removeClass('is-showing is-match').hide();
        }

      },
      showScope = function() {

        var searchScope = dropdown.find('option:selected').text();
        searchBar.attr('placeholder', 'Search ' + searchScope);

      },
      showMatched = function() {

        $('.asset.is-match').addClass('is-showing').show();

      };

  	// immediately loop through all assets
    $asset.each(function(index) {

      $(this).attr('data-original-index', index); // create data attr with original index

    }); // end loop

    /* CATEGORY SELECTION */

    dropdown.change(function() { // user selects category from dropdown

      var category = this.value, // store category selection
          selection = $(this).find('option:selected').text(); // text value for selection

      // if prior search reorder and hide search stuff
      resetAcademy(sort = true, null, hide = true);
      $('.search-info').hide();
      searchBar.val('');
      tagContainer.empty();

      // find assets in selected category and show
      $('.asset.' + category).addClass('is-showing').show();

      if (category == 'all') { // if user selects all resources

        $asset.show().addClass('is-showing'); // show all

      }
      // specify scope in searchbar
      showScope();

    });

    /* TILE SIZING */

    $(window).resize(function() {

      var $assetTitle = $('.asset.is-showing .asset-title'),
          assetWidth = $assetTitle.width();

      $assetTitle.css('height', assetWidth + 32); // keep tiles square at all times

    }).resize();

    /* ACADEMY SEARCH */

    search.submit(function(e) { // user submits query :O

      e.preventDefault(); // stop page from reloading

      var results = [], // results go here obvs
          scope = $('.asset.is-showing'), // set scope to only assets currently on page
          query = $('#academySearch').val().toLowerCase(), // user query (case insensitive)
          q = query.split(' '), // split query up by word and add to array
          tags = tagContainer.find('span.search-tags-tag');

      $asset.attr('data-score', '0'); // reset search scores

      /* FIND RESULTS */

      scope.each(function() { // loop through all assets on page

        var title = $(this).attr('title').toLowerCase(), // title of current asset
            match = false, // no matches by default
            score = 0, // score for result ordering
            bonus = scope.length; // bonus score based on number of available results

        for (var i = 0; i < q.length; i++) { // loop through query words

          var reg = new RegExp(q[i], 'g'); // match each word in query

          if (title.match(reg)) { // if current word in q finds match in title
            console.log('Matched word "' + q[i] + '"');
            match = true; // we got a match!
            score += (bonus - i); // give each match a bonus based on word order
            score++; // increment score for each match
          }

        } // end q loop

        if (match) {

          $(this).attr('data-score', score); // add score to DOM attribute
          results.push($(this)); // add current asset to results

        }

        match = false; // reset match

      }); // end scope loop

      /* DISPLAY RESULTS */

      session.push(results); // add latest search to session

      // log results and session history
      console.log(results);
      console.log(session);

      resetAcademy(null, null, hide = true); // hide all assets before showing results

      $.each(results, function() { // mark results as matches

        $(this).addClass('is-match');

      }); // end results loop

      // sort container based on data-score attr
      container.find('.asset.is-match').sort(function(a, b) {

        return ($(b).data('score')) > ($(a).data('score')) ? 1 : -1;

      }).appendTo(container);

      showMatched();

      /* SEARCH TAGS */

      // show result count and 'clear' button
      $('.search-info').show();

      // display number of results
      $('#resultCount').text(results.length + ' results found for "' + query + '"');

      // add breadcrumbs to container
      if (tagContainer.text().length == 0) { // if target container is empty

        // add first tag
        tagContainer.append('<span class=\"search-tags-tag is-active\">' + query + '</span>');

      } else { // other tags already exist

        $('.search-tags-tag').removeClass('is-active');
        // add subsequent tag
        tagContainer.append('<span class=\"search-tags-tag is-active is-sub\">' + query + '</span>');

      }

      // add title attributes for nice hover cues
      tags.each(function() {

        $(this).attr('title', 'Revert search scope back to "' + $(this).text() + '"');

      });

      // breadcrumb clicks
      tags.not('.is-active').click(function() {

        var index = tags.index($(this)),
            queryTxt = $(this).text();

        // set only clicked tag to active
        tags.removeClass('is-active');
        $(this).addClass('is-active');

        // reset everything
        resetAcademy(sort = true, show = true);

        // display updated number of results
        $('#resultCount').text(session[index].length + ' results found for "' + queryTxt + '"');

        $(this).nextAll().remove(); // remove everything after clicked tag
        session.length = index + 1; // revert session history

        $.each(session[index], function() { // loop through session array at same index as tag clicked

          $(this).addClass('is-match'); // add mod classes

        });

        console.log(session); // log new session history
        showMatched(); // show results for current query

      });

      /* CLEAR BUTTON */

      $('.js-clear-search').click(function() { // user clicks "clear search" button

        session = []; // empty search history
        $('.search-info').hide(); // hide search info bar

        // reset dropdown and search bar
        tagContainer.empty(); // remove all search tags
        dropdown.val('all');
        searchBar.val('');
        showScope();
        resetAcademy(sort = true, show = true); // reorder everything

      });

  }); // end submit function

});

Expand

v2.0

After the initial launch, I continued to tweak the algorithm and make UI/UX improvements.

Algorithm Improvements

First, I made adjustments to the scoring system. I realized in my original code, score++ doesn’t actually do anything to the value returned from the loop. Even if it did, adding 1 to the score wouldn’t really doing much anyway, so I decided to double the score for each matched word, using the *= operator to actually do something to score.

I also added a line in case the user types in an exact asset name, to ensure it gets the highest score.

if (title.match(reg)) {
	//...
	score += (bonus - i);
	if (title === query) {
		// if exact match, give 2x full bonus
		score += bonus * 2;
	}
	// double score for each matched word
	score *= 2;
}

UX Improvements

The interaction design of the search form needed some work, starting with the “search” button.

When v1.0 shipped, the button was a constant blue and was clickable at all times, allowing the user to submit empty queries. Empty searches essentially reset the results within the current scope, so no harm done, but they also generate empty breadcrumbs, which are ugly.

To combat this, I wanted to make the button only clickable when the search bar had contents, and grayed out otherwise.

I set the button to the modifying class is-active, only when the user had typed in the search bar and entered a string with a length greater than 0.

Next, a click function to prevent default behavior (in this case, form submission) when the search bar was empty.

searchBar.focus(function() {
	searchBar.on('input', function() {
		if (searchBar.val().length > 0) {
			searchBtn.addClass('is-active');
		} else {
			searchBtn.removeClass('is-active');
		}
	});
});

searchBtn.click(function(e) {
	if (searchBar.val().length === 0) {
		e.preventDefault();
	}
});
Interaction design and development for Adaptiva Academy search button

I thought it would be cool if users could sort results by date or relevance after submitting their query, so I created some new UI elements to allow for that.

To simplify the sorting, I added function expressions to module’s global scope called sortDate() and sortRel() and replaced any instances of duplicate code with calls to these expressions.

Since the page loads with everything sorted by date by default, the data-original-index attribute already serves as a great benchmark for chronological sorting, so I used that in sortDate().

sortRel() simply sorts results by score, or ‘relevance’ in user-friendly terms.

sortDate = function() {
	container.find('.asset').sort(function(a, b) {
		return ($(b).data('original-index')) < ($(a).data('original-index')) ? 1 : -1;
	}).appendTo(container);
},
sortRel = function() {
	container.find('.asset').sort(function(a, b) {
		return ($(b).data('score')) > ($(a).data('score')) ? 1 : -1;
	}).appendTo(container);
}

Both sort by buttons have unique id attributes and the same CSS classes which I targeted in the variable sortBy. I wrote a click function that covered both of them and figured out which function to run.

sortBy.click(function(e) {
	e.preventDefault();
	var clicked = $(this);

	if (clicked.hasClass('is-selected')) {
		// do nothing if already selected
		return false;
	} else {
		// set only clicked button to selected state
		sortBy.removeClass('is-selected');
		clicked.addClass('is-selected');
		// run desired sort function
		if (clicked.is('#sortDate')) {
			sortDate();
		} else if (clicked.is('#sortRel')) {
			sortRel();
		}
	}
});

Finally, after collecting some feedback from users and colleagues, it was clear that the narrowing scope was still not crystal clear to users. They were acting in “Google mode” and assuming every search would query all assets.

Though it would be easy to just set the scope to everything and give everyone what they expected, I firmly believed a narrowed scope would be more useful, since users would be searching to find something specific in a finite list of assets.

Instead of leaving the user’s last query submission in the search bar, I cleared it out after every submission and specified the narrowed scope in the placeholder text.

if (session.length > 0) {
	// give generic scope message after second query
	searchBar.val('').attr('placeholder', 'Search within previous results');
} else {
	// give specific scope message after first query
	searchBar.val('').attr('placeholder', 'Search within results for "' + query + '"');
}

Ah, much better. Now users have to see the UI cue before running subsequent queries.

Check out the full version of the latest iteration on GitHub:

See the Code

And here’s the updated UI:

UI design for Adaptiva Academy search feature

Epilogue

When I rolled this out, everyone I told about it was surprisingly psyched about it. I mean, as a computer nerd who wrote all this code, it’s pretty exciting because it was a challenge, but I mean it’s a search form for a bunch of tiles, not the next Google.

Still, apparently people wanted this. At the time I’m writing this, there are 98 assets listed in the Academy, which is a lot more than we imagined having when we first launched it.

Some of our marketing people regularly use the Academy as a quick way to retrieve links to our content, and they’ve told me this has saved them significant time already.

If our own employees were a little overwhelmed by navigating the content on the Academy, then thank Yeezus we got this shipped because the users must’ve been hurting too.

This project came up as, “Hey this would be nice to have” and ended up as a huge, productive, and experimental learning experience for me, as well as a marked improvement in user experience for the Adaptiva Academy.

You can check out the live code here: Adaptiva Acdemy.

Thanks for reading!

Jesse