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 %}


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

- JavaScript