index.js
/**
* finder.js module.
* @module finderjs
*/
'use strict';
var extend = require('xtend');
var EventEmitter = require('eventemitter3');
var _ = require('./util');
var defaults = {
labelKey: 'label',
childKey: 'children',
className: {
container: 'fjs-container',
col: 'fjs-col',
list: 'fjs-list',
item: 'fjs-item',
active: 'fjs-active',
children: 'fjs-has-children',
url: 'fjs-url',
itemPrepend: 'fjs-item-prepend',
itemContent: 'fjs-item-content',
itemAppend: 'fjs-item-append'
}
};
module.exports = finder;
/**
* @param {element} container
* @param {Array|Function} data
* @param {object} options
* @return {object} event emitter
*/
function finder(container, data, options) {
var emitter = new EventEmitter();
var cfg = extend(
defaults,
{
container: container,
emitter: emitter
},
options
);
// xtend doesn't deep merge
cfg.className = extend(defaults.className, options ? options.className : {});
// store the fn so we can call it on subsequent selections
if (typeof data === 'function') {
cfg.data = data;
}
// dom events
container.addEventListener('click', finder.clickEvent.bind(null, cfg));
container.addEventListener('keydown', finder.keydownEvent.bind(null, cfg));
// internal events
emitter.on('item-selected', finder.itemSelected.bind(null, cfg));
emitter.on('create-column', finder.addColumn.bind(null, cfg));
emitter.on('navigate', finder.navigate.bind(null, cfg));
emitter.on('go-to', finder.goTo.bind(null, cfg, data));
_.addClass(container, cfg.className.container);
finder.createColumn(data, cfg);
if (cfg.defaultPath) {
window.requestAnimationFrame(function next() {
finder.goTo(cfg, data, cfg.defaultPath);
});
}
container.setAttribute('tabindex', 0);
return emitter;
}
/**
* @param {string} str
* @return {string}
*/
function trim(str) {
return str.trim();
}
/**
* @param {object} config
* @param {object} data
* @param {array|string} path
*/
finder.goTo = function goTo(cfg, data, goToPath) {
var path = Array.isArray(goToPath)
? goToPath
: goToPath
.split('/')
.map(trim)
.filter(Boolean);
if (path.length) {
while (cfg.container.firstChild) {
cfg.container.removeChild(cfg.container.firstChild);
}
finder.selectPath(path, cfg, data);
}
};
/**
* @param {element} container
* @param {element} column to append to container
*/
finder.addColumn = function addColumn(cfg, col) {
cfg.container.appendChild(col);
cfg.emitter.emit('column-created', col);
};
/**
* @param {object} config
* @param {object} event value
* @param {object | undefined}
*/
finder.itemSelected = function itemSelected(cfg, value) {
var itemEl = value.item;
var item = itemEl._item;
var col = value.col;
var data = item[cfg.childKey] || cfg.data;
var activeEls = col.getElementsByClassName(cfg.className.active);
var x = window.pageXOffset;
var y = window.pageYOffset;
var newCol;
if (activeEls.length) {
_.removeClass(activeEls[0], cfg.className.active);
}
_.addClass(itemEl, cfg.className.active);
_.nextSiblings(col).map(_.remove);
// fix for #14: we need to keep the focus on a live DOM element, such as the
// container, in order for keydown events to get fired
cfg.container.focus();
window.scrollTo(x, y);
if (data) {
newCol = finder.createColumn(data, cfg, item);
cfg.emitter.emit('interior-selected', item);
} else if (item.url) {
document.location.href = item.url;
} else {
cfg.emitter.emit('leaf-selected', item);
}
return newCol;
};
/**
* Click event handler for whole container
* @param {element} container
* @param {object} config
* @param {object} event
*/
finder.clickEvent = function clickEvent(cfg, event) {
var el = event.target;
var col = _.closest(el, function test(el) {
return _.hasClass(el, cfg.className.col);
});
var item = _.closest(el, function test(el) {
return _.hasClass(el, cfg.className.item);
});
_.stop(event);
// list item clicked
if (item) {
cfg.emitter.emit('item-selected', {
col: col,
item: item
});
}
};
/**
* Keydown event handler for container
* @param {object} config
* @param {object} event
*/
finder.keydownEvent = function keydownEvent(cfg, event) {
var arrowCodes = {
38: 'up',
39: 'right',
40: 'down',
37: 'left'
};
if (event.keyCode in arrowCodes) {
_.stop(event);
cfg.emitter.emit('navigate', {
direction: arrowCodes[event.keyCode],
container: cfg.container
});
}
};
/**
* Function to handle preselected path from option.
* This is an recurive function which passes data of child
* to itself for rendering column.
* @param {array} path
* @param {object} cfg
* @param {object} data
* @param {object | undefined} column
*/
finder.selectPath = function selectPath(path, cfg, data, column) {
var currPath = path[0];
var childData = data.find(function find(item) {
return item[cfg.labelKey] === currPath;
});
var col = column || finder.createColumn(data, cfg);
var newCol = finder.itemSelected(cfg, {
col: col,
item: _.first(col, '[data-fjs-item="' + currPath + '"]')
});
path.shift();
if (path.length) {
finder.selectPath(path, cfg, childData[cfg.childKey], newCol);
}
};
/**
* Navigate the finder up, down, right, or left
* @param {object} config
* @param {object} event value - `container` prop contains a reference to the
* container, and `direction` can be 'up', 'down', 'right', 'left'
*/
finder.navigate = function navigate(cfg, value) {
var active = finder.findLastActive(cfg);
var target = null;
var dir = value.direction;
var item;
var col;
if (active) {
item = active.item;
col = active.col;
if (dir === 'up' && item.previousSibling) {
target = item.previousSibling;
} else if (dir === 'down' && item.nextSibling) {
target = item.nextSibling;
} else if (dir === 'right' && col.nextSibling) {
col = col.nextSibling;
target = _.first(col, '.' + cfg.className.item);
} else if (dir === 'left' && col.previousSibling) {
col = col.previousSibling;
target =
_.first(col, '.' + cfg.className.active) ||
_.first(col, '.' + cfg.className.item);
}
} else {
col = _.first(cfg.container, '.' + cfg.className.col);
target = _.first(col, '.' + cfg.className.item);
}
if (target) {
cfg.emitter.emit('item-selected', {
container: cfg.container,
col: col,
item: target
});
}
};
/**
* Find last (right-most) active item and column
* @param {Element} container
* @param {Object} config
* @return {Object}
*/
finder.findLastActive = function findLastActive(cfg) {
var activeItems = cfg.container.getElementsByClassName(cfg.className.active);
var item;
var col;
if (!activeItems.length) {
return null;
}
item = activeItems[activeItems.length - 1];
col = _.closest(item, function test(el) {
return _.hasClass(el, cfg.className.col);
});
return {
col: col,
item: item
};
};
/**
* @param {object} data
* @param {object} config
* @param {parent} [parent] - parent item that clicked/triggered createColumn
*/
finder.createColumn = function createColumn(data, cfg, parent) {
var div;
var list;
function callback(data) {
return finder.createColumn(data, cfg, parent);
}
if (typeof data === 'function') {
data.call(null, parent, cfg, callback);
} else if (Array.isArray(data)) {
list = finder.createList(data, cfg);
div = _.el('div');
div.appendChild(list);
_.addClass(div, cfg.className.col);
cfg.emitter.emit('create-column', div);
return div;
} else {
throw new Error('Unknown data type');
}
};
/**
* @param {array} data
* @param {object} config
* @return {element} list
*/
finder.createList = function createList(data, cfg) {
var ul = _.el('ul');
var items = data.map(function create(item) {
return finder.createItem(cfg, item);
});
var docFrag;
docFrag = items.reduce(function each(docFrag, curr) {
docFrag.appendChild(curr);
return docFrag;
}, document.createDocumentFragment());
ul.appendChild(docFrag);
_.addClass(ul, cfg.className.list);
return ul;
};
/**
* Default item render fn
* @param {object} cfg config object
* @param {object} item data
* @return {DocumentFragment}
*/
finder.createItemContent = function createItemContent(cfg, item) {
var frag = document.createDocumentFragment();
var prepend = _.el('div.' + cfg.className.itemPrepend);
var content = _.el('div.' + cfg.className.itemContent);
var append = _.el('div.' + cfg.className.itemAppend);
frag.appendChild(prepend);
content.appendChild(document.createTextNode(item[cfg.labelKey]));
frag.appendChild(content);
frag.appendChild(append);
return frag;
};
/**
* @param {object} cfg config object
* @param {object} item data
*/
finder.createItem = function createItem(cfg, item) {
var frag = document.createDocumentFragment();
var liClassNames = [cfg.className.item];
var li = _.el('li');
var a = _.el('a');
var createItemContent = cfg.createItemContent || finder.createItemContent;
frag = createItemContent.call(null, cfg, item);
a.appendChild(frag);
a.href = '';
a.setAttribute('tabindex', -1);
if (item.url) {
a.href = item.url;
liClassNames.push(cfg.className.url);
}
if (item.className) {
liClassNames.push(item.className);
}
if (item[cfg.childKey]) {
liClassNames.push(cfg.className[cfg.childKey]);
}
_.addClass(li, liClassNames);
li.appendChild(a);
li.setAttribute('data-fjs-item', item[cfg.labelKey]);
li._item = item;
return li;
};