Build your own AutoComplete feature in WordPress

This is based on a kind of cool mix of solutions that I’ve come up with in the last month. The end result is a highly performing and flexible auto complete solution, using only one little lightweight library that doesn’t have that much functionality even.

Time to byte

Imagine you have Custom Posts – an Entity – called “Jobs” since your website is actually a jobs platform. And you want people to be able to search for Jobs easily and recommend jobs while people are searching. Ofcourse, this needs to be fast, and regular Ajax calls to the WordPress admin-ajax.php takes time. The time to first bye (TTF) is already 300-500ms. Not even discussing the amount of code that needs to be run!

Solutions

So what can we think of making this faster. I thought of two solutions:
1. Use the REST API from WordPress to call for data.
2. Rebuild a JSON file on update of a Custom Post and make a simple search in JS

Implications

I want to try out both, to see performance implications and as an experiment. Logically, the REST API should still run a bunch of core / plugin functionality, slowing down response time and burdening the Webserver.

While the JSON file stays there and even gets cached. Only changes are made to JSON file when the Jobs entries are updated. It doesn’t run any code that isn’t needed. The search will need to happen inside Javascript though. But it’s a simple search and the library takes already some load of this.

For auto complete to work properly we need a library which can be added to your theme/plugin as follows. Make sure to hook it up with enqueue_scripts. And we can enqueue the JS file below with a localized script.

function add_scripts() {

wp_enqueue_script(
            'jquery-auto-complete',
            'https://cdnjs.cloudflare.com/ajax/libs/jquery-autocomplete/1.0.7/jquery.auto-complete.min.js',
            array( 'jquery' ),
            '1.0.7',
            true
        );

wp_enqueue_script('autocompleting', get_template_directory_uri() . '/js/autocomplete.js', array('jquery'), '1.0', true);

        $data = array(
            'search_request' => get_template_directory_uri() . '/inc/lib/results.json',
            'upload_url' => admin_url('async-upload.php'),
            'ajax_url'   => admin_url('admin-ajax.php'),
            'nonce'      => wp_create_nonce('media-form'),
            'search_url' => site_url() . '/wp-json/tomhemps/v1/search_dictionary_entries'
        );
        wp_localize_script( 'autocompleting', 'global', $data );

}

add_action( 'wp_enqueue_scripts', 'add_scripts', 10);

This is the JS file below. Pay special attention to the $.get request which is pulling from a JSON file located on the server. Also, there is a search and sort happening in JS, in case you prefer a different way than .includes this can be easily exchanged. Or directly pull the right result from a database. Since now all search results are more or less publicly reachable.

;( function( $, window, undefined ) {

    'use strict'

    var searchRequest;
    $('.search-autocomplete').autoComplete({
        minChars:0,
        cache:true,
        source: function(term, suggest){
            $('i.loader').addClass('searching');
            try { searchRequest.abort(); } catch(e){}
            searchRequest = $.get(global.search_request, {},
                function(res) {
                    var res = $.parseJSON(res);
                    var db = [];
                    for (var i in res) {
                        if(res[i].title) {
                            var title = res[i].title.toLowerCase();

                            // search happens here.
                            if (title.includes(term.toLowerCase())) {
                                db.push(res[i]);
                            }
                        }
                    }
                    db.sort(dynamicSort("title"));

                    suggest(db);


                    $('i.loader').removeClass('searching');
                });
        },
        renderItem: function (item, search){
            search = search.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
            var re = new RegExp("(" + search.split(' ').join('|') + ")", "gi");
            return '<div class="autocomplete-suggestion" data-title="'+item['title']+'" data-val="'+search+'"> '+item['title'].replace(re, "<b>$1</b>")+'</div>';
        },
        onSelect: function( event, ui ) {
            onSelectActions(event);
        },
    });

    function dynamicSort(property) {
        var sortOrder = 1;
        if(property[0] === "-") {
            sortOrder = -1;
            property = property.substr(1);
        }
        return function (a,b) {
            /* next line works with strings and numbers,
             * and you may want to customize it to your needs
             */
            var result = (a[property] < b[property]) ? -1 : (a[property] > b[property]) ? 1 : 0;
            return result * sortOrder;
        }
    }

    function onSelectActions(event){

        const $searchInput = $('.search-field');

        $searchInput.val(event.currentTarget.getAttribute('data-title'));
    }
} ( jQuery, window ) );

We need some CSS to handle highlighting and positioning.

