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 | 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(".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; }, }));