p p tommydalton.dev | Web Developer / Software Engineer/ Multimedia Artist
tommydalton & rootlevel.dev

tommydalton.dev

JS Autocomplete & Advanced Search with Craft CMS’s Element Query API

Step 1: Autocomplete from .json keyup search function.

import {ce, gid, isDefined, listen, qs} from '../helpers';
(() => {
    // Variables
    const inf = gid('inf');
    // Methods
    const init = () => {
      // Inner variables
      const smodal = gid('acSearch');
      const sqi = gid('sqi');
      const baseUrl = window.location.protocol + '//' + window.location.host;
      const url = baseUrl + '/autocomplete-uwIg2ivZtlYXrk.json';
      // Methods
      listen(inf, 'focus', () => {
        sqi.classList.add('active');
      });
      listen(inf, 'blur', () => {
        sqi.classList.remove('active');
      });
      
      listen(document, 'keydown', (e) => {
        if (e.key === 'Tab') {
          setTimeout(function() {
            const activeElement = document.activeElement;
            if (activeElement && activeElement.getAttribute('data-bs-target') === '#acSearch') {
              activeElement.addEventListener('keydown', function(e) {
                if (e.key === 'Enter') {
                  window.open(baseUrl + '/search?', '_self');
                  e.preventDefault();
                }
              }, {
                once: true
              });
            }
          }, 0);
        }
      });
      listen(document, 'keydown', (e) => {
        let listItems;
        let focusedElement;
        if (e.shiftKey && e.key === 'Tab') {
          focusedElement = document.activeElement;
          listItems = document.querySelectorAll('.querymatch li');
          if (listItems.length > 0 && focusedElement === listItems[1]) {
            event.preventDefault();
            if (listItems[0]) {
              listItems[0].focus();
            }
          }
        } else if (e.key === 'Tab') {
          focusedElement = document.activeElement;
          listItems = document.querySelectorAll('.querymatch li');
          if (listItems.length > 0 && focusedElement === listItems[0]) {
            event.preventDefault();
            if (listItems[1]) {
              listItems[1].focus();
            }
          }
        } else if (e.key === 'Enter') {
          focusedElement = document.activeElement;
          const focusedElementTarget = focusedElement.querySelector('a');
          if (focusedElementTarget) {
            window.location.href = focusedElementTarget.href;
          }
        } else {
          // console.log('listen');
        }
      });
      listen(inf, 'keyup', () => {
        const searchTerm = inf.value.toLowerCase();
        let inputFVal = inf.value;
        fetch(url)
          .then(response => response.json())
          .then(data => {
            let cleanData = data.map(entry => {
              return {
                ...entry,
                cleanSeoTitle: entry.seoTitle.replace(/%%.+?%%/g, '').replace(/&/g, '&'),
                cleanSeoDescription: entry.seoDescription.replace(/%%.+?%%/g, '').replace(/&/g, '&'),
                cleanSlug: entry.slug.replace(/-/g, ' ').replace(/[^a-zA-Z0-9\s]/g, ''),
                cleanContent: entry.pageContent.replace(/%%.+?%%/g, '').replace(/&/g, '&')
              };
            });
            const slugMatches = cleanData.filter(entry => 
            entry.cleanSlug.toLowerCase().includes(searchTerm));
            const seoTitleMatches = cleanData.filter(entry =>
            entry.cleanSeoTitle.toLowerCase().includes(searchTerm));
            const seoDescriptionMatches = cleanData.filter(entry =>
            entry.cleanSeoDescription.toLowerCase().includes(searchTerm));
            const pageContentMatches = cleanData.filter(entry =>
            entry.cleanContent.toLowerCase().includes(searchTerm));
            let matches = [
              ...seoTitleMatches,
              ...slugMatches.filter(slugMatch =>
                 !seoTitleMatches.includes(slugMatch)),
              ...seoDescriptionMatches.filter(descMatch =>
                 !seoTitleMatches.includes(descMatch) && !slugMatches.includes(descMatch)),
              ...pageContentMatches.filter(contentMatch =>
                 !seoTitleMatches.includes(contentMatch) && !slugMatches.includes(
                 contentMatch) && !seoDescriptionMatches.includes(contentMatch))
            ];
            matches.sort((a, b) => {
              if (a.cleanSeoTitle.toLowerCase().indexOf(searchTerm) !== -1) {
                return -1;
              } else if (b.cleanSeoTitle.toLowerCase().indexOf(searchTerm) !== -1) {
                return 1;
              } else if (a.cleanSlug.toLowerCase().indexOf(searchTerm) !== -1) {
                return -1;
              } else if (b.cleanSlug.toLowerCase().indexOf(searchTerm) !== -1) {
                return 1;
              } else if (a.cleanSeoDescription.toLowerCase().indexOf(searchTerm) !== -1) {
                return -1;
              } else if (b.cleanSeoDescription.toLowerCase().indexOf(searchTerm) !== -1) {
                return 1;
              } else {
                return a.cleanContent.toLowerCase().indexOf(searchTerm) - b.cleanContent.toLowerCase().indexOf(searchTerm);
              }
            });
            const ddMenu = ce('ul');
            ddMenu.className = 'querymatch dropdown-menu w-100 mt-6 p-0';
            ddMenu.setAttribute('tabIndex', -1);
            ddMenu.classList.toggle('d-inline', inputFVal.length >= 2);
            matches.forEach((match, index) => {
              const dditem = ce('li');
              dditem.tabindex = 0;
              dditem.className = 'dropdown-item dropdown-border py-2 px-3';
              dditem.setAttribute('tabIndex', index);
              const anchor = ce('a');
              anchor.className = 'dropdown-link fs-6 lh-1 fw-bold';
              anchor.href = match.url;
              anchor.textContent = match.cleanSeoTitle;
              anchor.innerHTML = highlightMatch(match.cleanSeoTitle, searchTerm);
              dditem.appendChild(anchor);
              ddMenu.appendChild(dditem);
            });
            const searchField = qs('input[name="query"]');
            const prevMenu = qs('.querymatch');
            if (prevMenu) {
              prevMenu.parentNode.removeChild(prevMenu);
            }
            searchField.parentNode.appendChild(ddMenu);

            function highlightMatch(text, term) {
              const startIndex = text.toLowerCase().indexOf(term.toLowerCase());
              if (startIndex === -1) return text;
              const matchedText = text.slice(startIndex, startIndex + term.length);
              return text.replace(new RegExp(term, 'gi'), `${matchedText}`);
            }
            if (ddMenu) {
              const lis = ddMenu.querySelectorAll('li');
              const totalHeight = Array.from(lis).reduce(function(sum, li) {
                return sum + li.offsetHeight;
              }, 0);
              const maxUlHeight = window.innerHeight - 200;
              if (totalHeight > maxUlHeight) {
                ddMenu.style.height = maxUlHeight + 'px';
                ddMenu.style.overflowY = 'scroll';
              } else {
                ddMenu.style.height = totalHeight + 'px';
                ddMenu.style.overflowY = 'auto';
              }
            }
          });
      });
    };
    // Events
    if (isDefined(inf)) {
      init();
    }

Step 2: Craft CMS db search and .twig results page output.

{% extends "_entries/default" %}

{% set props = {
    query: craft.app.request.getParam('query') ?? '',
    page: craft.app.request.getParam('page') ?? 1,
    offset: (craft.app.request.getParam('page') ?? 1) - 1
} %}

{% set data = {
    pageLinksLimit: craft.app.request.isMobileBrowser() ? 7 : 12
} %}

{% set query = craft.entries()
    .section('pages')
    .search(props.query)
    .type('not playground')
    .limit(data.pageLinksLimit) 
     %}

{% set pagesResults = craft.entries()
    .section('pages')
    .type('not playground')
    .all() %}

{% set craftResults = craft.entries()
    .section('pages')
    .search(props.query)
    .type('not playground')
    .all() %}

{# {% set SEOmaticMatches = 0 %} #}
{% set seomaticFilteredResults = [] %}

{% for entry in pagesResults %}
    {% set cleanSlug = entry.slug|replace({'-': ' ', '_': ' '}) %}
    {% set seoDescription = entry.seo.metaGlobalVars.parsedValue('seoDescription') ?? '' %}
    {% set rawTitle = entry.seo.metaGlobalVars.parsedValue('seoTitle') ?? '' %}
    {% set seoTitle = rawTitle|replace({'%%sep%%': ' ', '%%sitename%%': ' '}) %}
    {% set searchableContent = cleanSlug ~ ' ' ~ seoDescription ~ ' ' ~ seoTitle %}

    {% if props.query and searchableContent matches ('/' ~ props.query ~ '/') %}
        {# {% set SEOmaticMatches = SEOmaticMatches + 1 %} #}
        {% set seomaticFilteredResults = seomaticFilteredResults|merge([entry]) %}
    {% endif %}
{% endfor %}

{% set combinedResults = craftResults|merge(seomaticFilteredResults) %}
{% set uniqueResults = [] %}
{% set seenIds = [] %}
{% for entry in combinedResults %}
    {% if entry.id not in seenIds %}
        {% set uniqueResults = uniqueResults|merge([entry]) %}
        {% set seenIds = seenIds|merge([entry.id]) %}
    {% endif %}
{% endfor %}

{% set uniqueEntryIds = [] %}

{# Populate uniqueEntryIds with entry.id from uniqueEntries #}
{% for entry in uniqueResults %}
    {% set uniqueEntryIds = uniqueEntryIds|merge([entry.id]) %}
{% endfor %}

{# Paginate outputEntries with unique entry.ids and limit based on mobile browser detection #}
{% set outputResults = craft.entries()
    .section('pages')
    .type('not playground')
    .orderBy('score')
    .id(uniqueEntryIds)
    .limit(data.pageLinksLimit)
%}

{% set isMobile = craft.deviceDetect.isMobile ??? false %}
{% set totalResults = outputResults|length %}

{% block main %}

    <section class="bg-blue-400 py-5 py-lg-6">
        <div class="container">

            <form method="get" accept-charset="UTF-8">

                <div class="input-group mb-3">

                    {{ input('text', 'query', props.query, {
                        class: 'form-control',
                        pattern: '^[a-zA-Z0-9_ ]*$',
                        placeholder: "Type search word …",
                        title: 'No special characters'|t,
                        'aria-label': 'Search query'|t,
                        required: true
                    }) }}

                </div>
            </form>
        </div>
    </section>

    <section class="py-5 py-lg-6">

        <div class="container">
            <div class="clearfix
            {% if props.query|length %}border-bottom{% endif %}
            opacity-50 mt-1 mb-2"></div>

            {% if props.query|length %}

            <div class="search-results-container">

                {% if totalResults == 0 %}

                    <div class="no-results mb-6">
                        <p>No results found for "<span class='fw-medium text-blue'>{{ props.query }}</span>".</p>
                    </div>

                {% endif %}

            </div>
        </div>
        <div class="clearfix spacer-4"></div>
        <div class="row">

            {% if pag.totalPages > 1 and props.query|length %}

            <nav aria-label="Page navigation">
                <ul class="pagination justify-content-center mb-0">

                    {% set visibleLinks = data.pageLinksLimit - 4 %}
                        {% if isMobile and pag.currentPage <= 3 %}
                            {% if pag.currentPage == 1 %}
                                {% set sideLinks = (visibleLinks / 2)|round(0, 'ceil') %}
                            {% elseif pag.currentPage == 2 %}
                                {% set sideLinks = (visibleLinks / 2)|round(0, 'ceil') %}
                            {% elseif pag.currentPage > 2 %}
                                {% set sideLinks = (visibleLinks / 2)|round(0, 'floor') %}
                            {% else %}
                                {% set sideLinks = (visibleLinks / 2)|round(0, 'floor') %}
                            {% endif %}
                        {% else %}
                        {% set sideLinks = (visibleLinks / 2)|round(0, 'floor') %}
                        {% endif %}
                    {% set startPage = max(pag.currentPage - sideLinks, 2) %}
                    {% set endPage = min(pag.currentPage + sideLinks, pag.totalPages - 1) %}
                    
                    {% if pag.currentPage <= sideLinks %}
                        {% if isMobile and pag.currentPage == 1 %}
                            {% set startPage = 1 %}
                            {% set endPage = pag.currentPage + sideLinks + 1 %}
                        {% else %}
                            {% set startPage = 2 %}
                            {% set endPage = min(visibleLinks, pag.totalPages - 1) %}
                        {% endif %}    
                    {% elseif pag.currentPage > pag.totalPages - sideLinks %}
                        {% set startPage = max(pag.totalPages - visibleLinks, 2) %}
                        {% set endPage = pag.totalPages - 1 %}
                    {% endif %}

                    {% if pag.currentPage > sideLinks and pag.currentPage <= pag.totalPages - sideLinks %}
                        {% if isMobile and pag.currentPage < pag.totalPages - sideLinks - 1
                           and pag.currentPage > pag.totalPages + 3 - pag.totalPages - sideLinks %}
                            {% set startPage = pag.currentPage - sideLinks + 1 %}
                            {% set endPage = pag.currentPage + sideLinks - 1 %}
                        {% elseif isMobile and pag.currentPage == pag.totalPages - sideLinks - 1 %}
                            {% set startPage = pag.currentPage - sideLinks + 1 %}
                            {% set endPage = pag.currentPage + sideLinks - 1 %}
                        {% elseif isMobile %}
                            {% set startPage = pag.currentPage - sideLinks %}
                            {% set endPage = pag.currentPage + sideLinks - 1 %}
                        {% else %}
                            {% set startPage = pag.currentPage - sideLinks + 1 %}
                            {% set endPage = pag.currentPage + sideLinks - 1 %}
                        {% endif %}
                    {% endif %}

                    {% if pag.prevUrl %}
                        <li class="page-item">
                            <a href="{{ pag.prevUrl }}" class="page-link prev fw-medium">Previous</a>
                        </li>
                    {% endif %}

                    <li class="page-item">
                        <a href="{{ pag.currentPage == 1 ?
                            'javascript:void(0);' : pag.getPageUrl(1)}}"
                            class="page-link fw-medium {{ pag.currentPage == 1 ? 'active' : '' }}"
                        >1</a>
                    </li>

                    {% if startPage > 2 %}

                        <li class="page-item disabled">
                            <a class="page-link fw-medium dots"
                                href="#"
                                tabindex="-1"
                                aria-disabled="true"
                            >...</a>
                        </li>

                    {% endif %}

                    {% for page in startPage..endPage %}
                        {% if page > 1 and page < pag.totalPages %}

                            <li class="page-item">
                                <a href="{{ pag.currentPage == page ?
                                    'javascript:void(0);' : pag.getPageUrl(page)}}"
                                    class="page-link fw-medium {{ page == pag.currentPage ? 'active' : '' }}"
                                >{{ page }}</a>
                            </li>

                        {% endif %}
                    {% endfor %}

                    {% if endPage < pag.totalPages - 1 %}
                        <li class="page-item disabled">
                            <a class="page-link fw-medium dots"
                                href="#"
                                tabindex="-1"
                                aria-disabled="true"
                            >...</a>
                        </li>
                    {% endif %}

                    <li class="page-item">
                        <a href="{{ pag.currentPage == pag.totalPages ?
                            'javascript:void(0);' : pag.getPageUrl(pag.totalPages)}}"
                            class="page-link fw-medium {{ pag.currentPage == pag.totalPages ? 'active' : '' }}"
                        >{{ pag.totalPages }}</a>
                    </li>

                    {% if pag.nextUrl %}

                        <li class="page-item">
                            <a href="{{ pag.nextUrl }}" class="page-link next fw-medium">Next</a>
                        </li>

                    {% endif %}

                </ul>
            </nav>

            {% endif %}

        </div>        
    <div class="clearfix spacer-4"></div>
