Searchable List

In this example below you will see how to do a Searchable List with some HTML / CSS and Javascript

Thumbnail
This awesome code was written by TVR, you can see more from this user in the personal repository.
You can find the original code on Codepen.io
Copyright TVR ©
  • HTML
  • CSS
  • JavaScript
    <p>An experiment in creating unified and searchable selects and (eventually) multi-selects. I copied this over and generalized it from some previous work I did. There is a lot of tidying up that I'd like to do yet (port to jQuery, add checkboxes for multi-selecting, fix a couple bugs) but overall its quite performant.</p>
<div id="example1" class="searchableListMainContainer">
  <div class="searchableListSearchContainer">
    <span class="searchableListInputContainer"><label>Search</label><input type="text" class="searchableListInput"><span class="searchableListCount"></span></span>
    <button type="button" class="searchableListPrevious">&#x25B2;</button>
    <button type="button" class="searchableListNext">&#x25BC;</button>
  </div>
  <ol id="group-1" class="searchableList">
    <li data-key="group-1" data-value="1">One</li>
    <li data-key="group-1" data-value="2">Two</li>
    <li data-key="group-1" data-value="3">Three</li>
    <li data-key="group-1" data-value="4">Four</li>
    <li data-key="group-1" data-value="5">Five</li>
  </ol>
  <ol id="group-2" class="searchableList" style="visibility: hidden; height: 0;">
    <li data-key="group-1" data-value="6">Six</li>
    <li data-key="group-1" data-value="7">Seven</li>
    <li data-key="group-1" data-value="8">Eight</li>
    <li data-key="group-1" data-value="9">Nine</li>
    <li data-key="group-1" data-value="10">Ten</li>
  </ol>
</div>

/*Downloaded from https://www.codeseek.co/TVR/searchable-list-pypMPM */
    .searchableListMainContainer {
    color: #444;
    text-align: left;
    line-height: normal;
}
.searchableListSearchContainer, .searchableListGroupContainer {
    padding: 4px;
    border-bottom: 1px solid #ccc;
    text-align: center;
    white-space: nowrap;
}
.searchableListSearchContainer label {
    margin-right: 5px;
}
.searchableListSearchContainer {
    padding-bottom: 0;
    border: none;
}
.searchableListGroupContainer label {
    margin-right: 10px;
}
.searchableListGroupContainer label:last-child {
    margin-right: 0;
}
.searchableListInputContainer {
    position: relative;
    display: inline-block;
    vertical-align: top;
}
.searchableListInput {
    width: 300px;
    height: 19px;
}
.searchableListCount {
    position: absolute;
    top: 6px;
    right: 6px;
    color: #b8b8b8;
}
.searchableListPrevious,
.searchableListNext {
    margin: 0;
    padding: 0 6px;
    height: 23px;
    border: 1px solid #aaa;
    background: #f1f1f1;
    vertical-align: top;
}
.searchableList {
    position: relative;
    overflow-x: hidden;
    overflow-y: scroll;
    max-height: 300px;
    margin: 0;
    padding: 0;
    list-style: none;
}
.ie7 .searchableList {
    width: 100% !important;
}
.searchableList li {
    padding: 5px;
    border-bottom: 1px solid #f1f1f1;
    white-space: nowrap;
    cursor: pointer;
}
.searchableList li:hover {
    background: #f1f1f1;
}
.matchedText {
    background: #cedef6;
    color: #133465;
}
li.selectedRow,
li.selectedRow:hover {
    background: #3879d9;
}
li.selectedRow {
    color: #fff;
}