.autocomplete-suggestions {
  text-align: left; cursor: default; border: 1px solid #ccc; border-top: 0; background: #fff; box-shadow: -1px 1px 3px rgba(0,0,0,.1);

  /* core styles should not be changed */
  position: absolute; display: none; z-index: 9999; max-height: 254px; overflow: hidden; overflow-y: auto; box-sizing: border-box;
}
.autocomplete-suggestion { position: relative; padding: 0 .6em; line-height: 23px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 1.02em; color: #333; }
.autocomplete-suggestion b { font-weight: normal; color: #1f8dd6; }
.autocomplete-suggestion.selected { background: #f0f0f0; }


.autocomplete-suggestions {
  z-index: 100000;
}

We need the HTML to actually handle the input

<div class="search-block">
                        <form method="post" class="dictionary-search-form">

                            <input type="text" class="search-field search-autocomplete" name="search_term" required />
                            <i class="loader"></i>
                            <button type="submit" class="search-submit button"></button>
                        </form>
                    </div

And finally we need to handle the saving to a JSON file. The trick is to hook into the save action. That way, when the post gets updated, the JSON gets updated as well.

add_action( 'save_post_dictionary', 'save_dictionary_entries_as_json', 10,3 );

function save_dictionary_entries_as_json(){
    $results = new WP_Query( array(
        'post_type'     => array( 'dictionary' ),
        'post_status'   => 'publish',
        'nopaging'      => true,
        'posts_per_page'=> -1,
    ) );
    $items = array();
    if ( !empty( $results->posts ) ) {
        foreach ($results->posts as $result) {
                $items[] = array(
                    'title' => get_the_title($result->ID),
                    'results' => true
                );
        }
    }
    wp_reset_postdata();

    $fp = fopen(get_template_directory() . '/inc/lib/results.json', 'w');
    fwrite($fp, json_encode($items));
    fclose($fp);


}

Now we can test it for speed. Bam, TTFB 2.55ms.

Compare it to a general AJAX request.

Note that this on a local server, TTFB can be way lower on a production site, but both are locally.

WPML or other language plugins

In some cases it’s needed to actually have an autocomplete working for multiple languages. We’ll need to change the code for that to work for any kind of language. Note that this is specific for WPML => I call the apply_filters(‘wpml_active_languages’)

add_action( 'save_post_dictionary', 'save_dictionary_entries_as_json', 10,3 );

function save_dictionary_entries_as_json(){

   // call all languages that are currently active
    $languages     = apply_filters(
        'wpml_active_languages',
        [],
        [
            'skip_missing' => 1,
            'orderby'      => 'code',
        ]
    );

    // loop over each language and "set" the language temporarily by "switch_lang"
    foreach($languages as $lang) :

        global $sitepress;
        // WPML Super power language switcher...
        $sitepress->switch_lang( $lang['code'] );
    // query all custom post types
    $results = new WP_Query( array(
        'post_type'     => array( 'dictionary' ),
        'post_status'   => 'publish',
        'nopaging'      => true,
        'posts_per_page'=> -1,
    ) );
    $items = array();
    if ( !empty( $results->posts ) ) {
        foreach ($results->posts as $result) {
                // save all the titles in array
                $items[] = array(
                    'title' => get_the_title($result->ID),
                    'results' => true
                );
        }
    }

    wp_reset_postdata();
  
    // save all the items in a json file. Note especially the name of the file that it's saved in.
    $fp = fopen(get_template_directory() . '/inc/lib/dictionary_'.$lang['code'].'.json', 'w');
    fwrite($fp, json_encode($items, JSON_FORCE_OBJECT));
    fclose($fp);

    endforeach;


}

Modify the enqueue method to include language

I find it handy to include the language and other parameters in php, and localize the data to the script. That way the source of data stays in one place and JS is just executing on that data.

In this case, we would pass some variables to the autocomplete function in JS so that it knows what locale to check. There is also a global variable that gets updated in JS through WPML so this could also be used. But I kept this in PHP. Check it out.

 wp_enqueue_script('dictionary', get_template_directory_uri() . '/js/dictionary.js', array('jquery'), $js_version, true);

           // The language code is creates a dynamic path for use in JS.
            $data = array(
                'search_request' => get_template_directory_uri() . '/inc/lib/dictionary_' . ICL_LANGUAGE_CODE . '.json',
                'upload_url' => admin_url('async-upload.php'),
                'ajax_url' => admin_url('admin-ajax.php'),
                'nonce' => wp_create_nonce('media-form'),
                'locale' => ICL_LANGUAGE_CODE
            );
            wp_localize_script('dictionary', 'dictionary', $data);

And now take a look at the JS file. Here we didn’t need to change anything, since the $.get request just get the data from the JSON file.

var searchRequest;
  $('.dictionary-sidebar .search-autocomplete').autoComplete({
    minChars: 0,
    cache: true,
    menuClass: 'dictionary__autosuggest__list',
    source: function (term, suggest) {
      
      $('i.loader').addClass('searching');
      $('.dictionary-sidebar').toggleClass('active');
      try { searchRequest.abort(); } catch (e) {}
      searchRequest = $.get(dictionary.search_request, {},
        function (res) {
          var db = [];
          
          for (var i in res) {
            if (res[i].title) {
              var title = res[i].title.toLowerCase();
              
              if (title.includes(term.toLowerCase())) {
                
                db.push(res[i]);
              }
            }
          }
          
          db.sort(dynamicSort('title'));
          
          suggest(db);
          
          
          $('i.loader').removeClass('searching');
        });
    },
    renderItem: function (item, search) {
      search = search.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
      var re = new RegExp('(' + search.split(' ').join('|') + ')', 'gi');
      var rawtitle = item['title'].replace(re, '<b>$1</b>');
      var title = rawtitle;
      var dtitle = item['title'];
      var t = `<div class="autocomplete-suggestion" data-title="${dtitle}" data-val="${search}">${title}</div>`;
      return t;
    },
    onSelect: function (event, ui) {
      $('.dictionary-list').removeClass('opened');
      onSelectActions(event);
    },
  });
  
  function dynamicSort(property) {
    var sortOrder = 1;
    if (property[0] === '-') {
      sortOrder = -1;
      property = property.substr(1);
    }
    return function (a, b) {
      /* next line works with strings and numbers,
       * and you may want to customize it to your needs
       */
      var result = (a[property] < b[property]) ? -1 : (a[property] > b[property]) ? 1 : 0;
      return result * sortOrder;
    };
  }
  
  function onSelectActions(event) {
    
    var $searchInput = $('.search-field');
    
    $searchInput.val(event.currentTarget.getAttribute('data-title'));
  }

That’s it! If you are using Polylang, you’ll need to modify a few lines. Here they are:

Instead of ICL_LANGUAGE_CODE you use pll_current_language($value). And instead of apply_filters(‘wpml_active_languages’) you use pll_the_languages(array('show_flags'=>1,'show_names'=>0));

You can find more details on args on this documentation.

Leave a Reply