3
\$\begingroup\$

For my fellow Computer Science and Media students at my university, I build a link collection which soon a lot of people used. It basically served as a central place for all the links we needed to get access to lecture and exercise scripts, etc.

To step it up a bit, I decided to throw some JavaScript at it so folks could filter the list. The result can be seen here: vlau.me (Content is German, but the functionality is easy to figure out).

Now this is the first time I used JavaScript, so bear with the code.

Usage

I include the JS file and call recordFilter() with the first argument being the location of a JSON file which is used 1) to build the list in the first place and 2) to rebuild it when the user enters something into the input. The second argument is the ID of the container holding the components like the input element. The input element’s ID is the third argument.

<script src="js/vlaume.min.js"></script>
<script>recordFilter('_data/records.json', 'record-filter', 'record-filter__input');</script>

A note on how the list is populated in the first place: I use a static site generator (Jekyll) where I use the JSON file to build the list when generating the site. This is done to ensure users with disabled JavaScript get the list although they cannot filter it. That requires JavaScript.

The JavaScript

This is the complete code. I use an external Fuzzy Search implementation which is put into the compressed file but excluded from below code.

function recordFilter(jsonFile, containerName, inputID) {
    // Some names that are repeatedly used as HTML class or ID names
    var listName = 'record-list';
    var itemName = 'record';
    var linkName = itemName + '__link';
    var activeLinkName = linkName + '--active';

    // Get the JSON data by using a XML http request
    var listData;
    var xhr = new XMLHttpRequest();
    // The request needs to be synchronous for now because on slow connections the DOM is ready
    // before it fetches everything from the json file
    xhr.open('GET', jsonFile, true);
    xhr.onload = function(e) {
        if (xhr.readyState === 4) {
            if (xhr.status === 200) {
                listData = JSON.parse(xhr.responseText);
            } else {
                console.error(xhr.statusText);
            }
        }
    };
    xhr.onerror = function(e) {
        console.error(xhr.statusText);
    };
    xhr.send(null);



    /**
     * Before the record list can be build, the DOM has to be loaded so we can hook into the input.
     */
    window.onload = function(e) {
        // Some things that are only usable when JavaScript is enabled are hidden by default.
        // Removing the `js-disabled` class makes them visible again.
        if (document.body.classList.contains('js-disabled')) {
            document.body.classList.remove('js-disabled');
        }

        var placeholderKeys = [];
        for (var key in listData) {
            var value = listData[key];
            placeholderKeys = placeholderKeys.concat(value.title, value.abbr, value.keywords);
        }

        var filterInput = document.getElementById(inputID);
        filterInput.placeholder = placeholderKeys[Math.floor(Math.random() * placeholderKeys.length)];

        if (filterInput.value.length > 0) {
            buildRecordList(filterKeys(filterInput.value));
        }

        var recordList = document.getElementById(listName);
        setActiveClass(recordList.firstElementChild.getElementsByClassName(linkName)[0]);

        // Watch the search field for input changes …
        filterInput.addEventListener('input', function(e) {
            // … and build a new record list according to the filter value
            buildRecordList(filterKeys(filterInput.value));
        }, false);

        document.addEventListener('focus', function(e) {
            if (document.activeElement) {
                setActiveClass(document.activeElement);
            }
        }, true);
    };



    window.onkeydown = function(e) {
        // Put it into a separate code block because it was a bit lengthy
    };



    /**
     * Takes a string to search for in `listData` to create an array of related keys.
     * @return  An array consisting of key strings which are related to `str`.
     */
    function filterKeys(str) {
        if (str.length === 0) {
            var allKeys = [];
            for (var key in listData) {
                allKeys.push(key);
            }
            return allKeys;
        }

        var recordObjects = [];
        for (var objectKey in listData) {
            recordObjects.push(listData[objectKey]);
        }
        var options = {
            keys: ['abbr', 'title', 'keywords', 'persons', 'links.title'],
            id: 'key'
        };
        var fuse = new Fuse(recordObjects, options);
        return fuse.search(str);
    }



    /**
     * Build the record list containing elements belonging to keys in `relatedKeys`.
     */
    function buildRecordList(relatedKeys) {
        // Check if a list was build previously …
        var recordList = document.getElementById(listName);
        if (recordList) {
            // … and remove its content
            recordList.innerHTML = '';
        } else {
            // … otherwise, create it
            recordList = document.createElement('ul');
            recordList.id = recordList.className = listName;
            document.getElementById(containerName).insertBefore(recordList, null);
        }

        for (var i = 0; i < relatedKeys.length; i++) {
            recordList.innerHTML += recordStr(relatedKeys[i], listData[relatedKeys[i]]);
        }

        // If no list items were inserted, we need to stop here
        if (!recordList.hasChildNodes()) {
            return;
        }

        // Set the first child element in the list to active state
        setActiveClass(recordList.firstElementChild.getElementsByClassName(linkName)[0]);
    }



    /**
     * @return  a string that contains the HTML markup for a record
     */
    function recordStr(key, value) {
        var str = '<li class="' + itemName + '" data-key="' + value.key + '">' +
            '<div class="' + itemName + '__title">' + value.title + '</div>';

        if (value.links.length > 0) {
            str += '<nav class="nav  record-nav">';
            for (var i = 0; i < value.links.length; i++) {
                var link = value.links[i];
                str += '<a class="' + itemName + '__link" href="' + link.url + '">' +
                    link.title + '</a>';
            }
        }

        return str;
    }



    /**
     * @brief  Moves the active class to the given element
     */
    function setActiveClass(element) {
        if (element) {
            if (element.className.indexOf(linkName) > -1) {
                var recordList = document.getElementById(listName);
                var activeItem = recordList.getElementsByClassName(activeLinkName)[0];
                if (activeItem) {
                    activeItem.classList.remove(activeLinkName);
                }
                element.className += '  ' + activeLinkName;
            }
        }
    }



    /**
     * @return  the closest ancestor of `element` that has a class `className`
     */
    function findAncestor(element, className) {
        while ((element = element.parentElement) && !element.classList.contains(className));
        return element;
    }



    /**
     * @brief  Iterates over all current DOM elements to create an array of elements that are
     *         focusable (i.e. they’re visible and have a tabIndex greater -1)
     * @return  an array containing all currently focusable elements in the DOM
     */
    function focusableElements() {
        var elements = document.getElementsByTagName('*');
        var focusable = [];
        for (var i = 0; i < elements.length; i++) {
            if (elements[i].tabIndex > -1 && elements[i].offsetParent !== null) {
                focusable.push(elements[i]);
            }
        }
        return focusable;
    }
}

