From 513f82988db61e6fc579f5735df71e65bad2988d Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Sat, 25 Mar 2023 18:06:57 +0100 Subject: [PATCH] Search: integrate under `/` - update styles - integrate under `/` I copied all the code from https://github.com/readthedocs/readthedocs-sphinx-search/pull/132/. However, there are lot of things that are not required for this stage. I removed some of them and make the integration nicer with the client we are creating. These changes are related to the HTML structure and the CSS style. The functionality is exactly the same. Related #19 --- package-lock.json | 38 ++ package.json | 2 + src/search.css | 317 +++++++++++++++++ src/search.js | 861 +++++++++++++++++++++++++++++++++++++++++++++- webpack.config.js | 4 + 5 files changed, 1220 insertions(+), 2 deletions(-) create mode 100644 src/search.css diff --git a/package-lock.json b/package-lock.json index 84de3795..63f71722 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@babel/core": "^7.21.3", "@babel/plugin-syntax-import-assertions": "^7.20.0", "@babel/preset-env": "^7.20.2", + "@fortawesome/fontawesome-svg-core": "^6.3.0", + "@fortawesome/free-solid-svg-icons": "^6.3.0", "babel-jest": "^29.5.0", "babel-loader": "^9.1.2", "css-loader": "^6.7.3", @@ -1743,6 +1745,42 @@ "node": ">=10.0.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz", + "integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg==", + "dev": true, + "hasInstallScript": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz", + "integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz", + "integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.3.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", diff --git a/package.json b/package.json index f2743dd8..98bd528e 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "doc-diff": "github:readthedocs/doc-diff#humitos/nodejs-version", "ethical-ad-client": "github:readthedocs/ethical-ad-client#humitos/node-sass", "express-interceptor": "^1.2.0", + "@fortawesome/fontawesome-svg-core": "^6.3.0", + "@fortawesome/free-solid-svg-icons": "^6.3.0", "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", "jest-fetch-mock": "^3.0.3", diff --git a/src/search.css b/src/search.css new file mode 100644 index 00000000..dbbcebc8 --- /dev/null +++ b/src/search.css @@ -0,0 +1,317 @@ +.search__outer__wrapper { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 700; +} + +/* Backdrop */ + +.search__backdrop { + /* Positioning */ + position: fixed; + top: 0; + left: 0; + z-index: 500; + + /* Display and box model */ + width: 100%; + height: 100%; + display: none; + + /* Other */ + background-color: rgba(0, 0, 0, 0.3); +} + +.search__outer { + /* Positioning */ + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100000; + + /* Display and box model */ + height: 60%; + width: 60%; + max-height: 1000px; + max-width: 1500px; + overflow-y: scroll; + + /* Other */ + border: 1px solid #e0e0e0; + border-radius: 0.5rem 0.5rem 0 0; + line-height: 1.875; + background-color: #fcfcfc; + box-shadow: rgba(0, 0, 0, 0.05) 5px 5px 5px 5px, + rgba(0, 0, 0, 0.05) -5px -5px 5px 5px; + text-align: left; +} + +/* Custom scrollbar */ + +.search__outer::-webkit-scrollbar-track { + border-radius: 10px; + background-color: #fcfcfc; +} + +.search__outer::-webkit-scrollbar { + width: 7px; + height: 7px; + background-color: #fcfcfc; +} + +.search__outer::-webkit-scrollbar-thumb { + border-radius: 10px; + background-color: #8f8f8f; +} + +.search__outer form { + background-color: #eaeaea; + margin: 1rem; + border-radius: 0.3rem; + font-size: 1.2rem; + padding: 5px; +} + +.search__outer form label { + display: inline-block; + cursor: default; + font-size: 1.3rem; + padding-left: 10px; + margin: 0; +} + +.search__outer form input { + border: 0; + outline: none; + background: inherit; + width: 95%; +} + +/* Cross icon on top-right corner */ + +.search__cross { + position: absolute; + top: 0; + right: 0; + margin: 1rem; + font-size: large; +} + +.search__result__box { + padding: 5px; + margin: 1rem; +} + +.search__result__single { + margin-top: 10px; + border-bottom: 1px solid #e6e6e6; +} + +.search__result__box .active { + background-color: rgb(245, 245, 245); +} + +.search__error__box { + color: black; + min-width: 300px; + font-weight: 700; +} + +.outer_div_page_results { + margin: 5px 0px; + overflow: auto; + padding: 3px 5px; +} + +.search__result__single a { + text-decoration: none; + cursor: pointer; +} + +/* Title of each search result */ + +.search__result__title { + /* Display and box model */ + display: inline-block; + font-weight: 500; + margin-bottom: 15px; + margin-top: 0; + font-size: 15px; + + /* Other */ + color: #6ea0ec; + border-bottom: 1px solid #6ea0ec; +} + +.search__result__subheading { + color: black; + font-weight: 700; + float: left; + width: 20%; + font-size: 15px; + margin-right: 10px; + word-break: break-all; + overflow-x: hidden; +} + +.search__result__content { + margin: 0; + text-decoration: none; + color: black; + font-size: 15px; + display: block; + margin-bottom: 5px; + margin-bottom: 0; + line-height: inherit; + float: right; + width: calc(80% - 15px); + text-align: left; +} + +/* Highlighting of matched results */ + +.search__outer span { + font-style: normal; +} + +.search__outer .search__result__title span { + background-color: #e5f6ff; + padding-bottom: 3px; + border-bottom-color: black; +} + +.search__outer .search__result__content span { + background-color: #e5f6ff; + border-bottom: 1px solid black; +} + +.search__result__subheading span { + border-bottom: 1px solid black; +} + +.outer_div_page_results:hover { + background-color: rgb(245, 245, 245); +} + +.br-for-hits { + display: block; + content: ""; + margin-top: 10px; +} + +.rtd_ui_search_subtitle { + all: unset; + color: inherit; + font-size: 85%; +} + +.rtd__search__credits { + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: calc(-60% - 20px); + width: 60%; + max-width: 1500px; + height: 30px; + overflow: hidden; + background: #eee; + z-index: 100000; + border-radius: 0 0 0.5rem 0.5rem; + box-shadow: rgba(0, 0, 0, 0.05) 5px 5px 5px; + padding: 3px; + text-align: center; + color: black; +} + +.rtd__search__credits a { + color: black; + text-decoration: underline; + display: inline-block; +} + +.rtd__search__credits a img { + display: inline-block; + width: 125px; +} + +.search__domain_role_name { + font-size: 80%; + letter-spacing: 1px; +} + +.search__filters { + padding: 5px; + margin-left: 1rem; +} + +.search__filters ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; +} + +.search__filters li { + display: flex; + align-items: center; + margin-right: 15px; +} + +.search__filters li label { + border: 3px; + padding: 3px; +} + +.search__filters label { + color: black; + font-size: 15px; + margin: auto; +} + +.search__filters .search__filters__title { + color: black; + font-size: 15px; +} + +@media (max-width: 670px) { + .rtd__search__credits { + height: 50px; + bottom: calc(-60% - 40px); + overflow: hidden; + } +} + +@media (min-height: 1250px) { + .rtd__search__credits { + bottom: calc(-60% - 30px); + } +} + +@media (max-width: 630px) { + .search__result__subheading { + float: none; + width: 90%; + } + + .search__result__content { + float: none; + width: 90%; + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/src/search.js b/src/search.js index 1e9f4042..a22565b6 100644 --- a/src/search.js +++ b/src/search.js @@ -1,3 +1,860 @@ -import { initializeSearchAsYouType } from "readthedocs-search"; +import { library, icon } from "@fortawesome/fontawesome-svg-core"; +import { + faCircleXmark, + faMagnifyingGlass, +} from "@fortawesome/free-solid-svg-icons"; +import READTHEDOCS_LOGO from "./images/logo-wordmark-dark.svg"; -export { initializeSearchAsYouType }; +import styles from "./search.css"; +import { domReady } from "./utils"; + +const MAX_SUGGESTIONS = 50; +const MAX_SECTION_RESULTS = 3; +const MAX_SUBSTRING_LIMIT = 100; +const FETCH_RESULTS_DELAY = 250; +const CLEAR_RESULTS_DELAY = 300; +const RTD_SEARCH_PARAMETER = "rtd_search"; + +/** + * Debounce the function. + * Usage:: + * + * let func = debounce(() => console.log("Hello World"), 3000); + * + * // calling the func + * func(); + * + * //cancelling the execution of the func (if not executed) + * func.cancel(); + * + * @param {Function} func function to be debounced + * @param {Number} wait time to wait before running func (in miliseconds) + * @return {Function} debounced function + */ +const debounce = (func, wait) => { + let timeout; + + let debounced = function () { + let context = this; + let args = arguments; + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(context, args), wait); + }; + + debounced.cancel = () => { + clearTimeout(timeout); + timeout = null; + }; + + return debounced; +}; + +/** + * Build a section with its matching results. + * + * A section has the form: + * + * + *
+ * + * {title} + * + *

