site: implement pagefind component ui for search modal

- Add pagefind-component-ui.css and .js assets
- Replace search bar with custom button that opens modal
- Add custom result template with Tailwind classes
- Add dark mode styling for modal and result highlights
- Support Cmd/Ctrl+K keyboard shortcut

Assisted-By: cagent
This commit is contained in:
David Karlsson
2026-02-05 17:56:59 +00:00
parent 0150180a43
commit f929b8f1ed
5 changed files with 124 additions and 191 deletions
+1
View File
@@ -10,6 +10,7 @@ IgnoreURLs:
- "^/reference/api/hub/.*$" - "^/reference/api/hub/.*$"
- "^/reference/api/engine/v.+/#.*$" - "^/reference/api/engine/v.+/#.*$"
- "^/reference/api/registry/.*$" - "^/reference/api/registry/.*$"
- "^/pagefind/.*$"
IgnoreDirs: IgnoreDirs:
- "registry/configuration" - "registry/configuration"
- "compose/compose-file" # temporarily ignore until upstream is fixed - "compose/compose-file" # temporarily ignore until upstream is fixed
+25
View File
@@ -0,0 +1,25 @@
/* Pagefind Component UI Customizations */
/* Dark mode variables for modal */
.dark pagefind-modal {
--pf-text: var(--color-gray-100);
--pf-text-secondary: var(--color-gray-300);
--pf-text-muted: var(--color-gray-400);
--pf-background: var(--color-gray-900);
--pf-border: var(--color-gray-700);
--pf-border-focus: var(--color-blue-400);
--pf-hover: var(--color-gray-800);
}
/* Highlight marks in results */
pagefind-results mark {
background-color: var(--color-yellow-200);
color: inherit;
padding: 0 0.125rem;
border-radius: 0.125rem;
}
.dark pagefind-results mark {
background-color: rgba(255, 204, 72, 0.3);
color: white;
}
+1
View File
@@ -38,6 +38,7 @@
@import "global.css"; @import "global.css";
} }
@import "utilities.css"; @import "utilities.css";
@import "pagefind.css";
@import "syntax-dark.css"; @import "syntax-dark.css";
@import "syntax-light.css"; @import "syntax-light.css";
@import "components.css"; @import "components.css";
+1
View File
@@ -5,6 +5,7 @@
<link rel="{{ .Rel }}" type="{{ .MediaType.Type }}" href="{{ .Permalink }}" /> <link rel="{{ .Rel }}" type="{{ .MediaType.Type }}" href="{{ .Permalink }}" />
{{ end -}} {{ end -}}
{{ partial "utils/css.html" "-" }} {{ partial "utils/css.html" "-" }}
<link href="/pagefind/pagefind-component-ui.css" rel="stylesheet">
{{- if hugo.IsProduction -}} {{- if hugo.IsProduction -}}
<script <script
src="https://cdn.cookielaw.org/scripttemplates/otSDKStub.js" src="https://cdn.cookielaw.org/scripttemplates/otSDKStub.js"
+96 -191
View File
@@ -1,203 +1,108 @@
<div <div id="search-bar" class="h-full relative flex items-center overflow-visible">
x-ref="searchBarRef"
x-data="{ open: false }"
@click.outside="open = false;"
@keyup.escape.window="open = false"
id="search-bar"
class="h-full relative flex items-center overflow-visible"
>
<input
class="rounded-lg hidden w-64 lg:inline focus:outline-none bg-blue-700 border border-blue-500 px-3 py-[0.5625rem] focus:ring focus:ring-blue-400 placeholder-blue-300 h-[42px]"
x-ref="searchBarInput"
type="search"
id="search-bar-input"
placeholder="Search"
@focus="open = true;"
@keyup.enter.prevent="window.location.href = '/search/?q=' + $event.target.value;"
@keyup.escape.prevent="open = false;"
@keydown.window="(e) => {
switch(e.key) {
case 'k':
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
$el.focus();
}
break;
}
}"
tabindex="0"
/>
<button <button
id="search-bar-icon" type="button"
@click="window.location.href = '/search/?q=' + $refs.searchBarInput.value;"
class="lg:absolute right-2 p-1 rounded-lg cursor-pointer transition-colors hover:bg-blue-600 lg:hover:bg-transparent lg:hover:opacity-80"
aria-label="Search" aria-label="Search"
class="cursor-pointer flex items-center gap-2 p-2 rounded-lg bg-blue-700 border border-blue-500 text-white transition-colors focus:outline-none focus:ring focus:ring-blue-400 hover:bg-blue-800 hover:border-blue-400"
id="search-modal-trigger"
> >
<div class="bg-blue-700 rounded-md p-2 border border-blue-500 lg:border-none icon-svg"> <span class="icon-svg">
{{ partial "utils/svg.html" "/icons/search.svg" }} {{ partialCached "icon" "search" "search" }}
</div> </span>
<span class="hidden px-1 lg:inline">Search</span>
</button> </button>
<div </div>
id="search-bar-dropdown"
x-show="open"
x-cloak
x-ref="dropdown"
class="border-1 fixed z-[999] mt-2 hidden rounded-sm border-gray-100 bg-gray-50 p-6 font-medium text-gray-400 shadow-md md:block dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200"
style="width: min(500px, 90vw)"
x-effect="if (open) {
const rect = $refs.searchBarRef.getBoundingClientRect();
const dropdownWidth = Math.min(500, window.innerWidth * 0.9);
const viewportWidth = window.innerWidth;
// Calculate left position
let leftPos = rect.left;
// Prevent going off right edge
if (leftPos + dropdownWidth > viewportWidth - 20) {
leftPos = viewportWidth - dropdownWidth - 20;
}
// Prevent going off left edge
if (leftPos < 20) {
leftPos = 20;
}
$el.style.top = (rect.bottom + 8) + 'px';
$el.style.left = leftPos + 'px';
}"
>
<div id="search-bar-results">
{{- $emptyState := `
<div>
Start typing to search or try
<button @click="
$store.gordon.open($refs.searchBarInput.value.trim());
$refs.searchBarInput.value = '';
open = false;
" class="link">Ask AI</button>.
</div>
` }} {{- $emptyState | safe.HTML }}
<!-- results -->
</div>
</div>
<script type="module">
window.addEventListener("load", async function () {
const pagefind = await import("/pagefind/pagefind.js");
await pagefind.options({
ranking: {
termFrequency: 0.2,
pageLength: 0.75,
termSaturation: 1.4,
termSimilarity: 6.0,
},
});
const searchBarInput = document.querySelector("#search-bar-input"); <script type="module">
const searchBarResults = document.querySelector("#search-bar-results"); // Configure Pagefind before any components connect to DOM
const searchDropdown = document.querySelector("#search-bar-dropdown"); await import('/pagefind/pagefind-component-ui.js');
const { configureInstance, getInstanceManager } = window.PagefindComponents;
// Update position on scroll and resize configureInstance('default', {
function updateDropdownPosition() { bundlePath: '/pagefind/',
const searchBar = document.querySelector("#search-bar"); ranking: {
if ( termFrequency: 0.0,
!searchBar || termSimilarity: 2.0,
!searchDropdown || pageLength: 0.0,
searchDropdown.style.display === "none" termSaturation: 1.0
)
return;
const rect = searchBar.getBoundingClientRect();
const dropdownWidth = Math.min(500, window.innerWidth * 0.9);
const viewportWidth = window.innerWidth;
let leftPos = rect.left;
if (leftPos + dropdownWidth > viewportWidth - 20) {
leftPos = viewportWidth - dropdownWidth - 20;
}
if (leftPos < 20) {
leftPos = 20;
}
searchDropdown.style.top = rect.bottom + 8 + "px";
searchDropdown.style.left = leftPos + "px";
}
window.addEventListener("scroll", updateDropdownPosition);
window.addEventListener("resize", updateDropdownPosition);
async function search(e) {
const query = e.target.value;
if (query === "") {
searchBarResults.innerHTML = `{{ $emptyState | safe.HTML }}`;
return;
}
const search = await pagefind.debouncedSearch(query);
if (search === null) {
return;
} else {
const resultsLength = search.results.length;
const resultsData = await Promise.all(
search.results.slice(0, 5).map((r) => r.data()),
);
const results = resultsData.map((item, index) => ({
...item,
index: index + 1,
}));
if (query) {
searchBarResults.classList.remove("hidden");
} else {
searchBarResults.classList.add("hidden");
}
let resultsHTML = `<div class="p-2 text-gray-400 dark:text-gray-500">${resultsLength} results</div>`;
resultsHTML += results
.map((item) => {
// Truncate excerpt if it's too long
let excerpt = item.excerpt;
if (excerpt.length > 200) {
excerpt = excerpt.substring(0, 200);
} }
return `<div class="p-2"> });
<div class="flex flex-col items-start item">
<a class="link" style="word-break: break-word; overflow-wrap: anywhere;" href="${item.url}" data-query="${query}" data-index="${item.index}">${item.meta.title}</a>
<p class="text-black dark:text-white overflow-hidden text-left" style="word-break: break-word; overflow-wrap: anywhere;">…${excerpt}…</p>
</div>
</div>`;
})
.join("");
if (resultsLength > 5) { // Create modal after config is set
resultsHTML += `<div class="w-fit ml-auto px-4 py-2"><a href="/search/?q=${query}" class="link">Show all results</a></div>`; document.body.insertAdjacentHTML('beforeend', `
} <pagefind-modal id="search-modal" reset-on-close>
<pagefind-modal-header>
<pagefind-input placeholder="Search documentation…"></pagefind-input>
</pagefind-modal-header>
<pagefind-modal-body>
<p id="search-placeholder" class="text-center text-gray-500 dark:text-gray-400 py-8">
Start typing to search the documentation
</p>
<pagefind-summary></pagefind-summary>
<pagefind-results></pagefind-results>
</pagefind-modal-body>
</pagefind-modal>
`);
searchBarResults.innerHTML = resultsHTML; const modal = document.getElementById('search-modal');
} const placeholder = document.getElementById('search-placeholder');
// Custom result template
modal.querySelector('pagefind-results').resultTemplate = (result) => {
const li = document.createElement('li');
li.className = 'py-3 border-b border-gray-200 dark:border-gray-700 last:border-b-0';
const title = document.createElement('p');
title.className = 'font-medium';
const link = document.createElement('a');
link.className = 'text-blue-600 dark:text-blue-400 hover:underline';
link.href = result.meta.url || result.url;
link.textContent = result.meta.title;
title.appendChild(link);
li.appendChild(title);
if (result.excerpt) {
const excerpt = document.createElement('p');
excerpt.className = 'text-gray-600 dark:text-gray-400 mt-1 text-sm';
excerpt.innerHTML = result.excerpt;
li.appendChild(excerpt);
}
if (result.sub_results?.length) {
const ul = document.createElement('ul');
ul.className = 'mt-3 ml-4 flex flex-wrap gap-2';
for (const sub of result.sub_results) {
const subLi = document.createElement('li');
subLi.className = 'text-sm';
const subLink = document.createElement('a');
subLink.className = 'text-blue-600 dark:text-blue-400 hover:underline';
subLink.href = sub.url;
subLink.textContent = sub.title;
subLi.appendChild(subLink);
ul.appendChild(subLi);
} }
li.appendChild(ul);
}
searchBarInput.addEventListener("input", search); return li;
};
// Event delegation for tracking link clicks // Show/hide placeholder based on search state
if (window.heap !== undefined) { const instance = getInstanceManager().getInstance('default');
searchBarResults.addEventListener("click", function (event) { instance.on('search', (term) => {
if (event.target.tagName === "A" && event.target.closest(".link")) { placeholder.hidden = !!term;
const searchQuery = event.target.getAttribute("data-query"); });
const resultIndex = event.target.getAttribute("data-index"); instance.on('results', () => {
const url = new URL(event.target.href); placeholder.hidden = !!instance.searchTerm;
const properties = { });
docs_search_target_path: url.pathname,
docs_search_target_title: event.target.textContent, // Open modal
docs_search_query_text: searchQuery, const openModal = () => modal.open?.();
docs_search_target_index: resultIndex, document.getElementById('search-modal-trigger').addEventListener('click', openModal);
docs_search_source_path: window.location.pathname, document.addEventListener('keydown', (e) => {
docs_search_source_title: document.title, if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
}; e.preventDefault();
heap.track("Docs - Search - Click - Result Link", properties); openModal();
} }
}); });
} </script>
});
</script>
</div>