#209 - Autocomplete a Search Field from CMS

Create a real time search field that filters your CMS items as the user types.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

268 lines
Paste this into Webflow
<!-- đź’™ MEMBERSCRIPT #209 v0.1 đź’™ AUTOCOMPLETE FROM CMS -->

<script>
(function() {
  'use strict';

  var CONFIG = {
    attr: 'ms-code-search',
    groupAttr: 'ms-code-search-group',
    minCharsAttr: 'data-ms-search-min',
    maxResultsAttr: 'data-ms-search-max',
    debounceAttr: 'data-ms-search-debounce',
    activeAttr: 'data-ms-search-active',
    defaultMin: 1,
    defaultMax: 10,
    defaultDebounce: 200
  };

  // -- Helpers --

  function debounce(fn, ms) {
    var timer;
    return function() {
      var ctx = this, args = arguments;
      clearTimeout(timer);
      timer = setTimeout(function() { fn.apply(ctx, args); }, ms);
    };
  }

  function normalize(str) {
    return (str || '').toLowerCase().trim();
  }

  function findPaired(role, group) {
    var all = document.querySelectorAll('[' + CONFIG.attr + '="' + role + '"]');
    if (!group) return all[0] || null;
    for (var i = 0; i < all.length; i++) {
      if ((all[i].getAttribute(CONFIG.groupAttr) || '') === group) return all[i];
    }
    return all[0] || null;
  }

  // -- Init each search instance --

  function initSearch(input) {
    var group = input.getAttribute(CONFIG.groupAttr) || '';
    var list = findPaired('list', group);
    var emptyEl = findPaired('empty', group);
    var clearBtn = findPaired('clear', group);

    if (!list) {
      console.warn('MemberScript #number209: No element with ' + CONFIG.attr + '="list" found');
      return;
    }

    var minChars = parseInt(input.getAttribute(CONFIG.minCharsAttr), 10) || CONFIG.defaultMin;
    var maxResults = parseInt(input.getAttribute(CONFIG.maxResultsAttr), 10) || CONFIG.defaultMax;
    var debounceMs = parseInt(input.getAttribute(CONFIG.debounceAttr), 10) || CONFIG.defaultDebounce;

    // -- Collect CMS items and their searchable text --

    var items = [];
    var children = list.children;
    for (var i = 0; i < children.length; i++) {
      var child = children[i];
      if (child === emptyEl) continue;
      var fields = child.querySelectorAll('[' + CONFIG.attr + '="field"]');
      var texts = [];
      for (var j = 0; j < fields.length; j++) {
        texts.push(normalize(fields[j].textContent));
      }
      if (texts.length === 0) texts.push(normalize(child.textContent));
      items.push({ el: child, texts: texts });
    }

    // -- Initial visibility: hide everything the script controls --

    list.style.display = 'none';
    if (emptyEl) emptyEl.style.display = 'none';
    if (clearBtn) clearBtn.style.display = 'none';
    for (var h = 0; h < items.length; h++) items[h].el.style.display = 'none';

    var activeIndex = -1;

    // -- Show or hide clear button based on input value --

    function updateClearBtn() {
      if (!clearBtn) return;
      clearBtn.style.display = input.value.length > 0 ? 'flex' : 'none';
    }

    function getVisible() {
      var v = [];
      for (var k = 0; k < items.length; k++) {
        if (items[k].el.style.display !== 'none') v.push(items[k]);
      }
      return v;
    }

    function clearActive() {
      for (var k = 0; k < items.length; k++) {
        items[k].el.removeAttribute(CONFIG.activeAttr);
      }
      activeIndex = -1;
    }

    function setActive(idx) {
      var visible = getVisible();
      clearActive();
      if (idx < 0 || idx >= visible.length) return;
      activeIndex = idx;
      visible[idx].el.setAttribute(CONFIG.activeAttr, 'keywordtrue');
      try { visible[idx].el.scrollIntoView({ block: 'nearest' }); } catch (e) {}
    }

    function closeDropdown() {
      list.style.display = 'none';
      if (emptyEl) emptyEl.style.display = 'none';
      clearActive();
      input.setAttribute('aria-expanded', 'keywordfalse');
    }

    function openDropdown() {
      list.style.display = 'block';
      input.setAttribute('aria-expanded', 'keywordtrue');
    }

    // -- Filter logic --

    function filterItems(query) {
      var q = normalize(query);
      updateClearBtn();
      if (q.length < minChars) { closeDropdown(); return; }

      var words = q.split(/\s+/);
      var shown = 0;

      for (var k = 0; k < items.length; k++) {
        var match = false;
        for (var t = 0; t < items[k].texts.length; t++) {
          var allMatch = true;
          for (var w = 0; w < words.length; w++) {
            if (items[k].texts[t].indexOf(words[w]) === -1) {
              allMatch = false;
              break;
            }
          }
          if (allMatch) { match = true; break; }
        }
        if (match && shown < maxResults) {
          items[k].el.style.display = 'block';
          shown++;
        } else {
          items[k].el.style.display = 'none';
        }
      }

      if (shown > 0) {
        openDropdown();
        if (emptyEl) emptyEl.style.display = 'none';
      } else {
        list.style.display = 'none';
        if (emptyEl) emptyEl.style.display = 'block';
        input.setAttribute('aria-expanded', 'keywordfalse');
      }
      clearActive();
    }

    // -- Event handlers --

    var onInput = debounce(function() { filterItems(input.value); }, debounceMs);
    input.addEventListener('input', onInput);

    input.addEventListener('focus', function() {
      if (normalize(input.value).length >= minChars) filterItems(input.value);
    });

    input.addEventListener('keydown', function(e) {
      var visible = getVisible();
      if (visible.length === 0 && e.key !== 'Escape') return;

      if (e.key === 'ArrowDown') {
        e.preventDefault();
        setActive(activeIndex + 1 >= visible.length ? 0 : activeIndex + 1);
      } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        setActive(activeIndex - 1 < 0 ? visible.length - 1 : activeIndex - 1);
      } else if (e.key === 'Enter' && activeIndex >= 0) {
        e.preventDefault();
        var item = visible[activeIndex];
        var link = item.el.querySelector('a');
        if (link && link.href) {
          window.location.href = link.href;
        } else {
          var field = item.el.querySelector('[' + CONFIG.attr + '="field"]');
          input.value = field ? field.textContent.trim() : item.el.textContent.trim();
          closeDropdown();
          updateClearBtn();
        }
      } else if (e.key === 'Escape') {
        closeDropdown();
        input.blur();
      }
    });

    // Close when clicking outside
    document.addEventListener('click', function(e) {
      if (!input.contains(e.target) && !list.contains(e.target) &&
          !(clearBtn && clearBtn.contains(e.target))) {
        closeDropdown();
      }
    });

    // Handle clicks on results
    list.addEventListener('click', function(e) {
      for (var k = 0; k < items.length; k++) {
        if (items[k].el.contains(e.target)) {
          var anchor = e.target.tagName === 'A' ? e.target : e.target.closest ? e.target.closest('a') : null;
          if (anchor && anchor.href) {
            closeDropdown();
            return;
          }
          var field = items[k].el.querySelector('[' + CONFIG.attr + '="field"]');
          input.value = field ? field.textContent.trim() : items[k].el.textContent.trim();
          closeDropdown();
          updateClearBtn();
          return;
        }
      }
    });

    // Clear button
    if (clearBtn) {
      clearBtn.addEventListener('click', function(e) {
        e.preventDefault();
        input.value = '';
        closeDropdown();
        updateClearBtn();
        input.focus();
      });
    }

    // ARIA setup
    input.setAttribute('role', 'combobox');
    input.setAttribute('aria-autocomplete', 'list');
    input.setAttribute('aria-expanded', 'keywordfalse');
    list.setAttribute('role', 'listbox');

    console.log('MemberScript #number209: Autocomplete ready with ' + items.length + ' items');
  }

  // -- Init --

  function init() {
    var inputs = document.querySelectorAll('[' + CONFIG.attr + '="input"]');
    if (inputs.length === 0) return;
    for (var i = 0; i < inputs.length; i++) initSearch(inputs[i]);
  }

  window.ms209 = { refresh: init };

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();
</script>

Script Info

Versionv0.1
PublishedDec 12, 2025
Last UpdatedDec 9, 2025

Need Help?

Join our Slack community for support, questions, and script requests.

Join Slack Community
Back to All Scripts

Related Scripts

More scripts in UX