The "top search bar doesn't work" report had TWO stacked causes, found by reproducing with real keypress-by-keypress typing instead of Playwright's fill() (which fires a single input event and masked both): 1. htmx.min.js was included PER PAGE. The header's live search is htmx-driven on every page, but five pages rendering @Header — series, admin, admin hash-conflicts, admin processing-issues, browse-detail — never loaded it: the input's hx-* attributes were inert (htmx-internal-data absent), so typing produced no request at all. The include now lives in the header partial itself (classic script, before the module bundle); the sixteen per-page copies on @Header pages are removed (double-processing guard). Non-header pages (login/register/setup/reader/index) keep their own. 2. htmx's delay: modifier on a PROGRAMMATICALLY dispatched custom event (htmx.trigger) loses the request entirely under rapid successive triggers — every re-fire canceled the pending timer and the last never issued, so fast typing (the normal kind) never opened the dropdown while one-shot fill() worked. The debounce moved into the Alpine component (client-local state, where it belongs); htmx now executes the bare `search` event immediately. Also made bundle staleness structurally impossible: each bundle's script stamp is a content hash computed at startup (templates/ bundles.go), so a rebuild that changes bundle bytes changes its cache key with no manual ?v=N bump — the manual stamp was how the first dropdown fix stayed invisible for a deploy. The reader bundle (never stamped at all) is covered too. HTML pages additionally send Cache-Control: no-cache (they are dynamic), so a stale page can no longer reference a stale stamp; /api, /static and /uploads keep their native caching, pinned by a middleware test.
117 lines
3.8 KiB
TypeScript
117 lines
3.8 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
|
|
// Rendering ownership (AGENTS.md): the dropdown's CONTENT is templ —
|
|
// the header input's hx-get swaps the SearchResults partial (titles,
|
|
// authors, library names, progress/completed overlays all
|
|
// server-rendered) into #search-results. This module owns only
|
|
// client-local state: min-length gating, open/close, and keyboard
|
|
// selection. It replaces the old renderer that built the dropdown from
|
|
// innerHTML string templates — a violation of the rendering-ownership
|
|
// doctrine this migration removes (2026-10-27 search-progress batch).
|
|
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
const SEARCH_MIN_CHARS = 2;
|
|
|
|
interface HtmxAfterRequestEvent extends CustomEvent {
|
|
detail: { successful: boolean };
|
|
}
|
|
|
|
Alpine.data("headerSearch", () => ({
|
|
open: false,
|
|
loading: false,
|
|
selected: -1,
|
|
lastQuery: "",
|
|
debounceTimer: null as ReturnType<typeof setTimeout> | null,
|
|
|
|
init() {
|
|
// htmx dispatches request lifecycle events on the REQUESTING
|
|
// element (the input), not the swap target.
|
|
const input = this.$refs.input as HTMLInputElement | undefined;
|
|
if (!input) return;
|
|
input.addEventListener("htmx:beforeRequest", () => {
|
|
this.loading = true;
|
|
});
|
|
input.addEventListener("htmx:afterRequest", (evt: Event) => {
|
|
this.loading = false;
|
|
const detail = (evt as HtmxAfterRequestEvent).detail;
|
|
// htmx swaps only 2xx bodies: a failed request closes instead of
|
|
// swapping an error page into the dropdown.
|
|
if (detail && detail.successful) {
|
|
this.open = true;
|
|
this.selected = -1;
|
|
this.lastQuery = this.query();
|
|
} else {
|
|
this.open = false;
|
|
}
|
|
});
|
|
},
|
|
|
|
query(): string {
|
|
const input = this.$refs.input as HTMLInputElement | undefined;
|
|
return input ? input.value.trim() : "";
|
|
},
|
|
|
|
onInput() {
|
|
const q = this.query();
|
|
if (q.length < SEARCH_MIN_CHARS) {
|
|
this.close();
|
|
return;
|
|
}
|
|
// Alpine owns the debounce: htmx's `delay:` on a custom trigger
|
|
// event LOSES the request under rapid successive triggers (each
|
|
// re-arm cancels the pending one and the last never issues —
|
|
// verified empirically; this is the 2026-10-27 "dropdown never
|
|
// opens while typing" bug). The bare `search` event just executes.
|
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
this.debounceTimer = setTimeout(() => {
|
|
this.debounceTimer = null;
|
|
window.htmx.trigger(this.$refs.input as HTMLElement, "search");
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
},
|
|
|
|
onFocus() {
|
|
if (this.query().length < SEARCH_MIN_CHARS) return;
|
|
if (this.lastQuery === this.query() && this.hasResults()) {
|
|
this.open = true;
|
|
} else {
|
|
window.htmx.trigger(this.$refs.input as HTMLElement, "search");
|
|
}
|
|
},
|
|
|
|
hasResults(): boolean {
|
|
const results = document.getElementById("search-results");
|
|
return !!results && results.childElementCount > 0;
|
|
},
|
|
|
|
resultItems(): HTMLElement[] {
|
|
const results = document.getElementById("search-results");
|
|
if (!results) return [];
|
|
return Array.from(results.querySelectorAll<HTMLElement>(".search-result-item"));
|
|
},
|
|
|
|
moveSelection(delta: number) {
|
|
if (!this.open) return;
|
|
const items = this.resultItems();
|
|
if (items.length === 0) return;
|
|
let next = this.selected + delta;
|
|
if (next < -1) next = -1;
|
|
if (next >= items.length) next = items.length - 1;
|
|
this.selected = next;
|
|
items.forEach((el, i) => {
|
|
el.style.backgroundColor = i === next ? "var(--surface-hover)" : "";
|
|
});
|
|
if (next >= 0) items[next].scrollIntoView({ block: "nearest" });
|
|
},
|
|
|
|
openSelected() {
|
|
if (!this.open || this.selected < 0) return;
|
|
const link = this.resultItems()[this.selected]?.querySelector("a");
|
|
if (link) (link as HTMLAnchorElement).click();
|
|
},
|
|
|
|
close() {
|
|
this.open = false;
|
|
this.selected = -1;
|
|
},
|
|
}));
|