// @flow
/**
* search for dom elements on your page
* @module holmes
*/
(function(root, factory) {
'use strict';
/* $FlowIssue - doesn't work with umd */
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define([], function() {
/* $FlowIssue - doesn't work with umd */
return (root.holmes = factory(document));
});
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory(document);
} else {
// Browser globals
/* $FlowIssue - doesn't work with umd */
root.holmes = factory(document);
}
})(this, function(document) {
// UMD Definition above, do not remove this line
// To get to know more about the Universal Module Definition
// visit: https://github.com/umdjs/umd
'use strict';
/**
* search for dom elements on your page
* @alias module:holmes
* @constructor
* @param {string} [options.input='input[type=search]']
* A <code>querySelector</code> to find the <code>input</code>
* @param {string} options.find
* A <code>querySelectorAll</code> rule to find each of the find terms
* @param {string=} options.placeholder
* Text to show when there are no results (innerHTML)
* @param {bool} [options.mark=false]
* Whether to <code><mark></mark></code> the matching text
* Text to show when there are no results (<code>innerHTML</code>)
* @param {string} [options.class.visible=false]
* class to add to matched items
* @param {string} [options.class.hidden='hidden']
* class to add to non-matched items
* @param {boolean} [options.dynamic=false]
* Whether to query for the content of the elements on every input.
* If this is <code>false</code>, then only when initializing the script will
* fetch the content of the elements to search in. If this is <code>true</code>
* then it will refresh on every <code>input</code> event.
* @param {boolean} [options.contenteditable=false]
* DEPRECATED (now handled automatically) whether the input is a contenteditable or
* not. By default it's assumed that it's <code><input></code>, <code>true</code> here
* will use <code><div contenteditable></code>
* @param {boolean} [options.instant=false]
* By default Holmes waits for the <code>DOMContentLoaded</code> event to fire
* before listening. This is to make sure that all content is available. However
* if you exactly know when all your content is available (ajax, your own event or
* other situations), you can put this option on <code>true</code>.
* @param {number} [options.minCharacters=0] The minimum amount of characters to be typed before
* Holmes starts searching. Beware that this also counts when backspacing.
* @param {onChange} [options.onHidden]
* Callback for when an item is hidden.
* @param {onChange} [options.onVisible]
* Callback for when an item is visible again.
* @param {onChange} [options.onEmpty]
* Callback for when no items were found.
* @param {onChange} [options.onFound]
* Callback for when items are found after being empty.
* @param {function} [options.onInput]
* Callback for every input.
*/
function holmes(options) {
var empty = false;
if (typeof options != 'object') {
throw new Error('The options need to be given inside an object like this:\nholmes({\n\tfind:".result"\n});\nsee also https://haroen.me/holmes/doc/module-holmes.html');
}
/**
* Options
* @type {Object}
*/
holmes.prototype.options = options;
// if holmes.prototype.options.find is missing, the searching won't work so we'll thrown an exceptions
if (typeof holmes.prototype.options.find == 'undefined') {
throw new Error('A find argument is needed. That should be a querySelectorAll for each of the items you want to match individually. You should have something like: \nholmes({\n\tfind:".result"\n});\nsee also https://haroen.me/holmes/doc/module-holmes.html');
}
/**
* Start an event listener with the specified options
*/
holmes.prototype.start = function() {
// setting default values
if (typeof holmes.prototype.options.input == 'undefined') {
holmes.prototype.options.input = 'input[type=search]';
}
if (typeof holmes.prototype.options.placeholder == 'undefined') {
holmes.prototype.options.placeholder = false;
}
if (typeof holmes.prototype.options.mark == 'undefined') {
holmes.prototype.options.mark = false;
}
if (typeof holmes.prototype.options.class == 'undefined') {
holmes.prototype.options.class = {};
}
if (typeof holmes.prototype.options.class.visible == 'undefined') {
holmes.prototype.options.class.visible = false;
}
if (typeof holmes.prototype.options.class.hidden == 'undefined') {
holmes.prototype.options.class.hidden = 'hidden';
}
if (typeof holmes.prototype.options.dynamic == 'undefined') {
holmes.prototype.options.dynamic = false;
}
if (typeof holmes.prototype.options.minCharacters == 'undefined') {
holmes.prototype.options.minCharacters = 0;
}
holmes.prototype.running = true;
/**
* The input element
* @type {NodeList}
*/
holmes.prototype.input = document.querySelector(holmes.prototype.options.input);
/**
* All of the elements that are searched
* @type {NodeList}
*/
holmes.prototype.elements = document.querySelectorAll(holmes.prototype.options.find);
/**
* amount of elements to search
* @type {Number}
*/
holmes.prototype.elementsLength = holmes.prototype.elements.length;
/**
* The amount of elements that are hidden
* @type {Number}
*/
holmes.prototype.hidden = 0;
// create a container for a placeholder
if (holmes.prototype.options.placeholder) {
/**
* Placeholder element
* @type {Element}
*/
holmes.prototype.placeholder = document.createElement('div');
holmes.prototype.placeholder.id = "holmes-placeholder";
holmes.prototype.placeholder.classList.add(holmes.prototype.options.class.hidden);
holmes.prototype.placeholder.innerHTML = holmes.prototype.options.placeholder;
if (holmes.prototype.elements[0].parentNode) {
holmes.prototype.elements[0].parentNode.appendChild(holmes.prototype.placeholder);
} else {
throw new Error('The Holmes placeholder could\'t be put; the elements had no parent.');
}
}
// if a visible class is given, give it to everything
if (holmes.prototype.options.class.visible) {
var i;
for (i = 0; i < holmes.prototype.elementsLength; i++) {
holmes.prototype.elements[i].classList.add(holmes.prototype.options.class.visible);
}
}
// listen for input
holmes.prototype.input.addEventListener('input', inputHandler);
};
/**
* input event handler
*/
function inputHandler() {
// by default the value isn't found
var found = false;
// if a minimum of characters is required
// check if that limit has been reached
if (holmes.prototype.options.minCharacters) {
var length;
if (holmes.prototype.input instanceof HTMLInputElement) {
length = holmes.prototype.input.value.toLowerCase().length;
} else if (holmes.prototype.input.contentEditable) {
length = holmes.prototype.input.textContent.toLowerCase().length;
} else {
throw new Error("The Holmes input was no <input> or contenteditable.");
}
if (holmes.prototype.options.minCharacters > length && length !== 0) {
return;
}
}
// search in lowercase
/**
* Lowercase string holmes searces for
* @type {string}
*/
holmes.prototype.searchString;
if (holmes.prototype.input instanceof HTMLInputElement) {
holmes.prototype.searchString = holmes.prototype.input.value.toLowerCase();
} else if (holmes.prototype.input.contentEditable) {
holmes.prototype.searchString = holmes.prototype.input.textContent.toLowerCase();
} else {
throw new Error("The Holmes input was no <input> or contenteditable.");
}
// if the dynamic option is enabled, then we should query
// for the contents of `elements` on every input
if (holmes.prototype.options.dynamic) {
holmes.prototype.elements = document.querySelectorAll(holmes.prototype.options.find);
holmes.prototype.elementsLength = holmes.prototype.elements.length;
}
// loop over all the elements
// in case this should become dynamic, query for the elements here
var i;
var regex = new RegExp('(' + holmes.prototype.searchString + ')(?![^<]*>)', 'gi');
for (i = 0; i < holmes.prototype.elementsLength; i++) {
// if the current element doesn't contain the search string
// add the hidden class and remove the visbible class
if (holmes.prototype.elements[i].textContent.toLowerCase().indexOf(holmes.prototype.searchString) === -1) {
if (holmes.prototype.options.class.visible) {
holmes.prototype.elements[i].classList.remove(holmes.prototype.options.class.visible);
}
if (!holmes.prototype.elements[i].classList.contains(holmes.prototype.options.class.hidden)) {
holmes.prototype.elements[i].classList.add(holmes.prototype.options.class.hidden);
holmes.prototype.hidden++;
if (typeof holmes.prototype.options.onHidden === 'function') {
holmes.prototype.options.onHidden(holmes.prototype.elements[i]);
}
}
// else
// remove the hidden class and add the visible
} else {
if (holmes.prototype.options.class.visible) {
holmes.prototype.elements[i].classList.add(holmes.prototype.options.class.visible);
}
if (holmes.prototype.elements[i].classList.contains(holmes.prototype.options.class.hidden)) {
holmes.prototype.elements[i].classList.remove(holmes.prototype.options.class.hidden);
holmes.prototype.hidden--;
if (empty && typeof holmes.prototype.options.onFound === 'function') {
holmes.prototype.options.onFound(holmes.prototype.placeholder);
}
if (typeof holmes.prototype.options.onVisible === 'function') {
holmes.prototype.options.onVisible(holmes.prototype.elements[i]);
}
empty = false;
}
// if we need to mark it:
// remove all <mark> tags
// add new <mark> tags around the text
if (holmes.prototype.options.mark) {
holmes.prototype.elements[i].innerHTML = holmes.prototype.elements[i].innerHTML.replace(/<\/?mark>/g, '');
if (holmes.prototype.searchString.length) {
holmes.prototype.elements[i].innerHTML = holmes.prototype.elements[i].innerHTML.replace(regex, '<mark>$1</mark>');
}
}
// the element is now found at least once
found = true;
}
};
if (typeof holmes.prototype.options.onInput === 'function') {
holmes.prototype.options.onInput(holmes.prototype.searchString);
}
// No results were found and last time we checked it wasn't empty
if (!found && !empty) {
empty = true;
if (holmes.prototype.options.placeholder) {
holmes.prototype.placeholder.classList.remove(holmes.prototype.options.class.hidden);
}
if (typeof holmes.prototype.options.onEmpty === 'function') {
holmes.prototype.options.onEmpty(holmes.prototype.placeholder);
}
} else if (!empty) {
if (holmes.prototype.options.placeholder) {
holmes.prototype.placeholder.classList.add(holmes.prototype.options.class.hidden);
}
}
}
// whether to start immediately or wait on the load of DOMContent
if (typeof holmes.prototype.options.instant == 'undefined') {
holmes.prototype.options.instant = false;
}
if (holmes.prototype.options.instant) {
holmes.prototype.start();
} else {
window.addEventListener('DOMContentLoaded', holmes.prototype.start);
}
/**
* remove the current event listener
* @see holmes.prototype.start
* @return {Promise} resolves when the event is removed
*/
holmes.prototype.stop = function() {
return new Promise(function(resolve, reject) {
holmes.prototype.input.removeEventListener('input', inputHandler);
// remove placeholder
if (holmes.prototype.placeholder.parentNode) {
holmes.prototype.placeholder.parentNode.removeChild(holmes.prototype.placeholder);
} else {
throw new Error('The Holmes placeholder has no parent.');
}
// remove marks
if (holmes.prototype.options.mark) {
var i;
for (i = 0; i < holmes.prototype.elementsLength; i++) {
holmes.prototype.elements[i].innerHTML = holmes.prototype.elements[i].innerHTML.replace(/<\/?mark>/g, '');
}
}
// done
holmes.prototype.running = false;
resolve('This instance of Holmes has been stopped.');
});
};
/**
* empty the search string programmatically.
* This avoids having to send a new `input` event
*/
holmes.prototype.clear = function() {
if (holmes.prototype.input instanceof HTMLInputElement) {
holmes.prototype.input.value = '';
} else if (holmes.prototype.input.contentEditable) {
holmes.prototype.input.textContent = '';
} else {
throw new Error("The Holmes input was no <input> or contenteditable.");
}
// if a visible class is given, give it to everything
if (holmes.prototype.options.class.visible) {
for (var i = 0; i < holmes.prototype.elementsLength; i++) {
holmes.prototype.elements[i].classList.remove(holmes.prototype.options.class.hidden);
holmes.prototype.elements[i].classList.add(holmes.prototype.options.class.visible);
}
} else {
for (var i = 0; i < holmes.prototype.elementsLength; i++) {
holmes.prototype.elements[i].classList.remove(holmes.prototype.options.class.hidden);
}
}
if (holmes.prototype.options.placeholder) {
holmes.prototype.placeholder.classList.add(holmes.prototype.options.class.hidden);
if (holmes.prototype.options.class.visible) {
holmes.prototype.placeholder.classList.remove(holmes.prototype.options.class.visible);
}
}
holmes.prototype.hidden = 0;
};
/**
* Show the amount of elements, and those hidden and visible
* @return {object} all matching elements, the amount of hidden and the amount of visible elements
*/
holmes.prototype.count = function() {
return {
all: holmes.prototype.elementsLength,
hidden: holmes.prototype.hidden,
visible: holmes.prototype.elementsLength - holmes.prototype.hidden
};
}
};
/**
* Callback used for changes in item en list states.
* @callback onChange
* @param {object} [element]
* Element affected by the event. This is the item found by
* <code>onVisible</code> and <code>onHidden</code> and the placeholder
* (or <code>undefined</code>) for <code>onEmpty</code> and
* <code>onFound</code>.
*/
return holmes;
});