|
| 1 | +/*! |
| 2 | + * sphinx-searchlite — the bundled dialog. |
| 3 | + * |
| 4 | + * Optional: set `searchlite_ui = False` and drive `window.SearchLite` yourself |
| 5 | + * if your theme ships its own search UI. The markup is created at runtime so |
| 6 | + * this works with any theme without template overrides. |
| 7 | + */ |
| 8 | + |
| 9 | +(function () { |
| 10 | + "use strict"; |
| 11 | + |
| 12 | + if (!window.SearchLite || !window.SearchLite.indexUrl) return; |
| 13 | + |
| 14 | + // Captured while the script is executing; `currentScript` is null later on. |
| 15 | + var uiScript = document.currentScript || document.querySelector("script[data-searchlite-adopt]"); |
| 16 | + var adoptThemeSearch = !uiScript || uiScript.getAttribute("data-searchlite-adopt") !== "false"; |
| 17 | + |
| 18 | + var engine = window.SearchLite.create({ url: window.SearchLite.indexUrl }); |
| 19 | + var selected = 0; |
| 20 | + |
| 21 | + var dialog = document.createElement("dialog"); |
| 22 | + dialog.id = "searchlite-dialog"; |
| 23 | + dialog.innerHTML = |
| 24 | + '<form class="searchlite-panel" method="dialog" role="search">' + |
| 25 | + '<div class="searchlite-field">' + |
| 26 | + '<input type="search" id="searchlite-input" autocomplete="off" spellcheck="false" placeholder="Search documentation…" aria-label="Search" />' + |
| 27 | + '<kbd class="searchlite-kbd">Esc</kbd>' + |
| 28 | + "</div>" + |
| 29 | + '<div id="searchlite-results" role="listbox" aria-label="Search results"></div>' + |
| 30 | + '<p id="searchlite-empty" class="searchlite-empty" hidden>No results found.</p>' + |
| 31 | + "</form>"; |
| 32 | + document.body.appendChild(dialog); |
| 33 | + |
| 34 | + var input = dialog.querySelector("#searchlite-input"); |
| 35 | + var results = dialog.querySelector("#searchlite-results"); |
| 36 | + var empty = dialog.querySelector("#searchlite-empty"); |
| 37 | + |
| 38 | + /* Colour adoption ------------------------------------------------------ */ |
| 39 | + |
| 40 | + // Themes signal dark mode with their own class or attribute, not |
| 41 | + // `prefers-color-scheme`, so read the page's actual colours instead. The |
| 42 | + // computed value is assigned verbatim: it may be `color(display-p3 ...)` or |
| 43 | + // any other modern syntax, and parsing it would lose that. |
| 44 | + function backdropColour() { |
| 45 | + var node = document.body; |
| 46 | + while (node) { |
| 47 | + var colour = getComputedStyle(node).backgroundColor; |
| 48 | + if (colour && colour !== "transparent" && colour.indexOf("rgba(0, 0, 0, 0") !== 0) return colour; |
| 49 | + node = node.parentElement; |
| 50 | + } |
| 51 | + return null; |
| 52 | + } |
| 53 | + |
| 54 | + function adoptColours() { |
| 55 | + var background = backdropColour(); |
| 56 | + var foreground = getComputedStyle(document.body).color; |
| 57 | + if (background) dialog.style.setProperty("--searchlite-background", background); |
| 58 | + if (foreground) dialog.style.setProperty("--searchlite-foreground", foreground); |
| 59 | + } |
| 60 | + |
| 61 | + // Theme toggles mutate a class or attribute on <html> or <body>. |
| 62 | + var themeWatcher = new MutationObserver(adoptColours); |
| 63 | + [document.documentElement, document.body].forEach(function (node) { |
| 64 | + themeWatcher.observe(node, { attributes: true, attributeFilter: ["class", "data-theme", "data-mode", "style"] }); |
| 65 | + }); |
| 66 | + adoptColours(); |
| 67 | + |
| 68 | + function render(found) { |
| 69 | + results.replaceChildren(); |
| 70 | + found.items.forEach(function (record, position) { |
| 71 | + var link = document.createElement("a"); |
| 72 | + link.className = "searchlite-result"; |
| 73 | + link.href = record.u; |
| 74 | + link.setAttribute("role", "option"); |
| 75 | + link.setAttribute("aria-selected", String(position === selected)); |
| 76 | + |
| 77 | + var title = document.createElement("span"); |
| 78 | + title.className = "searchlite-result-title"; |
| 79 | + title.appendChild(window.SearchLite.highlight(record.s || record.t, found.terms)); |
| 80 | + |
| 81 | + var context = document.createElement("span"); |
| 82 | + context.className = "searchlite-result-context"; |
| 83 | + var summary = window.SearchLite.excerpt(record, found.terms); |
| 84 | + context.textContent = record.s ? record.t + " \u2014 " + summary : summary; |
| 85 | + |
| 86 | + link.append(title, context); |
| 87 | + results.appendChild(link); |
| 88 | + }); |
| 89 | + empty.hidden = found.items.length > 0; |
| 90 | + } |
| 91 | + |
| 92 | + function update() { |
| 93 | + if (!input.value.trim()) { |
| 94 | + results.replaceChildren(); |
| 95 | + empty.hidden = true; |
| 96 | + return; |
| 97 | + } |
| 98 | + selected = 0; |
| 99 | + render(engine.search(input.value)); |
| 100 | + } |
| 101 | + |
| 102 | + function move(delta) { |
| 103 | + var options = results.querySelectorAll(".searchlite-result"); |
| 104 | + if (!options.length) return; |
| 105 | + selected = (selected + delta + options.length) % options.length; |
| 106 | + options.forEach(function (option, position) { |
| 107 | + option.setAttribute("aria-selected", String(position === selected)); |
| 108 | + }); |
| 109 | + options[selected].scrollIntoView({ block: "nearest" }); |
| 110 | + } |
| 111 | + |
| 112 | + function open() { |
| 113 | + if (dialog.open) return; |
| 114 | + adoptColours(); |
| 115 | + dialog.showModal(); |
| 116 | + engine.load().then(update); |
| 117 | + input.focus(); |
| 118 | + input.select(); |
| 119 | + } |
| 120 | + |
| 121 | + document.querySelectorAll("[data-searchlite-open]").forEach(function (trigger) { |
| 122 | + trigger.addEventListener("click", open); |
| 123 | + }); |
| 124 | + |
| 125 | + /* Adopting the theme's own search box ---------------------------------- */ |
| 126 | + |
| 127 | + // Without this the dialog has no visible entry point on a theme that knows |
| 128 | + // nothing about it, and the theme's box still leads to Sphinx's separate |
| 129 | + // search page — two searches with different results. |
| 130 | + function adoptSearchFields() { |
| 131 | + if (!adoptThemeSearch) return; |
| 132 | + var selectors = [ |
| 133 | + 'input[name="q"]', |
| 134 | + 'form[action$="search.html"] input', |
| 135 | + 'form[role="search"] input', |
| 136 | + "input[type=search]", |
| 137 | + ]; |
| 138 | + var seen = new Set(); |
| 139 | + selectors.forEach(function (selector) { |
| 140 | + document.querySelectorAll(selector).forEach(function (field) { |
| 141 | + if (field === input || seen.has(field)) return; |
| 142 | + seen.add(field); |
| 143 | + field.classList.add("searchlite-adopted"); |
| 144 | + field.readOnly = true; |
| 145 | + field.addEventListener("focus", open); |
| 146 | + field.addEventListener("click", open); |
| 147 | + var form = field.closest("form"); |
| 148 | + if (form) { |
| 149 | + form.addEventListener("submit", function (event) { |
| 150 | + event.preventDefault(); |
| 151 | + open(); |
| 152 | + }); |
| 153 | + } |
| 154 | + }); |
| 155 | + }); |
| 156 | + } |
| 157 | + |
| 158 | + adoptSearchFields(); |
| 159 | + |
| 160 | + input.addEventListener("input", function () { |
| 161 | + engine.load().then(update); |
| 162 | + }); |
| 163 | + |
| 164 | + dialog.addEventListener("keydown", function (event) { |
| 165 | + if (event.key === "ArrowDown") { |
| 166 | + event.preventDefault(); |
| 167 | + move(1); |
| 168 | + } else if (event.key === "ArrowUp") { |
| 169 | + event.preventDefault(); |
| 170 | + move(-1); |
| 171 | + } else if (event.key === "Enter") { |
| 172 | + var active = results.querySelector('.searchlite-result[aria-selected="true"]'); |
| 173 | + if (active) { |
| 174 | + event.preventDefault(); |
| 175 | + window.location.href = active.href; |
| 176 | + } |
| 177 | + } |
| 178 | + }); |
| 179 | + |
| 180 | + dialog.addEventListener("click", function (event) { |
| 181 | + if (event.target === dialog) dialog.close(); |
| 182 | + }); |
| 183 | + |
| 184 | + document.addEventListener("keydown", function (event) { |
| 185 | + var target = event.target; |
| 186 | + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; |
| 187 | + if (event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k")) { |
| 188 | + event.preventDefault(); |
| 189 | + open(); |
| 190 | + } |
| 191 | + }); |
| 192 | +})(); |
0 commit comments