| index 4e4a14e..00ccdc2 100644 |
| --- a/web/static/searchui.js |
| +++ b/web/static/searchui.js |
| @@ -23,7 +23,7 @@ export function wireSearch(deps) { |
| // Search over the local store: rows decrypt lazily into an in-MEMORY index |
| // (search.js — never written anywhere), then queries are instant scans. |
| // Everything is local; the query never leaves the device. Results render in |
| -// the sidebar (#msg-results) below the name-filtered chat list; a row jumps |
| +// a dropdown under the search box (#msg-results); a row jumps |
| // to the message in its thread. "Search earlier messages" walks incomplete |
| // threads' full history into the store (runBackfill), streaming new hits in, |
| // cancellable — the #228 time machine. |
| @@ -47,17 +47,7 @@ function searchableThreads() { |
| let searchSeq = 0; |
| let searchTimer = null; |
| let searchBackfill = null; // { controller } while the deep walk runs |
| -let searchActiveTarget = null; // .msg element carrying the sticky .search-active highlight |
| -let searchActiveHitKey = null; // "tid:sid" of the currently jumped-to result, for row styling |
| - |
| -// Sticky highlight is separate from .flash (which always fades on its own): |
| -// it stays on the jumped-to message until another result is picked or the |
| -// search ends, at which point this clears it. |
| -function clearSearchHighlight() { |
| - if (searchActiveTarget) searchActiveTarget.classList.remove("search-active"); |
| - searchActiveTarget = null; |
| - searchActiveHitKey = null; |
| -} |
| +let searchCursorKey = null; // "tid:sid" of the keyboard-highlighted row; null → the top hit |
| |
| export function scheduleMessageSearch(query) { |
| clearTimeout(searchTimer); |
| @@ -68,14 +58,100 @@ export function scheduleMessageSearch(query) { |
| // later "Search earlier messages" resumes where it stopped. |
| if (searchBackfill) searchBackfill.controller.abort(); |
| searchSeq++; |
| + searchCursorKey = null; |
| box.classList.add("hidden"); |
| box.textContent = ""; |
| - clearSearchHighlight(); |
| + const filter = document.getElementById("chat-filter"); |
| + if (filter) { |
| + filter.setAttribute("aria-expanded", "false"); |
| + filter.removeAttribute("aria-activedescendant"); |
| + } |
| return; |
| } |
| + searchCursorKey = null; // an edited query re-highlights the top hit |
| searchTimer = setTimeout(() => runMessageSearch(query), 250); |
| } |
| |
| +// Ending the search goes through the real input path — value cleared plus an |
| +// input event — so the name-filtered chat list resets in the same breath |
| +// (shell.js owns that wiring) and scheduleMessageSearch hides the dropdown. |
| +function clearSearch(refocus) { |
| + const filter = document.getElementById("chat-filter"); |
| + if (!filter) return; |
| + filter.value = ""; |
| + filter.dispatchEvent(new Event("input", { bubbles: true })); |
| + if (refocus) filter.focus(); |
| +} |
| + |
| +// Scaffold rebuilds can remove the node holding focus; when that happens |
| +// (focus fell to <body>) hand it back to the search box rather than stranding |
| +// a keyboard user at the top of the document. |
| +function rescueFocus() { |
| + if (document.activeElement !== document.body) return; |
| + const filter = document.getElementById("chat-filter"); |
| + if (filter) filter.focus(); |
| +} |
| + |
| +// The dropdown dismisses like the filter menu beside it: a click anywhere |
| +// outside the search bar and the results ends the search. (A pick ends it |
| +// too — the row's own onclick — before this listener sees the click.) |
| +document.addEventListener("click", (e) => { |
| + const box = document.getElementById("msg-results"); |
| + if (!box || box.classList.contains("hidden")) return; |
| + const bar = document.getElementById("chat-search"); |
| + if (box.contains(e.target) || (bar && bar.contains(e.target))) return; |
| + clearSearch(false); |
| +}); |
| + |
| +// The keyboard cursor: focus never leaves the search box — the highlighted |
| +// row (top hit by default, so plain Enter opens it with no arrowing) is a |
| +// virtual cursor the box's aria-activedescendant tracks, combobox-style. |
| +function setCursor(s, row) { |
| + for (const r of s.list.children) { |
| + r.classList.toggle("active", r === row); |
| + r.setAttribute("aria-selected", String(r === row)); |
| + } |
| + const filter = document.getElementById("chat-filter"); |
| + if (filter) { |
| + if (row) filter.setAttribute("aria-activedescendant", row.id); |
| + else filter.removeAttribute("aria-activedescendant"); |
| + } |
| + searchCursorKey = row ? row.dataset.key : null; |
| +} |
| + |
| +// Keyboard: Down/Up move the highlight from the search box (or a Tab-focused |
| +// row), Enter in the box opens the highlighted hit, Escape ends the search |
| +// from anywhere in it. Rows are real buttons, so Tab + Enter/Space on a row |
| +// already behave. |
| +document.addEventListener("keydown", (e) => { |
| + if (e.isComposing) return; // IME owns Enter/arrows mid-composition |
| + const box = document.getElementById("msg-results"); |
| + if (!box || box.classList.contains("hidden")) return; |
| + const filter = document.getElementById("chat-filter"); |
| + if (e.target !== filter && !box.contains(e.target)) return; |
| + if (e.key === "Escape") { |
| + e.preventDefault(); |
| + clearSearch(true); |
| + return; |
| + } |
| + const s = resultsScaffold(); |
| + if (!s) return; |
| + const rows = [...s.list.children]; |
| + const cur = rows.findIndex((r) => r.classList.contains("active")); |
| + if (e.key === "ArrowDown" || e.key === "ArrowUp") { |
| + e.preventDefault(); |
| + if (!rows.length) return; |
| + const next = e.key === "ArrowDown" ? Math.min(cur + 1, rows.length - 1) : Math.max(cur - 1, 0); |
| + setCursor(s, rows[next]); |
| + rows[next].scrollIntoView({ block: "nearest" }); |
| + return; |
| + } |
| + if (e.key === "Enter" && e.target === filter && cur >= 0) { |
| + e.preventDefault(); |
| + rows[cur].click(); |
| + } |
| +}); |
| + |
| async function runMessageSearch(query) { |
| const mySeq = ++searchSeq; |
| // Stream: render after each thread's ensure, so a big space's first search |
| @@ -100,6 +176,9 @@ function resultsScaffold() { |
| status.className = "msg-results-count"; |
| status.setAttribute("aria-live", "polite"); |
| const list = document.createElement("div"); |
| + list.id = "msg-results-list"; // the search box's aria-controls target |
| + list.setAttribute("role", "listbox"); |
| + list.setAttribute("aria-label", "Message search results"); |
| const footer = document.createElement("div"); |
| box.append(status, list, footer); |
| } |
| @@ -136,15 +215,24 @@ function renderSearchResults(query) { |
| snip.className = "msg-result-snippet"; |
| snip.textContent = snippetFor(h.text, query); |
| row.append(top, snip); |
| - const key = `${h.tid}:${h.id}`; |
| - if (key === searchActiveHitKey) row.classList.add("active"); |
| + row.dataset.key = `${h.tid}:${h.id}`; // the cursor follows a hit across streamed rebuilds |
| + row.id = `msg-hit-${h.tid}-${h.id}`; // aria-activedescendant target |
| + row.setAttribute("role", "option"); |
| row.onclick = () => { |
| - searchActiveHitKey = key; |
| - for (const r of s.list.children) r.classList.toggle("active", r === row); |
| + // A pick ends the search — dropdown closed, chat list unfiltered — |
| + // then lands on the message; scrollToMessage's flash marks the spot |
| + // (no sticky highlight to outlive a search that's already over). |
| + clearSearch(false); |
| jumpToMessage(h.tid, h.id); |
| }; |
| s.list.appendChild(row); |
| } |
| + // The cursor lands on the top hit unless the member had arrowed to a hit |
| + // that's still in the (streamed re-)rendered list. |
| + const kept = [...s.list.children].find((r) => r.dataset.key === searchCursorKey); |
| + setCursor(s, kept || s.list.firstChild); |
| + const filter = document.getElementById("chat-filter"); |
| + if (filter) filter.setAttribute("aria-expanded", "true"); |
| renderSearchFooter(query); |
| } |
| |
| @@ -156,6 +244,7 @@ async function renderSearchFooter(query) { |
| if (!s) return; |
| if (searchBackfill) { |
| s.footer.replaceChildren(searchBackfill.cancel); |
| + rescueFocus(); |
| return; |
| } |
| const mySeq = searchSeq; |
| @@ -165,14 +254,19 @@ async function renderSearchFooter(query) { |
| if (!mark || !mark.complete) incomplete.push(t.id); |
| } |
| if (mySeq !== searchSeq || searchBackfill) return; // superseded meanwhile |
| + const hadFocus = s.footer.contains(document.activeElement); |
| s.footer.textContent = ""; |
| - if (!incomplete.length) return; |
| + if (!incomplete.length) { |
| + rescueFocus(); |
| + return; |
| + } |
| const btn = document.createElement("button"); |
| btn.type = "button"; |
| btn.className = "msg-result-deeper"; |
| btn.textContent = "Search earlier messages…"; |
| btn.onclick = () => startDeepSearch(query, incomplete); |
| s.footer.replaceChildren(btn); |
| + if (hadFocus) btn.focus(); // the affordance keeps focus across its own rebuild |
| } |
| |
| async function startDeepSearch(query, threadIds) { |
| @@ -184,7 +278,8 @@ async function startDeepSearch(query, threadIds) { |
| cancel.textContent = "Stop searching"; |
| cancel.onclick = () => controller.abort(); |
| searchBackfill = { controller, cancel, progress: "Searching…" }; |
| - renderSearchResults(query); // swaps the footer button for Stop |
| + renderSearchResults(query); // swaps the footer button for Stop (sync up to the footer's first await) |
| + cancel.focus(); // the deeper-button press continues on the walk's one control |
| try { |
| for (const tid of threadIds) { |
| if (controller.signal.aborted) break; |
| @@ -228,7 +323,15 @@ async function fetchMessagePage(threadId, before) { |
| async function jumpToMessage(tid, sid) { |
| await openThreadFn(tid); |
| const pane = document.getElementById("app-pane"); |
| - const feed = pane && pane.querySelector(".chatapp-feed"); |
| + let feed = pane && pane.querySelector(".chatapp-feed"); |
| + // Landing in a thread that wasn't open: the fresh mount paints |
| + // asynchronously (store hydrate → first render), so wait — bounded — for |
| + // the feed and its rows to exist before deciding history needs paging; |
| + // otherwise the walk bails on an empty feed and the jump lands nowhere. |
| + for (let i = 0; i < 40 && !(feed && feed.querySelector(".msg[data-id]")); i++) { |
| + await new Promise((r) => setTimeout(r, 50)); |
| + feed = pane && pane.querySelector(".chatapp-feed"); |
| + } |
| if (!feed) return; |
| for (let i = 0; i < 12 && !feed.querySelector(`.msg[data-id="${sid}"]`); i++) { |
| const first = feed.querySelector(".msg[data-id]"); |
| @@ -242,12 +345,23 @@ async function jumpToMessage(tid, sid) { |
| // an offscreen control). The bubble itself takes it; a deleted/never-found |
| // target falls back to the composer. |
| const target = feed.querySelector(`.msg[data-id="${sid}"]`); |
| - if (searchActiveTarget && searchActiveTarget !== target) searchActiveTarget.classList.remove("search-active"); |
| - searchActiveTarget = target; |
| - if (target) target.classList.add("search-active"); |
| const focusEl = target || document.querySelector(".chatapp-root .composer .composer-input"); |
| if (focusEl) { |
| if (target) target.tabIndex = -1; |
| focusEl.focus({ preventScroll: true }); |
| } |
| + if (target) { |
| + // The found message waves from the crowd (.search-wave: a lift + tilt on |
| + // the bubble over .flash's row tint; reduced motion gets a fading dashed |
| + // ring). Remove-reflow-add restarts it when the same message is found |
| + // again; the name check keeps .flash's earlier end from cutting it short. |
| + target.classList.remove("search-wave"); |
| + void target.offsetWidth; |
| + target.classList.add("search-wave"); |
| + target.addEventListener("animationend", function unwave(ev) { |
| + if (ev.animationName !== "search-wave" && ev.animationName !== "search-found") return; |
| + target.classList.remove("search-wave"); |
| + target.removeEventListener("animationend", unwave); |
| + }); |
| + } |
| } |