+ * {contents[0]} + *

+ *

+ * {contents[1]} + *

+ * ... + *
+ *
+ * + * @param {String} id. + * @param {String} title. + * @param {String} link. + * @param {Array} contents. + */ +const buildSection = function (id, title, link, contents) { + let span_element = createDomNode("span", { + class: "search__result__subheading", + }); + span_element.innerHTML = title; + + let div_element = createDomNode("div", { + class: "outer_div_page_results", + id: id, + }); + div_element.appendChild(span_element); + + for (var i = 0; i < contents.length; i += 1) { + let p_element = createDomNode("p", { class: "search__result__content" }); + p_element.innerHTML = contents[i]; + div_element.appendChild(p_element); + } + + let section = createDomNode("a", { href: link }); + section.appendChild(div_element); + return section; +}; + +/** + * Adds/removes "rtd_search" url parameter to the url. + */ +const updateUrl = () => { + let parsed_url = new URL(window.location.href); + let search_query = getSearchTerm(); + // search_query should not be an empty string. + if (search_query.length > 0) { + parsed_url.searchParams.set(RTD_SEARCH_PARAMETER, search_query); + } else { + parsed_url.searchParams.delete(RTD_SEARCH_PARAMETER); + } + // Update url. + window.history.pushState({}, null, parsed_url.toString()); +}; + +/* + * Keeps in sync the original search bar with the input from the modal. + */ +const updateSearchBar = () => { + let search_bar = getInputField(); + search_bar.value = getSearchTerm(); +}; + +/* + * Returns true if the modal window is visible. + */ +const isModalVisible = () => { + let modal = document.querySelector(".search__outer__wrapper"); + if (modal !== null && modal.style !== null && modal.style.display !== null) { + return modal.style.display === "block"; + } + return false; +}; + +/** + * Create and return DOM nodes + * with passed attributes. + * + * @param {String} nodeName name of the node + * @param {Object} attributes obj of attributes to be assigned to the node + * @return {Object} dom node with attributes + */ +const createDomNode = (nodeName, attributes) => { + let node = document.createElement(nodeName); + if (attributes !== null) { + for (let attr in attributes) { + node.setAttribute(attr, attributes[attr]); + } + } + return node; +}; + +/** + * Checks if data type is "string" or not + * + * @param {*} data data whose data-type is to be checked + * @return {Boolean} 'true' if type is "string" and length is > 0 + */ +const _is_string = (str) => { + if (typeof str === "string" && str.length > 0) { + return true; + } else { + return false; + } +}; + +/** + * Generate and return html structure + * for a page section result. + * + * @param {Object} sectionData object containing the result data + * @param {String} page_link link of the main page. It is used to construct the section link + * @param {Number} id to be used in for this section + */ +const get_section_html = (sectionData, page_link, id) => { + let section_subheading = sectionData.title; + let highlights = sectionData.highlights; + if (highlights.title.length) { + section_subheading = highlights.title[0]; + } + + let section_content = [ + sectionData.content.substring(0, MAX_SUBSTRING_LIMIT) + " ...", + ]; + + if (highlights.content.length) { + let highlight_content = highlights.content; + section_content = []; + for ( + let j = 0; + j < highlight_content.length && j < MAX_SECTION_RESULTS; + ++j + ) { + section_content.push("... " + highlight_content[j] + " ..."); + } + } + + let section_link = `${page_link}#${sectionData.id}`; + let section_id = "hit__" + id; + return buildSection( + section_id, + section_subheading, + section_link, + section_content + ); +}; + +/** + * Generate and return html structure + * for a sphinx domain result. + * + * @param {Object} domainData object containing the result data + * @param {String} page_link link of the main page. It is used to construct the section link + * @param {Number} id to be used in for this section + */ +const get_domain_html = (domainData, page_link, id) => { + let domain_link = `${page_link}#${domainData.id}`; + let domain_role_name = domainData.role; + let domain_name = domainData.name; + let domain_content = + domainData.content.substr(0, MAX_SUBSTRING_LIMIT) + " ..."; + + let highlights = domainData.highlights; + if (highlights.name.length) { + domain_name = highlights.name[0]; + } + if (highlights.content.length) { + domain_content = highlights.content[0]; + } + + let domain_id = "hit__" + id; + + let div_role_name = createDomNode("div", { + class: "search__domain_role_name", + }); + div_role_name.innerText = `[${domain_role_name}]`; + domain_name += div_role_name.outerHTML; + + return buildSection(domain_id, domain_name, domain_link, [domain_content]); +}; + +/** + * Generate search results for a single page. + * + * This has the form: + *
+ * + *

+ * {title} + * {subtitle} + *
+ *

+ *
+ * + * + * {section} + * + *
+ * + * + * {section} + * + *
+ *
+ * + * @param {Object} resultData search results of a page + * @param {String} projectName + * @param {Number} id from the last section + * @return {Object} a
node with the results of a single page + */ +const generateSingleResult = (resultData, projectName, id) => { + let page_link = resultData.path; + let page_title = resultData.title; + let highlights = resultData.highlights; + + if (highlights.title.length) { + page_title = highlights.title[0]; + } + + let h2_element = createDomNode("h2", { class: "search__result__title" }); + h2_element.innerHTML = page_title; + + // Results can belong to different projects. + // If the result isn't from the current project, add a note about it. + const project_slug = resultData.project.slug; + if (projectName !== project_slug) { + let subtitle = createDomNode("small", { class: "rtd_ui_search_subtitle" }); + subtitle.innerText = ` (from project ${project_slug})`; + h2_element.appendChild(subtitle); + // If the result isn't from the current project, + // then we create an absolute link to the page. + page_link = `${resultData.domain}${page_link}`; + } + h2_element.appendChild(createDomNode("br")); + + let a_element = createDomNode("a", { href: page_link }); + a_element.appendChild(h2_element); + + let content = createDomNode("div"); + content.appendChild(a_element); + + let separator = createDomNode("br", { class: "br-for-hits" }); + for (let i = 0; i < resultData.blocks.length; ++i) { + let block = resultData.blocks[i]; + let section = null; + id += 1; + if (block.type === "section") { + section = get_section_html(block, page_link, id); + } else if (block.type === "domain") { + section = get_domain_html(block, page_link, id); + } + + if (section !== null) { + content.appendChild(section); + content.appendChild(separator); + } + } + return content; +}; + +/** + * Generate search suggestions list. + * + * @param {Object} data response data from the search backend + * @param {String} projectName name (slug) of the project + * @return {Object} a
node with class "search__result__box" with results + */ +const generateSuggestionsList = (data, projectName) => { + let search_result_box = createDomNode("div", { + class: "search__result__box", + }); + + let max_results = Math.min(MAX_SUGGESTIONS, data.results.length); + let id = 0; + for (let i = 0; i < max_results; ++i) { + let search_result_single = createDomNode("div", { + class: "search__result__single", + }); + + let content = generateSingleResult(data.results[i], projectName, id); + + search_result_single.appendChild(content); + search_result_box.appendChild(search_result_single); + + id += data.results[i].blocks.length; + } + return search_result_box; +}; + +/** + * Removes .active class from all the suggestions. + */ +const removeAllActive = () => { + const results = document.querySelectorAll(".outer_div_page_results.active"); + const results_arr = Object.keys(results).map((i) => results[i]); + for (let i = 1; i <= results_arr.length; ++i) { + results_arr[i - 1].classList.remove("active"); + } +}; + +/** + * Add .active class to the search suggestion + * corresponding to `id`, and scroll to that suggestion smoothly. + * + * @param {Number} id of the suggestion to activate + */ +const addActive = (id) => { + const current_item = document.querySelector("#hit__" + id); + // in case of no results or any error, + // current_item will not be found in the DOM. + if (current_item !== null) { + current_item.classList.add("active"); + current_item.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "start", + }); + } +}; + +/* + * Select next/previous result. + * Go to the first result if already in the last result, + * or to the last result if already in the first result. + * + * @param {Boolean} forward. + */ +const selectNextResult = (forward) => { + const all = document.querySelectorAll(".outer_div_page_results"); + const current = document.querySelector(".outer_div_page_results.active"); + + let next_id = 1; + let last_id = 1; + + if (all.length > 0) { + let last = all[all.length - 1]; + if (last.id !== null) { + let match = last.id.match(/\d+/); + if (match !== null) { + last_id = Number(match[0]); + } + } + } + + if (current !== null && current.id !== null) { + let match = current.id.match(/\d+/); + if (match !== null) { + next_id = Number(match[0]); + next_id += forward ? 1 : -1; + } + } + + // Cycle to the first or last result. + if (next_id <= 0) { + next_id = last_id; + } else if (next_id > last_id) { + next_id = 1; + } + + removeAllActive(); + addActive(next_id); +}; + +/** + * Returns initial search input field, + * which is already present in the docs. + * + * @return {Object} Input field node + */ +const getInputField = () => { + let inputField; + + // on search some pages (like search.html), + // no div is present with role="search", + // in that case, use the other query to select + // the input field + try { + inputField = document.querySelector("[role='search'] input"); + if (inputField === undefined || inputField === null) { + throw "'[role='search'] input' not found"; + } + } catch (err) { + inputField = document.querySelector("input[name='q']"); + } + + return inputField; +}; + +/* + * Returns the current search term from the modal. + */ +const getSearchTerm = () => { + let search_outer_input = document.querySelector(".search__outer__input"); + if (search_outer_input !== null) { + return search_outer_input.value || ""; + } + return ""; +}; + +/** + * Removes all results from the search modal. + * It doesn't close the search box. + */ +const removeResults = () => { + let all_results = document.querySelectorAll(".search__result__box"); + for (let i = 0; i < all_results.length; ++i) { + all_results[i].parentElement.removeChild(all_results[i]); + } +}; + +/** + * Creates and returns a div with error message + * and some styles. + * + * @param {String} err_msg error message to be displayed + */ +const getErrorDiv = (err_msg) => { + let err_div = createDomNode("div", { + class: "search__result__box search__error__box", + }); + err_div.innerHTML = err_msg; + return err_div; +}; + +/** + * Fetch the suggestions from search backend, + * and appends the results to
node, + * which is already created when the page was loaded. + * + * @param {String} api_endpoint: API endpoint + * @param {Object} parameters: search parameters + * @param {String} projectName: name (slug) of the project + * @return {Function} debounced function with debounce time of 500ms + */ +const fetchAndGenerateResults = (api_endpoint, parameters, projectName) => { + let search_outer = document.querySelector(".search__outer"); + + // Removes all results (if there is any), + // and show the "Searching ...." text to + // the user. + removeResults(); + let search_loding = createDomNode("div", { class: "search__result__box" }); + search_loding.innerHTML = "Searching ...."; + search_outer.appendChild(search_loding); + + let fetchFunc = () => { + // Update URL just before fetching the results + // updateUrl(); + updateSearchBar(); + + const url = api_endpoint + "?" + new URLSearchParams(parameters).toString(); + + fetch(url, { method: "GET" }) + .then((response) => { + if (!response.ok) { + throw new Error(); + } + return response.json(); + }) + .then((data) => { + if (data.results.length > 0) { + let search_result_box = generateSuggestionsList(data, projectName); + removeResults(); + search_outer.appendChild(search_result_box); + + // remove active classes from all suggestions + // if the mouse hovers, otherwise styles from + // :hover and .active will clash. + search_outer.addEventListener("mouseenter", (e) => { + removeAllActive(); + }); + } else { + removeResults(); + let err_div = getErrorDiv("No results found"); + search_outer.appendChild(err_div); + } + }) + .catch((error) => { + removeResults(); + let err_div = getErrorDiv("There was an error. Please try again."); + search_outer.appendChild(err_div); + }); + }; + return debounce(fetchFunc, FETCH_RESULTS_DELAY); +}; + +/** + * Creates the initial html structure which will be + * appended to the as soon as the page loads. + * This html structure will serve as the boilerplate + * to show our search results. + * + * @param {Array} filters: filters to be applied to the search. + * {["Filter name", "Filter value"]} + * @return {String} initial html structure + */ +const generateAndReturnInitialHtml = (config) => { + const magnifier = icon(faMagnifyingGlass, { + title: "Magnifier", + classes: ["magnifier"], + }); + const xmark = icon(faCircleXmark, { + title: "Close", + classes: ["close"], + }); + const filters = config.features.search.filters; + + let innerHTML = ` +
+
+ + + +
+
+
    +
+
+
+ + `; + + let div = createDomNode("div", { + class: "search__outer__wrapper search__backdrop", + }); + div.innerHTML = innerHTML; + + let filters_list = div.querySelector(".search__filters ul"); + // Add filters below the search box if present. + if (filters.length > 0) { + let li = createDomNode("li", { class: "search__filters__title" }); + li.innerText = "Filters:"; + filters_list.appendChild(li); + } + // Each checkbox contains the index of the filter, + // so we can get the proper filter when selected. + for (let i = 0, len = filters.length; i < len; i++) { + const [name, filter] = filters[i]; + let li = createDomNode("li", { class: "search__filter", title: filter }); + let id = `rtd-search-filter-${i}`; + let checkbox = createDomNode("input", { type: "checkbox", id: id }); + let label = createDomNode("label", { for: id }); + label.innerText = name; + checkbox.value = i; + li.appendChild(checkbox); + li.appendChild(label); + filters_list.appendChild(li); + + checkbox.addEventListener("click", (event) => { + // Uncheck all other filters when one is checked. + // We only support one filter at a time. + const checkboxes = document.querySelectorAll(".search__filters input"); + for (const checkbox of checkboxes) { + if (checkbox.checked && checkbox.value != event.target.value) { + checkbox.checked = false; + } + } + + // Trigger a search with the current selected filter. + let search_query = getSearchTerm(); + const filter = getCurrentFilter(config); + search_query = filter + " " + search_query; + const search_params = { + q: search_query, + }; + fetchAndGenerateResults( + config.features.search.api_endpoint, + search_params, + config.features.search.project + )(); + }); + } + return div; +}; + +/** + * Opens the search modal. + * + * @param {String} custom_query if a custom query is provided, + * initialize the value of input field with it, or fallback to the + * value from the original search bar. + */ +const showSearchModal = (custom_query) => { + // removes previous results (if there are any). + removeResults(); + + let show_modal = function () { + // removes the focus from the initial input field + // which as already present in the docs. + let search_bar = getInputField(); + search_bar.blur(); + + // sets the value of the input field to empty string and focus it. + let search_outer_input = document.querySelector(".search__outer__input"); + if (search_outer_input !== null) { + if (typeof custom_query !== "undefined" && _is_string(custom_query)) { + search_outer_input.value = custom_query; + search_bar.value = custom_query; + } else { + search_outer_input.value = search_bar.value; + } + search_outer_input.focus(); + } + }; + + let element = document.querySelector(".search__outer__wrapper"); + if (element && element.style) { + element.style.display = "block"; + } + show_modal(); +}; + +/** + * Closes the search modal. + */ +const removeSearchModal = () => { + // removes previous results before closing + removeResults(); + + updateSearchBar(); + + // sets the value of input field to empty string and remove the focus. + let search_outer_input = document.querySelector(".search__outer__input"); + if (search_outer_input !== null) { + search_outer_input.value = ""; + search_outer_input.blur(); + } + + // update url (remove 'rtd_search' param) + // updateUrl(); + + let element = document.querySelector(".search__outer__wrapper"); + if (element && element.style) { + element.style.display = "none"; + } +}; + +/** + * Get the current selected filter. + * + * If no filter checkbox is selected, the default filter is returned. + * + * @param {Object} config + */ +function getCurrentFilter(config) { + const checkbox = document.querySelector(".search__filters input:checked"); + if (checkbox == null) { + return config.features.search.default_filter; + } + return config.features.search.filters[parseInt(checkbox.value)][1]; +} + +export function initializeSearchAsYouType(config) { + document.adoptedStyleSheets.push(styles); + library.add(faMagnifyingGlass); + library.add(faCircleXmark); + domReady.then(() => { + let initialHtml = generateAndReturnInitialHtml(config); + document.body.appendChild(initialHtml); + eventListeners(config); + }); +} + +function eventListeners(config) { + // TODO: make these selectors to use IDs + let search_outer_wrapper = document.querySelector(".search__outer__wrapper"); + let search_outer_input = document.querySelector(".search__outer__input"); + // let cross_icon = document.querySelector(".search__cross"); + + // this stores the current request. + let current_request = null; + + // TODO: revisit this use case. Why is this important? + // // if "rtd_search" is present in URL parameters, + // // then open the search modal and show the results + // // for the value of "rtd_search" + // const url_params = new URLSearchParams(document.location.search); + // const query = url_params.get(RTD_SEARCH_PARAMETER); + // if (query !== null) { + // showSearchModal(query); + // search_outer_input.value = query; + + // let event = document.createEvent("Event"); + // event.initEvent("input", true, true); + // search_outer_input.dispatchEvent(event); + // } + + search_outer_input.addEventListener("input", (e) => { + let search_query = getSearchTerm(); + if (search_query.length > 0) { + if (current_request !== null) { + // cancel previous ajax request. + current_request.cancel(); + } + const filter = getCurrentFilter(config); + search_query = filter + " " + search_query; + const search_params = { + q: search_query, + }; + current_request = fetchAndGenerateResults( + config.features.search.api_endpoint, + search_params, + config.features.search.project + ); + current_request(); + } else { + // if the last request returns the results, + // the suggestions list is generated even if there + // is no query. To prevent that, this function + // is debounced here. + let func = () => { + removeResults(); + // updateUrl(); + }; + debounce(func, CLEAR_RESULTS_DELAY)(); + // updateUrl(); + } + }); + + search_outer_input.addEventListener("keydown", (e) => { + // if "ArrowDown is pressed" + if (e.keyCode === 40) { + e.preventDefault(); + selectNextResult(true); + } + + // if "ArrowUp" is pressed. + if (e.keyCode === 38) { + e.preventDefault(); + selectNextResult(false); + } + + // if "Enter" key is pressed. + if (e.keyCode === 13) { + e.preventDefault(); + const current_item = document.querySelector( + ".outer_div_page_results.active" + ); + // if an item is selected, + // then redirect to its link + if (current_item !== null) { + const link = current_item.parentElement["href"]; + window.location.href = link; + } else { + // submit search form if there + // is no active item. + const input_field = getInputField(); + const form = input_field.parentElement; + + search_bar.value = getSearchTerm(); + form.submit(); + } + } + }); + + search_outer_wrapper.addEventListener("click", (e) => { + // HACK: only close the search modal if the + // element clicked has as the parent Node. + // This is done so that search modal only gets closed + // if the user clicks on the backdrop area. + if (e.target.parentNode === document.body) { + removeSearchModal(); + } + }); + + // close the search modal if clicked on cross icon. + // cross_icon.addEventListener("click", (e) => { + // removeSearchModal(); + // }); + + // close the search modal if the user pressed + // Escape button + document.addEventListener("keydown", (e) => { + if (e.keyCode === 27) { + removeSearchModal(); + } + }); + + // open search modal if "forward slash" button is pressed + document.addEventListener("keydown", (e) => { + if (e.keyCode === 191 && !isModalVisible()) { + // prevent opening "Quick Find" in Firefox + e.preventDefault(); + showSearchModal(); + } + }); +} diff --git a/webpack.config.js b/webpack.config.js index ce41491b..4b63629f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -62,6 +62,10 @@ module.exports = (env, argv) => { }, }, }, + { + test: /\.svg/, + type: "asset/inline", + }, ], }, plugins: [],