/* filters elements on page based on url or search box. syntax: term1 term2 "full phrase 1" "full phrase 2" "tag: tag 1" match if: all terms AND at least one phrase AND at least one tag */ { // elements to filter const elementSelector = ".card, .citation, .post-excerpt"; // search box element const searchBoxSelector = ".search-box"; // results info box element const infoBoxSelector = ".search-info"; // tags element const tagSelector = ".tag"; // split search query into terms, phrases, and tags const splitQuery = (query) => { // split into parts, preserve quotes const parts = query.match(/"[^"]*"|\S+/g) || []; // bins const terms = []; const phrases = []; const tags = []; // put parts into bins for (let part of parts) { if (part.startsWith('"')) { part = part.replaceAll('"', "").trim(); if (part.startsWith("tag:")) tags.push(normalizeTag(part.replace(/tag:\s*/, ""))); else phrases.push(part.toLowerCase()); } else terms.push(part.toLowerCase()); } return { terms, phrases, tags }; }; // normalize tag string for comparison window.normalizeTag = (tag) => tag.trim().toLowerCase().replaceAll(/\s+/g, "-"); // get data attribute contents of element and children const getAttr = (element, attr) => [element, ...element.querySelectorAll(`[data-${attr}]`)] .map((element) => element.dataset[attr]) .join(" "); // determine if element should show up in results based on query const elementMatches = (element, { terms, phrases, tags }) => { // tag elements within element const tagElements = [...element.querySelectorAll(".tag")]; // check if text content exists in element const hasText = (string) => ( element.innerText + getAttr(element, "tooltip") + getAttr(element, "search") ) .toLowerCase() .includes(string); // check if text matches a tag in element const hasTag = (string) => tagElements.some((tag) => normalizeTag(tag.innerText) === string); // match logic return ( (terms.every(hasText) || !terms.length) && (phrases.some(hasText) || !phrases.length) && (tags.some(hasTag) || !tags.length) ); }; // loop through elements, hide/show based on query, and return results info const filterElements = (parts) => { let elements = document.querySelectorAll(elementSelector); // results info let x = 0; let n = elements.length; let tags = parts.tags; // filter elements for (const element of elements) { if (elementMatches(element, parts)) { element.style.display = ""; x++; } else element.style.display = "none"; } return [x, n, tags]; }; // highlight search terms const highlightMatches = async ({ terms, phrases }) => { // make sure Mark library available if (typeof Mark === "undefined") return; // reset new Mark(document.body).unmark(); // limit number of highlights to avoid slowdown let counter = 0; const filter = () => counter++ < 100; // highlight terms and phrases new Mark(elementSelector) .mark(terms, { separateWordSearch: true, filter }) .mark(phrases, { separateWordSearch: false, filter }); }; // update search box based on query const updateSearchBox = (query = "") => { const boxes = document.querySelectorAll(searchBoxSelector); for (const box of boxes) { const input = box.querySelector("input"); const button = box.querySelector("button"); const icon = box.querySelector("button i"); input.value = query; icon.className = input.value.length ? "icon fa-solid fa-xmark" : "icon fa-solid fa-magnifying-glass"; button.disabled = input.value.length ? false : true; } }; // update info box based on query and results const updateInfoBox = (query, x, n) => { const boxes = document.querySelectorAll(infoBoxSelector); if (query.trim()) { // show all info boxes boxes.forEach((info) => (info.style.display = "")); // info template let info = ""; info += `Showing ${x.toLocaleString()} of ${n.toLocaleString()} results
`; info += "Clear search"; // set info HTML string boxes.forEach((el) => (el.innerHTML = info)); } // if nothing searched else { // hide all info boxes boxes.forEach((info) => (info.style.display = "none")); } }; // update tags based on query const updateTags = (query) => { const { tags } = splitQuery(query); document.querySelectorAll(tagSelector).forEach((tag) => { // set active if tag is in query if (tags.includes(normalizeTag(tag.innerText))) tag.setAttribute("data-active", ""); else tag.removeAttribute("data-active"); }); }; // run search with query const runSearch = (query = "") => { const parts = splitQuery(query); const [x, n] = filterElements(parts); updateSearchBox(query); updateInfoBox(query, x, n); updateTags(query); highlightMatches(parts); }; // update url based on query const updateUrl = (query = "") => { const url = new URL(window.location); let params = new URLSearchParams(url.search); params.set("search", query); url.search = params.toString(); window.history.replaceState(null, null, url); }; // search based on url param const searchFromUrl = () => { const query = new URLSearchParams(window.location.search).get("search") || ""; runSearch(query); }; // return func that runs after delay const debounce = (callback, delay = 250) => { let timeout; return (...args) => { window.clearTimeout(timeout); timeout = window.setTimeout(() => callback(...args), delay); }; }; // when user types into search box const debouncedRunSearch = debounce(runSearch, 1000); window.onSearchInput = (target) => { debouncedRunSearch(target.value); updateUrl(target.value); }; // when user clears search box with button window.onSearchClear = () => { runSearch(); updateUrl(); }; // after page loads window.addEventListener("load", searchFromUrl); // after tags load window.addEventListener("tagsfetched", searchFromUrl); }