From 9eb6e9ccbb99ac569d399372bcd6cc89dcb15110 Mon Sep 17 00:00:00 2001 From: Zaine Date: Wed, 13 May 2026 23:14:04 +0100 Subject: [PATCH] search improvements --- assets/scripts/search.js | 415 +++++++++++++++++++++++++-------------- assets/styles/misc.css | 257 ++++++++++++++++++------ 2 files changed, 470 insertions(+), 202 deletions(-) diff --git a/assets/scripts/search.js b/assets/scripts/search.js index cad145d..f45c851 100755 --- a/assets/scripts/search.js +++ b/assets/scripts/search.js @@ -1,27 +1,47 @@ let lunrIndex; let documents = []; let activeIndex = -1; +let currentResults = []; +let isSyncingHeaderInput = false; +let suppressHeaderFocus = false; +let searchReady = false; + const searchInput = document.getElementById("search-input"); const searchBtn = document.getElementById("search-btn"); -const resultsBox = document.createElement("div"); -const searchForm = searchInput.closest("form"); +const searchForm = searchInput?.closest("form"); + const modal = document.createElement("div"); -const modalResults = modal.querySelector("#search-modal-results"); - -resultsBox.id = "search-results"; -document.body.appendChild(resultsBox); - modal.id = "search-modal"; +modal.setAttribute("aria-hidden", "true"); modal.innerHTML = ` -
-
-

Search results

-
-
+
+ `; - document.body.appendChild(modal); +const pane = modal.querySelector(".search-modal-content"); +const paneInput = modal.querySelector("#search-pane-input"); +const modalResults = modal.querySelector("#search-modal-results"); +const paneCount = modal.querySelector("#search-pane-count"); + function extractDocuments(node, currentPath = "") { if (node.type === "folder" && node.children) { const nextPath = currentPath ? `${currentPath}/${node.name}` : node.name; @@ -53,11 +73,18 @@ function buildIndex() { } async function initSearch() { - const response = await fetch("/test.json"); - const json = await response.json(); + try { + const response = await fetch("/test.json"); + const json = await response.json(); - extractDocuments(json); - buildIndex(); + extractDocuments(json); + buildIndex(); + searchReady = true; + if (isSearchOpen()) renderResults(paneInput.value); + } catch (error) { + console.warn("Search index could not be loaded", error); + if (paneCount) paneCount.textContent = "Search is unavailable right now."; + } } initSearch(); @@ -71,7 +98,7 @@ function buildExactQuery(query) { .join(" "); } -function buildSnippet(content, terms, radius = 80) { +function buildSnippet(content, terms, radius = 90) { if (!content || terms.length === 0) return ""; const lower = content.toLowerCase(); @@ -107,14 +134,23 @@ function getSearchTerms(query) { .filter((t) => t.length > 2); } +function escapeHtml(text) { + return String(text || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + function highlight(text, terms) { - if (!text || terms.length === 0) return text; + const escapedText = escapeHtml(text); + if (!escapedText || terms.length === 0) return escapedText; - const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + const escapedTerms = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + const regex = new RegExp(`(${escapedTerms.join("|")})`, "gi"); - const regex = new RegExp(`(${escaped.join("|")})`, "gi"); - - return text.replace(regex, "$1"); + return escapedText.replace(regex, "$1"); } function search(query) { @@ -143,187 +179,272 @@ function search(query) { .filter(Boolean); } -function showResults(results) { - resultsBox.innerHTML = ""; - activeIndex = -1; +function setHeaderSearchValue(value, dispatchInput = false) { + if (!searchInput) return; - if (results.length === 0) { - resultsBox.style.display = "none"; - return; + isSyncingHeaderInput = true; + searchInput.value = value; + isSyncingHeaderInput = false; + + if (dispatchInput) { + searchInput.dispatchEvent(new Event("input", { bubbles: true })); } +} - const rect = searchInput.getBoundingClientRect(); - resultsBox.style.top = `${rect.bottom + window.scrollY}px`; - resultsBox.style.left = `${rect.left + window.scrollX}px`; - resultsBox.style.width = `${rect.width}px`; +function isSearchOpen() { + return modal.classList.contains("is-open"); +} - results.forEach((result, i) => { - const item = document.createElement("div"); - item.className = "search-result"; - item.dataset.index = i; +function openSearchPane(initialValue = "") { + const value = initialValue || searchInput?.value || paneInput?.value || ""; - item.innerHTML = ` -
${result.title}
-
${result.preview}
- `; + modal.classList.add("is-open"); + modal.setAttribute("aria-hidden", "false"); + document.documentElement.classList.add("search-open"); - item.onclick = () => { - window.location.href = result.path; - }; + paneInput.value = value; + setHeaderSearchValue(value); + renderResults(value); - resultsBox.appendChild(item); - }); + window.setTimeout(() => { + paneInput.focus(); + paneInput.setSelectionRange(paneInput.value.length, paneInput.value.length); + }, 0); +} - resultsBox.style.display = "block"; +function closeSearchPane() { + modal.classList.remove("is-open"); + modal.setAttribute("aria-hidden", "true"); + document.documentElement.classList.remove("search-open"); + activeIndex = -1; + suppressHeaderFocus = true; + searchInput?.focus(); + window.setTimeout(() => { + suppressHeaderFocus = false; + }, 0); +} + +function getResultItems() { + return Array.from(modalResults.querySelectorAll(".search-modal-result")); } function updateActiveResult() { - const items = resultsBox.querySelectorAll(".search-result"); + const items = getResultItems(); - items.forEach((item) => item.classList.remove("active")); + items.forEach((item, index) => { + const isActive = index === activeIndex; + item.classList.toggle("active", isActive); + item.setAttribute("aria-selected", String(isActive)); + }); if (activeIndex >= 0 && activeIndex < items.length) { - const activeItem = items[activeIndex]; - - activeItem.classList.add("active"); - - activeItem.scrollIntoView({ + items[activeIndex].scrollIntoView({ block: "nearest", behavior: "smooth", }); } } -function openSearchModal(results) { - const modal = document.getElementById("search-modal"); - const modalResults = modal?.querySelector("#search-modal-results"); - modal.tabIndex = -1; - - if (!modal || !modalResults) { - console.warn("Search modal not available"); +function setActiveResult(index) { + const items = getResultItems(); + if (!items.length) { + activeIndex = -1; return; } + activeIndex = Math.max(0, Math.min(index, items.length - 1)); + updateActiveResult(); +} + +function openActiveResult() { + const items = getResultItems(); + if (!items.length) return; + + const index = activeIndex >= 0 ? activeIndex : 0; + items[index]?.click(); +} + +function renderResults(query) { + const trimmed = query.trim(); + currentResults = trimmed.length >= 2 ? search(trimmed) : []; + activeIndex = currentResults.length ? 0 : -1; modalResults.innerHTML = ""; - if (results.length === 0) { - modalResults.innerHTML = "

No results found.

"; + if (!trimmed) { + paneCount.textContent = "Start typing to search your notes."; + modalResults.innerHTML = ` +
+ Try a topic, page title, or phrase from a note. +
+ `; + return; } - results.forEach((result) => { - const item = document.createElement("div"); - item.className = "search-modal-result"; - item.innerHTML = ` -
${result.title}
-
${result.preview}
+ if (trimmed.length < 2) { + paneCount.textContent = "Keep typing."; + modalResults.innerHTML = ` +
+ Use at least two characters. +
`; - item.onclick = () => (window.location.href = result.path); + return; + } + + if (!searchReady) { + paneCount.textContent = "Loading search index."; + modalResults.innerHTML = ` +
+ Loading results... +
+ `; + return; + } + + if (!currentResults.length) { + paneCount.textContent = "No results found."; + modalResults.innerHTML = ` +
+ No matches for "${escapeHtml(trimmed)}". +
+ `; + return; + } + + paneCount.textContent = `${currentResults.length} result${currentResults.length === 1 ? "" : "s"}`; + + currentResults.slice(0, 20).forEach((result, i) => { + const item = document.createElement("a"); + item.className = "search-modal-result"; + item.href = result.path; + item.dataset.index = i; + item.setAttribute("role", "option"); + item.setAttribute("aria-selected", String(i === activeIndex)); + + item.innerHTML = ` + ${result.title} + ${result.preview} + ${escapeHtml(result.path)} + `; + + item.addEventListener("mouseenter", () => setActiveResult(i)); modalResults.appendChild(item); }); - modal.style.display = "block"; - modal.focus(); + updateActiveResult(); } -function submitSearch() { - const q = searchInput.value.trim(); - if (!q) return; - openSearchModal(search(q)); -} - -/* EVENTS */ -searchInput.addEventListener("keydown", (e) => { - const items = resultsBox.querySelectorAll(".search-result"); - if (!items.length) return; +function handleSearchKeydown(e) { + const items = getResultItems(); switch (e.key) { case "ArrowDown": e.preventDefault(); - if (activeIndex < items.length - 1) { - activeIndex++; - updateActiveResult(); - } + if (items.length) setActiveResult(activeIndex + 1); break; case "ArrowUp": e.preventDefault(); - if (activeIndex > 0) { - activeIndex--; - updateActiveResult(); + if (items.length) setActiveResult(activeIndex <= 0 ? items.length - 1 : activeIndex - 1); + break; + + case "Home": + if (items.length) { + e.preventDefault(); + setActiveResult(0); + } + break; + + case "End": + if (items.length) { + e.preventDefault(); + setActiveResult(items.length - 1); } break; case "Enter": e.preventDefault(); - if (activeIndex >= 0 && activeIndex < items.length) { - items[activeIndex].click(); - } else { - submitSearch(); - } + openActiveResult(); break; case "Escape": - resultsBox.style.display = "none"; - activeIndex = -1; + e.preventDefault(); + closeSearchPane(); break; } -}); +} -document.addEventListener("click", (e) => { - if ( - !resultsBox.contains(e.target) && - e.target !== searchInput && - e.target !== searchBtn - ) { - resultsBox.style.display = "none"; - } -}); +function shouldUseSlashShortcut(event) { + const target = event.target; + const isEditable = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target?.isContentEditable; -modal.addEventListener("click", (e) => { - if (e.target.classList.contains("search-modal-backdrop")) { - modal.style.display = "none"; - } -}); -modal.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - modal.style.display = "none"; - searchInput.focus(); - } -}); + return event.key === "/" && !isEditable && !event.metaKey && !event.ctrlKey && !event.altKey; +} -window.addEventListener("pageshow", () => { - searchInput.value = ""; - resultsBox.style.display = "none"; -}); -window.addEventListener("beforeunload", () => { - searchInput.value = ""; -}); - -if (searchForm) { - searchForm.addEventListener("submit", (e) => { - e.preventDefault(); - submitSearch(); +if (searchInput && searchBtn && paneInput && modalResults && paneCount) { + searchInput.addEventListener("focus", () => { + if (!suppressHeaderFocus) openSearchPane(searchInput.value); }); -} + searchInput.addEventListener("click", () => openSearchPane(searchInput.value)); -searchBtn.addEventListener("click", submitSearch); + searchInput.addEventListener("input", () => { + if (isSyncingHeaderInput) return; + if (!isSearchOpen()) openSearchPane(searchInput.value); + paneInput.value = searchInput.value; + renderResults(searchInput.value); + }); -searchInput.addEventListener("input", () => { - const q = searchInput.value.trim(); + paneInput.addEventListener("input", () => { + setHeaderSearchValue(paneInput.value, true); + renderResults(paneInput.value); + }); - if (q.length < 3) { - resultsBox.style.display = "none"; - return; + paneInput.addEventListener("keydown", handleSearchKeydown); + modalResults.addEventListener("keydown", handleSearchKeydown); + + searchBtn.addEventListener("click", () => openSearchPane(searchInput.value)); + + modal.addEventListener("click", (e) => { + if (e.target.hasAttribute("data-search-close")) { + closeSearchPane(); + } + }); + + pane.addEventListener("click", (e) => e.stopPropagation()); + + document.addEventListener("keydown", (e) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + openSearchPane(searchInput.value); + return; + } + + if (shouldUseSlashShortcut(e)) { + e.preventDefault(); + openSearchPane(""); + } + + if (e.key === "Escape" && isSearchOpen()) { + closeSearchPane(); + } + }); + + window.addEventListener("pageshow", () => { + searchInput.value = ""; + paneInput.value = ""; + renderResults(""); + }); + + window.addEventListener("beforeunload", () => { + searchInput.value = ""; + }); + + if (searchForm) { + searchForm.addEventListener("submit", (e) => { + e.preventDefault(); + openSearchPane(searchInput.value); + }); } - showResults(search(q)); -}); - -/* OLD FUNCTIONS */ -function buildFuzzyQuery(query) { - return query - .toLowerCase() - .split(/\s+/) - .filter((term) => term.length > 2) - .map((term) => `${term}~1`) - .join(" "); } diff --git a/assets/styles/misc.css b/assets/styles/misc.css index 4d1e050..cfb7218 100755 --- a/assets/styles/misc.css +++ b/assets/styles/misc.css @@ -250,7 +250,6 @@ time.countdown.expired { color: var(--fg); } -/* Button styling */ #search-btn { display: inline-grid; place-items: center; @@ -266,13 +265,13 @@ time.countdown.expired { line-height: 1; } -/* Focus state */ #search-input:focus, #search-btn:focus-visible { outline: 0; border-color: color-mix(in oklab, var(--accent) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 16%, transparent); } + @media (max-width: 700px) { #search-input { display: none; @@ -282,98 +281,246 @@ time.countdown.expired { font-size: 1rem; } } -#search-results { - position: absolute; - max-height: 60vh; - overflow-y: auto; - background: var(--bg-color, #fff); - border: 1px solid var(--border-color, #ccc); - border-radius: 6px; - box-shadow: 0 8px 20px rgba(0,0,0,0.12); - display: none; - z-index: 9999; -} -.search-result { - padding: 0.6rem 0.8rem; - cursor: pointer; - border-bottom: 1px solid #eee; -} -.search-result:hover { - background: var(--accent-bg, #f2f6ff); -} - -.search-result-title { - font-weight: 600; - font-size: 0.9rem; -} - -.search-result-preview { - font-size: 0.75rem; - color: #666; -} #search-modal { position: fixed; inset: 0; - display: none; + display: grid; + place-items: start center; + padding: clamp(1rem, 7vh, 4.5rem) 1rem 1rem; + opacity: 0; + pointer-events: none; + visibility: hidden; z-index: 10000; + transition: opacity 150ms ease, visibility 150ms ease; +} + +#search-modal.is-open { + opacity: 1; + pointer-events: auto; + visibility: visible; +} + +.search-open { + overflow: hidden; } .search-modal-backdrop { position: absolute; inset: 0; - background: rgba(0,0,0,0.45); + background: + color-mix(in oklab, var(--bg) 34%, transparent); + backdrop-filter: blur(10px); } .search-modal-content { position: relative; - max-width: 700px; - margin: 10vh auto; - padding: 1.2rem; - background: var(--bg-color, #fff); - border-radius: 10px; - box-shadow: 0 20px 60px rgba(0,0,0,0.25); - max-height: 70vh; + width: min(720px, 100%); + max-height: min(74vh, 780px); + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + overflow: hidden; + background: color-mix(in oklab, var(--surface) 94%, var(--bg)); + border: 1px solid color-mix(in oklab, var(--border) 78%, var(--fg)); + border-radius: 8px; + box-shadow: + 0 24px 80px rgba(0, 0, 0, 0.28), + 0 2px 12px rgba(0, 0, 0, 0.12); +} + +.search-pane-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.6rem; + padding: 0.8rem; + border-bottom: 1px solid var(--border); +} + +.search-pane-field { + display: grid; + grid-template-columns: 1.9rem minmax(0, 1fr); + align-items: center; + min-width: 0; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 7px; + color: var(--muted); +} + +.search-pane-field:focus-within { + border-color: color-mix(in oklab, var(--accent) 58%, var(--border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 16%, transparent); +} + +.search-pane-icon { + display: grid; + place-items: center; + min-height: 2.7rem; + font-size: 1.1rem; +} + +#search-pane-input { + width: 100%; + min-width: 0; + min-height: 2.7rem; + padding: 0.45rem 0.75rem 0.45rem 0; + border: 0; + outline: 0; + background: transparent; + color: var(--fg); + font: 500 1rem/1.4 var(--font-body); +} + +#search-pane-input::placeholder { + color: var(--muted); +} + +.search-pane-close { + display: inline-grid; + place-items: center; + width: 2.7rem; + height: 2.7rem; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface-soft); + color: var(--fg); + cursor: pointer; + font-size: 1.45rem; + line-height: 1; +} + +.search-pane-close:hover, +.search-pane-close:focus-visible { + outline: 0; + border-color: color-mix(in oklab, var(--accent) 58%, var(--border)); + background: var(--accent-bg); +} + +.search-pane-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.7rem 1rem 0.55rem; + border-bottom: 1px solid color-mix(in oklab, var(--border) 72%, transparent); +} + +.search-pane-meta h2 { + margin: 0; + color: var(--fg); + font-size: 0.95rem; + line-height: 1.2; +} + +#search-pane-count { + color: var(--muted); + font-size: 0.78rem; + line-height: 1.35; +} + +.search-pane-results { + min-height: 10rem; overflow-y: auto; + overscroll-behavior: contain; + padding: 0.45rem; } .search-modal-result { - padding: 0.8rem; - border-bottom: 1px solid #eee; + display: grid; + gap: 0.26rem; + padding: 0.7rem 0.8rem; + border: 1px solid transparent; + border-radius: 7px; + color: inherit; cursor: pointer; + text-decoration: none; } -.search-modal-result:hover { - background: var(--accent-bg, #f2f6ff); +.search-modal-result:hover, +.search-modal-result.active { + background: var(--accent-bg); + border-color: color-mix(in oklab, var(--accent) 28%, var(--border)); + text-decoration: none; } .search-modal-title { + color: var(--fg); font-weight: 600; + font-size: 0.95rem; + line-height: 1.25; } .search-modal-preview { - font-size: 0.85rem; - color: #666; + color: var(--muted); + font-size: 0.82rem; + line-height: 1.35; } + +.search-modal-path { + color: color-mix(in oklab, var(--muted) 82%, var(--accent)); + font-size: 0.72rem; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-empty { + display: grid; + min-height: 9rem; + place-items: center; + padding: 1rem; + color: var(--muted); + text-align: center; + font-size: 0.9rem; +} + +.search-pane-help { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + padding: 0.65rem 0.8rem; + border-top: 1px solid var(--border); + color: var(--muted); + background: color-mix(in oklab, var(--surface-soft) 82%, transparent); + font-size: 0.72rem; +} + +.search-pane-help span { + padding: 0.22rem 0.45rem; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--surface); +} + mark { background: rgba(255, 230, 150, 0.8); color: inherit; padding: 0 0.15em; border-radius: 3px; } -.search-result { - cursor: pointer; - padding: 0.5em 0.75em; -} -.search-result.active { - background: rgba(0, 0, 0, 0.08); -} - -.search-result.active mark { +.search-modal-result.active mark { background: rgba(255, 230, 150, 0.9); } +@media (max-width: 560px) { + #search-modal { + place-items: start stretch; + padding: 0.75rem; + } + + .search-modal-content { + max-height: calc(100vh - 1.5rem); + } + + .search-pane-meta { + align-items: flex-start; + flex-direction: column; + gap: 0.2rem; + } +} /* Table of contents */ #table-of-contents{