diff --git a/static/mappicker.js b/static/mappicker.js index 75b2151..cc18792 100644 --- a/static/mappicker.js +++ b/static/mappicker.js @@ -11,6 +11,7 @@ (function () { let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null; let searchForm, searchInput, sugEl, confirmBtn, hintEl; + let sugSeq = 0, sugTimer = 0, sugAbort = null; const PIN_ICON = ``; @@ -49,15 +50,58 @@ overlay.addEventListener("pointerdown", (e) => { if (e.target === overlay) close(); }); confirmBtn.onclick = () => finish(pin); - searchForm.addEventListener("submit", async (e) => { - e.preventDefault(); - const q = searchInput.value.trim(); - if (!q) return; + // Live suggestions (top 5, single-letter-typo tolerant) as the user types: + // debounced so a fast typist doesn't fire a request per keystroke, aborted + // + sequence-guarded so a slow stale response can't overwrite a newer list. + async function fetchSug(q) { + const seq = ++sugSeq; + if (sugAbort) sugAbort.abort(); + sugAbort = new AbortController(); try { - const res = await fetch(`api/v2/geocode?q=${encodeURIComponent(q)}`); + const res = await fetch(`api/v2/suggest?q=${encodeURIComponent(q)}`, { signal: sugAbort.signal }); const d = await res.json(); - renderSug(d.results || []); - } catch (err) { /* leave the map as the fallback way to pick */ } + if (seq === sugSeq) renderSug(d.results || []); + } catch (err) { /* aborted, or offline — the map stays the fallback way to pick */ } + } + searchInput.addEventListener("input", () => { + clearTimeout(sugTimer); + // The old list stays visible until fresh results land, but its highlight + // must go — Enter mid-typing shouldn't pick a suggestion for the shorter + // query the list still reflects. + const stale = sugEl.querySelector("li.active"); + if (stale) stale.classList.remove("active"); + const q = searchInput.value.trim(); + if (q.length < 2) { sugEl.hidden = true; return; } + sugTimer = setTimeout(() => fetchSug(q), 250); + }); + // Arrow keys walk the list (Enter picks via the submit handler below); + // Escape dismisses the list before the overlay's Escape-to-close kicks in. + searchInput.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !sugEl.hidden) { + sugEl.hidden = true; + e.stopPropagation(); + return; + } + if (sugEl.hidden || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return; + const items = [...sugEl.children]; + if (!items.length) return; + e.preventDefault(); + const cur = items.findIndex((li) => li.classList.contains("active")); + const next = e.key === "ArrowDown" + ? (cur + 1) % items.length + : (cur <= 0 ? items.length - 1 : cur - 1); + setActiveSug(items, next); + items[next].scrollIntoView({ block: "nearest" }); + }); + searchForm.addEventListener("submit", (e) => { + e.preventDefault(); + clearTimeout(sugTimer); + // Enter picks the highlighted suggestion when the list is up; otherwise + // it searches immediately (no debounce wait). + const active = sugEl.hidden ? null : sugEl.querySelector("li.active"); + if (active) { active.click(); return; } + const q = searchInput.value.trim(); + if (q) fetchSug(q); }); // Hide the suggestions when tapping elsewhere in the modal (but not the map, // which has its own tap handler). @@ -69,6 +113,10 @@ }); } + function setActiveSug(items, idx) { + items.forEach((li, i) => li.classList.toggle("active", i === idx)); + } + function renderSug(list) { const shown = list; sugEl.innerHTML = ""; @@ -84,8 +132,13 @@ setPin(r.lat, r.lon, 10); finish({ lat: r.lat, lon: r.lon }); }; + // Keep one highlight: hovering moves it off whatever the arrows chose. + li.addEventListener("pointerenter", () => + setActiveSug([...sugEl.children], [...sugEl.children].indexOf(li))); sugEl.appendChild(li); }); + // Pre-highlight the top match so Enter picks it straight away. + setActiveSug([...sugEl.children], 0); sugEl.hidden = false; } @@ -110,6 +163,10 @@ onPickCb = onPick; pin = null; confirmBtn.disabled = true; + // Cancel any in-flight/pending suggestion fetch from the previous open so + // a late response can't pop a stale list over the fresh empty box. + clearTimeout(sugTimer); + sugSeq++; sugEl.hidden = true; searchInput.value = ""; hintEl.textContent = "Search above, or tap the map to drop a pin."; diff --git a/static/style.css b/static/style.css index c8e7e07..c7345c1 100644 --- a/static/style.css +++ b/static/style.css @@ -699,7 +699,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; } box-shadow: 0 12px 30px rgba(0, 0, 0, .4); } .mp-sug li { padding: 10px 12px; border-radius: 7px; cursor: pointer; font-size: 14px; } -.mp-sug li:hover { background: var(--surface-2); } +.mp-sug li:hover, .mp-sug li.active { background: var(--surface-2); } .mp-sug .sub { color: var(--muted); font-size: 12px; } .mp-map { flex: 1 1 auto; min-height: 300px; margin: 4px 16px; z-index: 0;