Handling key presses

Arrow keys are used to navigate the links in the list while up/down are used to go from item (an item is a block containing links) vertically, left/right keys are used to go from link to link.

When the user navigates to a link, I set focus() which 1) scrolls the element into view by default and 2) allows the user to open the link with enter or Ctrl+enter.

Tabbing through links on the page also moves the active class (used for styling with CSS) if the to be focused element is a link of the list. It does not interfer with the default behavior of tabbing.

/**
 * Listen to various key presses to enable arrow key navigation over the record links.
 * Opening links is done by giving links focus which has the desired interaction by default
 *
 * Some keys and which keycodes they’re mapped to:
 * `tab` – 9;   `enter` – 13;   `←` – 37;   `↑` – 38;   `→` – 39;   `↓` – 40;
 */
window.onkeydown = function(e) {
    e = e || window.event;

    var recordList = document.getElementById(listName);

    // If `e.keyCode` is not in the array, abort mission right away
    if ([9, 13, 37, 38, 39, 40].indexOf(e.keyCode) === -1 || !recordList.hasChildNodes()) {
        return;
    }

    var activeLink = recordList.getElementsByClassName(activeLinkName)[0];

    if (e.keyCode === 13) {
        if (document.activeElement === document.getElementById(inputID)) {
            document.activeElement.blur();
            activeLink.focus();
        } else {
            return;
        }
    }

    var targetElement;

    if (e.keyCode === 9) {
        // If there is only one item, the default is fine.
        if (recordList.length === 1) {
            return;
        }

        var elements = focusableElements();
        var activeElement = document.activeElement;

        // Determine which element is the one that will receive focus
        for (var el = 0; el < elements.length; el++) {
            if (elements[el] === activeElement) {
                if (e.shiftKey && elements[el-1]) {
                    targetElement = elements[el-1];
                } else if (elements[el+1]) {
                    targetElement = elements[el+1];
                }
                break;
            }
        }
    }

    if ([37, 39].indexOf(e.keyCode) > -1) {
        var previousLink;
        var nextLink;
        var linkElements = recordList.getElementsByClassName(linkName);
        for (var i = 0; i < linkElements.length; i++) {
            if (activeLink === linkElements[i]) {
                previousLink = linkElements[i-1];
                nextLink = linkElements[i+1];
                break;
            }
        }

        if (!previousLink && !nextLink) {
            return;
        }

        if (e.keyCode === 37 && previousLink) {
            targetElement = previousLink;
        } else if (e.keyCode === 39 && nextLink) {
            targetElement = nextLink;
        }
    } else if ([38, 40].indexOf(e.keyCode) > -1) {
        var activeItem = findAncestor(activeLink, itemName);
        var previousItem = activeItem.previousElementSibling;
        var nextItem = activeItem.nextElementSibling;

        if (!previousItem && !nextItem) {
            return;
        }

        if (e.keyCode === 38 && previousItem) {
            targetElement = previousItem.getElementsByClassName(linkName)[0];
        } else if (e.keyCode === 40 && nextItem) {
            targetElement = nextItem.getElementsByClassName(linkName)[0];
        }

    }

    if (targetElement && targetElement.classList.contains(linkName)) {
        if ([37, 38, 39, 40].indexOf(e.keyCode) > -1) {
            e.preventDefault();
            targetElement.focus();
        }
        activeLink.classList.remove(activeLinkName);
        targetElement.className += '  ' + activeLinkName;
    }
};

