// the address bar: crumbs at rest, a search field once you click in or press "/". empty, it
// lists every page; typing searches pages, their headings and the configuration settings.
import { focusIn } from "./menukeys";
interface Entry {
kind: "page" | "heading" | "setting";
label: string;
hint: string;
url: string;
icon: string;
}
const KINDS: [Entry["kind"], string][] = [["page", "pages"], ["heading", "sections"], ["setting", "settings"]];
const PER_KIND = 8;
/** below this many index hits, full text search (pagefind) fills in */
const FEW = 3;
interface Pagefind {
search(q: string): Promise<{ results: { data(): Promise<{ url: string; meta: { title?: string }; sub_results: { url: string; title: string }[] }> }[] }>;
}
let pagefind: Promise<Pagefind | null> | undefined;
// built after the site by `pagefind`, so it isn't there in dev
const loadPagefind = () =>
(pagefind ??= import(/* @vite-ignore */ `${location.origin}/pagefind/pagefind.js`).catch(() => null) as Promise<Pagefind | null>);
async function fullText(q: string, skip: Set<string>): Promise<Entry[]> {
const pf = await loadPagefind();
if (!pf) return [];
const { results } = await pf.search(q);
const data = await Promise.all(results.slice(0, PER_KIND).map((r) => r.data()));
return data
.map((d) => {
const sub = d.sub_results[0];
const url = new URL(sub?.url ?? d.url, location.origin);
return { kind: "heading" as const, label: sub?.title ?? d.meta.title ?? url.pathname, hint: d.meta.title ?? "", url: url.pathname + url.hash, icon: "i-doc" };
})
.filter((e) => !skip.has(e.url));
}
const addr = document.querySelector<HTMLElement>("[data-addr]");
if (addr) setup(addr);
function setup(addr: HTMLElement) {
const input = addr.querySelector<HTMLInputElement>("[data-q]")!;
const menu = addr.querySelector<HTMLElement>("[data-menu]")!;
const all = menu.querySelector<HTMLElement>("[data-all]")!;
const results = menu.querySelector<HTMLElement>("[data-results]")!;
// sibling menus can be added later (configuration adds its own), so look them up each time
const sibs = () => [...addr.querySelectorAll<HTMLElement>("[data-sibmenu]")];
// without js the menus are plain popovers their buttons toggle. from here on this script
// opens and closes them, so they stop light dismissing on their own
takeOver(addr);
let index: Promise<Entry[]> | undefined;
let active: HTMLElement | null = null;
let act = -1;
const load = () => (index ??= fetch("/search.json").then((r) => r.json() as Promise<Entry[]>));
const links = () => (active ? [...active.querySelectorAll<HTMLAnchorElement>("a")].filter((a) => a.offsetParent) : []);
function mark(i: number, scroll = true) {
const ls = links();
act = ls.length ? Math.max(0, Math.min(ls.length - 1, i)) : -1;
ls.forEach((a, j) => a.classList.toggle("act", j === act));
if (scroll && act >= 0) ls[act].scrollIntoView({ block: "nearest" });
}
function closeAll() {
hide(menu);
sibs().forEach(hide);
addr.querySelectorAll("[data-sib]").forEach((b) => b.setAttribute("aria-expanded", "false"));
input.setAttribute("aria-expanded", "false");
active = null;
act = -1;
}
function openMain() {
closeAll();
show(menu, addr);
input.setAttribute("aria-expanded", "true");
active = menu;
void render();
}
function typeMode() {
addr.classList.add("typing");
input.focus();
openMain();
}
function leaveTyping() {
input.value = "";
addr.classList.remove("typing");
closeAll();
}
function openSib(btn: HTMLElement) {
const m = sibs().find((s) => s.dataset.sibmenu === btn.dataset.sib);
if (!m) return;
const wasOpen = m.matches(":popover-open");
closeAll();
if (wasOpen) return;
show(m, addr, btn);
btn.setAttribute("aria-expanded", "true");
focusIn(m);
}
async function render() {
const q = input.value.trim().toLowerCase();
all.hidden = !!q;
results.hidden = !q;
if (!q) {
const ls = links();
const cur = ls.findIndex((a) => a.getAttribute("aria-current") === "page");
mark(cur >= 0 ? cur : 0, false);
// open on the page you're on, with its group's title above it and its sections below
if (cur >= 0) {
const head = ls[cur].previousElementSibling?.classList.contains("grp") ? ls[cur].previousElementSibling! : ls[cur];
menu.scrollTop += head.getBoundingClientRect().top - menu.getBoundingClientRect().top - 3;
}
return;
}
const entries = await load();
if (input.value.trim().toLowerCase() !== q) return;
const score = (e: Entry) => {
const l = e.label.toLowerCase();
return l.startsWith(q) ? 0 : l.includes(q) ? 1 : l.replace(/^hydrant_/, "").startsWith(q) ? 0 : 2;
};
let html = "";
const shown = new Set<string>();
for (const [kind, title] of KINDS) {
const hits = entries.filter((e) => e.kind === kind && score(e) < 2).sort((a, b) => score(a) - score(b)).slice(0, PER_KIND);
if (!hits.length) continue;
hits.forEach((e) => shown.add(e.url));
html += `<div class="grp">${title}</div>` + hits.map((e) => row(e, q)).join("");
}
if (shown.size < FEW) {
const text = await fullText(q, shown);
if (input.value.trim().toLowerCase() !== q) return;
if (text.length) html += `<div class="grp">in the text</div>` + text.map((e) => row(e, q)).join("");
}
results.innerHTML = html || `<div class="none">nothing about “${esc(q)}” anywhere in the docs</div>`;
mark(0);
}
addr.addEventListener("click", (e) => {
const t = e.target as HTMLElement;
const sib = t.closest<HTMLElement>("[data-sib]");
if (sib) {
e.preventDefault();
openSib(sib);
return;
}
if (t.closest("[data-arrow]")) {
if (menu.matches(":popover-open")) leaveTyping();
else typeMode();
return;
}
if (t.closest("[data-type]")) typeMode();
// a link that stays on this page (a #hash) doesn't unload it, so close the menu it was in
if (t.closest(".menu a")) setTimeout(leaveTyping);
});
input.addEventListener("focus", () => addr.classList.contains("typing") || typeMode());
input.addEventListener("input", () => void render());
input.addEventListener("blur", () => {
// a click on a result lands after blur, so let it through before closing
setTimeout(() => {
if (!addr.contains(document.activeElement) && !input.value) leaveTyping();
}, 150);
});
// tabbing out of a sibling menu closes it, like a click elsewhere does
addr.addEventListener("focusout", (e) => {
const from = (e.target as HTMLElement).closest<HTMLElement>("[data-sibmenu]");
const to = e.relatedTarget as HTMLElement | null;
if (from && to && !from.contains(to) && !to.closest("[data-sib]")) closeAll();
});
addr.addEventListener("mousemove", (e) => {
const a = (e.target as HTMLElement).closest<HTMLAnchorElement>("a");
if (a && active?.contains(a)) mark(links().indexOf(a), false);
});
document.addEventListener("click", (e) => {
if (!addr.contains(e.target as Node)) input.value ? closeAll() : leaveTyping();
});
document.addEventListener("keydown", (e) => {
const typing = /^(input|textarea|select)$/i.test((e.target as HTMLElement).tagName);
if (e.key === "/" && !typing) {
e.preventDefault();
typeMode();
return;
}
const sib = sibs().find((s) => s.matches(":popover-open"));
if (sib && e.key === "Escape") {
closeAll();
addr.querySelector<HTMLElement>(`[data-sib="${sib.dataset.sibmenu}"]`)?.focus();
return;
}
if (!active) return;
if (e.key === "Escape") {
leaveTyping();
input.blur();
return;
}
// while searching, focus stays in the field, so the highlight in the results is drawn here
if (e.target !== input) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
mark(act + (e.key === "ArrowDown" ? 1 : -1));
} else if (e.key === "Enter") {
const a = links()[act];
if (a) {
e.preventDefault();
location.href = a.href;
// a section of this page doesn't unload it
leaveTyping();
}
}
});
}
/** every menu in the address bar becomes a manual popover, its buttons no longer toggle it */
export function takeOver(root: HTMLElement) {
root.querySelectorAll<HTMLElement>("[popover]").forEach((p) => (p.popover = "manual"));
root.querySelectorAll("[popovertarget]").forEach((b) => b.removeAttribute("popovertarget"));
}
const anchoring = CSS.supports("anchor-name: --a");
/** opens a menu under the field, at its button's left edge (or the field's own) */
export function show(menu: HTMLElement, field: HTMLElement, btn?: HTMLElement) {
if (!menu.matches(":popover-open")) menu.showPopover();
// css anchors place it where they're supported, elsewhere it goes by hand
if (anchoring) return;
const f = field.getBoundingClientRect();
const left = btn ? btn.getBoundingClientRect().left : f.left;
Object.assign(menu.style, { margin: "0", top: `${f.bottom}px`, left: `${left}px`, right: btn ? "auto" : `${innerWidth - f.right}px`, bottom: "auto" });
}
function hide(menu: HTMLElement) {
if (menu.matches(":popover-open")) menu.hidePopover();
}
function esc(s: string) {
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
}
function highlight(label: string, q: string) {
const i = label.toLowerCase().indexOf(q);
if (i < 0) return esc(label);
return esc(label.slice(0, i)) + "<mark>" + esc(label.slice(i, i + q.length)) + "</mark>" + esc(label.slice(i + q.length));
}
function row(e: Entry, q: string) {
const here = e.url === location.pathname ? ' aria-current="page"' : "";
return `<a href="${esc(e.url)}"${here}><svg class="icon" width="16" height="16" aria-hidden="true"><use href="#${e.icon}"/></svg><span class="lbl">${highlight(e.label, q)}</span><span class="hint">${esc(e.hint)}</span></a>`;
}
export {};