/*Downloaded from https://www.codeseek.co/TVR/searchable-list-pypMPM */
    var SearchableList = {

    timeout: null,

    findHtmlTags: /<\/?\w+((\s+\w+(\s*=\s*(?:\".*?"|'.*?'|[^'\">\s]+))?)+\s*|\s*)\/?>/i,

    applyEvents: function (form) {

        var searchInput = form.getElement('.searchableListInput'),
            countContainer = form.getElement('.searchableListCount'),
            previousButton = form.getElement('.searchableListPrevious'),
            nextButton = form.getElement('.searchableListNext'),
            listContainers = form.getElements('.searchableList'),
            hiddenInput = form.getElement('input[data-reporting-level]'),
            radios = form.getElements('input[name=searchableListGroup]');

        radios.addEvent('click', function () {
            var pulloutSelector = this.getAttribute('data-pullout-selector'),
                pullinSelector = this.getAttribute('data-pullin-selector'),
                pullout = document.getElement(pulloutSelector),
                pullin = pullinSelector ? document.getElements(pullinSelector + ':not(' + pulloutSelector + ')') : null,
                listContainer;
            if (pullin) {
                pullin.setStyles({ 'visibility': 'hidden', 'height': 0 });
            }
            pullout.setStyles({ 'visibility': '', 'height': '' });
            listContainer = SearchableList.getVisibleListContainer(listContainers);
            if (searchInput.value !== listContainer.retrieve('lastValue')) { // only run if the value has actually changed
                SearchableList.highlightMatches(searchInput, listContainer); // highlight any matches
                nextButton.fireEvent('click'); // scroll to the first matched results if it exists
            } else {
                SearchableList.scrollTo('', listContainer, countContainer, searchInput);
            }
        });
        searchInput.addEvent('keydown:keys(enter)', function (e) {
            e.preventDefault(); // take control of any result of hitting enter key so we don't accidentally submit a form
        });
        searchInput.addEvent('keydown', function (e) {
            e.stopPropagation(); // stop bubbling to improve performance for large lists
            if (e.key === 'up') {
                previousButton.fireEvent('click'); // up key moves to previous matching result
            } else if (e.key === 'down') {
                nextButton.fireEvent('click'); // down key moves to the next matching result
            } else if (e.key === 'enter') {
                var listContainer = SearchableList.getVisibleListContainer(listContainers), matches = listContainer.retrieve('matches'), matchIndex = listContainer.retrieve('matchIndex');
                if (matches && matches[matchIndex]) {
                    SearchableList.select(matches[matchIndex].get('data-key'), matches[matchIndex].get('data-value'), hiddenInput); // enter key fills down the form and submits
                }
            } else {
                clearTimeout(SearchableList.timeout); // cancel delayed search if triggered fast enough to avoid excessive work.
                SearchableList.timeout = setTimeout(function () {
                    SearchableList.highlightMatches(searchInput, SearchableList.getVisibleListContainer(listContainers)); // highlight any matches
                    nextButton.fireEvent('click'); // scroll to the first matched results if it exists
                }, Browser.ie7 || Browser.ie8 ? 500 : 200); // delay the search to save a few brain cycles for fast typer. give IE a little extra time on top of that.
            }
        });
        previousButton.addEvent('click', function () {
            SearchableList.scrollTo('previous', ClientSelector.getVisibleListContainer(listContainers), countContainer, searchInput);
        });
        nextButton.addEvent('click', function () {
            SearchableList.scrollTo('next', ClientSelector.getVisibleListContainer(listContainers), countContainer, searchInput);
        });
        listContainers.addEvent('click:relay(li)', function () { // delegate to avoid excessive events
            SearchableList.select(this.get('data-key'), this.get('data-value'), hiddenInput);
        });
        if (Browser.ie7) {
            listContainers.each(function (listContainer) {
                listContainer.setStyle('width', listContainer.getSize().x); // list items can't fill the listContainer if it doesn't have a fixed width
            });
        }
    },

    highlightMatches: function (searchInput, listContainer) {
        var value, items, itemCount, ieLimitExceeded, textContainsMatches, wordsToBeHighlighted, highlightedMarkup, matches, i, item, itemHTML;
        value = searchInput.value.trim().replace(/[\-\[\]{}()*+?.,\\\^$|#]/g, '\\$&'); // trim and escape any potential regex characters being used in search
        items = listContainer.getChildren();
        itemCount = items.length;
        ieLimitExceeded = (Browser.ie7 || Browser.ie8) && value.length <= 1; // single character search can be really hard on ie and aren't really helpful anyway.
        textContainsMatches = new RegExp('^(?=.*' + value.split(' ').join(')(?=.*') + ').*$', 'i'); // regex lookahead. used to as many words, partial or complete, in any order.
        wordsToBeHighlighted = new RegExp('(' + value.split(' ').join('|') + ')', 'gi');
        highlightedMarkup = '<span class="matchedText">$1</span>';
        matches = [];
        if (value !== listContainer.retrieve('lastValue')) { // only run if the value has actually changed
            listContainer.setStyle('display', 'none'); // hide while interacting with list to prevent repaint/reflows for each loop iteration
            for (i = 0; i < itemCount; i++) {
                item = items[i];
                // content containes htmlentities but we want to search as text, to get text, you usually use innerText for IE7 & 8 and textContent for all else.
                // however, innerText, even as a getter, triggers a reflow. this would trigger a reflow for each row in the client selector for each triggered search.
                // so i'm recursively collecting the raw text via the nodeValue property to avoid a performance killing mess of reflows. if needed, stripTags could be added.
                itemHTML = item.textContent || (function getText (a) { return Array.map(a, function (e) { return e.childNodes.length > 0 ? getText(e.childNodes) : e.nodeValue; }).join(''); })(item.childNodes);
                if (!ieLimitExceeded && value !== '' && textContainsMatches.test(itemHTML)) {
                    matches.push(item);
                    item.innerHTML = itemHTML.replace(wordsToBeHighlighted, highlightedMarkup); // overwrite row text with new highlighted text
                } else if (itemHTML !== item.innerHTML) {
                    item.innerHTML = itemHTML; // since the highlighted rows are overwritten we only need to remove the highlight from rows that no longer match
                }
            }
            listContainer.setStyle('display', ''); // show again now that we've finished interacting
            listContainer.store('lastValue', value);
            listContainer.store('matches', matches);
            listContainer.eliminate('matchIndex');
        }
        return matches.length > 0;
    },

    scrollTo: function (direction, listContainer, countContainer, searchInput) {
        var lastMatch = listContainer.retrieve('lastMatch'), searchInputValue = searchInput.value.trim(), matches = listContainer.retrieve('matches'), matchIndex;
        if (lastMatch) {
            lastMatch.removeClass('selectedRow');
        }
        if (!searchInputValue.length || !matches.length) {
            countContainer.innerHTML = !searchInputValue.length ? '' : '0 of 0'; // if nothing is entered, dont show result count. if value enter matches nothing, show 0 of 0
            listContainer.scrollTop = listContainer.firstChild.offsetTop; // if no matches, scroll to top of list
        } else {
            matchIndex = listContainer.retrieve('matchIndex');
            if (direction === 'next') {
                matchIndex = matchIndex + 1 > matches.length - 1 ? 0 : matchIndex + 1; // next result with loopback to start
            }
            if (direction === 'previous') {
                matchIndex = matchIndex - 1 < 0 ? matches.length - 1 : matchIndex - 1; // previous result with loopback to end
            }
            listContainer.store('lastMatch', matches[matchIndex].addClass('selectedRow')); // highlight as store the selected row
            countContainer.innerHTML = (matchIndex + 1) + ' of ' + matches.length; // mark the number of matching results
            SearchableList.scrollToMatchIfOutOfView(matches[matchIndex], listContainer);
        }
        listContainer.store('matchIndex', matchIndex);
    },

    scrollToMatchIfOutOfView: function (match, listContainer) {
        var matchStartPoint = match.offsetTop, matchEndPoint = matchStartPoint + match.getSize().y,
            viewStartPoint = listContainer.scrollTop, viewEndPoint = viewStartPoint + listContainer.getSize().y,
            matchIsAboveView = matchEndPoint < viewStartPoint, matchIsBelowView = matchStartPoint > viewEndPoint;
        if (matchIsAboveView || matchIsBelowView) {
            listContainer.scrollTop = matchStartPoint - 60; // scroll to matched result plus a little to help make context within heirarchy more clear
        }
    },

    select: function (key, value, hiddenInput) {
        hiddenInput.setAttribute('name', key);
        hiddenInput.value = value;
        hiddenInput.form.submit();
    },

    getVisibleListContainer: function (listContainers) {
        for (var i = 0, l = listContainers.length; i < l; i++) {
            if (listContainers[i].getStyle('visibility') !== 'hidden') {
                return listContainers[i];
            }
        }
    }

};

$(window).addEvent('domready', function () {
  SearchableList.applyEvents($('example1'));
});


Comments