Related Links:

\$\endgroup\$
1
\$\begingroup\$

One thing that falls into my eye, is that you have a possible race condition: You use the data you load via AJAX in the window.onload event handler. However window.onload doesn't wait for AJAX calls, so it theoretically could happen, that the window.onload event handler runs before the data is loaded.

Instead you should use the data directly in the AJAX onload event.


You shouldn't be assigning event handlers directly to the element properties. window.onload = ... overwrites any other handler assigned to it (e.g. by another script), and accordingly can be overwritten itself. Instead use addEventListener just like you do for the over events.

Also you should consider using the event DOMContentLoaded instead load.


You use several newer DOM features such as classList and getElementsByClassName. It may be a good idea to check for support before using them, so that older browsers don't trip over them.


As with the actual functionality, I'm not quite sure I understand yet what is happening. In any case it could do with some more comments, and possibly a better separation of logic and output. Maybe I'll have more time later to have another look.

\$\endgroup\$
  • \$\begingroup\$ Considering your first point, I tested this with slow connections via dev tools. Also, the request is synchronous meaning that it will block execution. However I indeed run into this issue when switching it to work asynchronously. \$\endgroup\$ – kleinfreund Nov 27 '15 at 15:33
  • \$\begingroup\$ On browser support for newer DOM features: getElementsByClassName (caniuse) has very good support. classList (caniuse) is worse, but I don't use features limiting support in IE10/11. \$\endgroup\$ – kleinfreund Nov 27 '15 at 15:36
  • \$\begingroup\$ Oh, I missed that fact that it was synchronous, but that's a bad idea anyway, so it's good you want to change that. \$\endgroup\$ – RoToRa Nov 27 '15 at 15:41

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.