</section> 

{% endblock %}
Hf-js-autocomplete-json
hf-js-autocomplete-json__project_detail_enlarge.jpg
75.79 KB

1454 x 857
1 of 2
Hf-js-autocomplete-json
Hf-craft-cms-return
hf-craft-cms-return__project_detail_enlarge.jpg
147.46 KB

1454 x 1837
2 of 2
Hf-craft-cms-return
More Projects
Message Tommy

Autocomplete & Advanced Search

JavaScript, Element Query API, and Twig

  • Code Examples

July 2024

Project Description

This JavaScript module enhances a search input field with keyup autocomplete functionality, efficient filtering and navigation features. It listens for user interactions and searches while fetching autocomplete suggestions from a JSON file containing summarized site entry data output from Craft CMS. The script processes the search results by cleaning and prioritizing matches based on title, description, slug, and content relevance. It dynamically creates and updates a dropdown menu with clickable suggestions and ensures smooth navigation using keyboard interactions. Additionally, it manages the visibility and accessibility of the dropdown for an improved user experience. If match is not found in optimized search term data, the query is submitted through Craft CMS db search that parses all entry fields including seoMatic plugin data and ranks relevancy of results

Healthfirst
Try it:
Project Technologies Languages, Libraries & CMS
  • JavaScript
App Settings Disable Image Overlay Enable Image Overlay Disable Filter Effect Enable Filter Effect
tommydalton & rootlevel.dev

Contact

ee@tommydalton.dev linkedin.com/in/tommydaltonchi (312)810-7668 (ROOT)

x
Orientation [listening]
DOM
WIN
s
s
default xs sm md lg xl 2xl 3xl 4xl 5xl 6xl 7xl