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: