eleven / messenger Public

web: message search results float as a dropdown over the chat list (#1193)

* web: message search results float as a dropdown over the chat list

The #586 results panel was an in-flow sidebar section, so a query shoved
the thread list (and the 'No chats match' note) down the column, and the
box could grow a stray horizontal scrollbar. It now floats as a dropdown
anchored under the search bar — same treatment as the filter menu beside
it: a zero-height anchor keeps the sidebar flow untouched, the panel gets
the menu's card styling, sits under the filter menu's z-index, and clips
horizontal overflow. Show/hide logic and keyboard order are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: search dropdown closes on pick + arrow-key navigation

A pick now ends the search through the real input path (dropdown closes,
chat list unfilters) and lands on the message — scrollToMessage's flash
marks the spot, so the sticky search-active highlight (which a closed
search could no longer clear) is removed outright. Outside clicks dismiss
like the filter menu. Keyboard: ArrowDown from the search box walks into
the results, Down/Up move through them including the footer affordance,
Up off the top returns to the box, Escape ends the search; focus follows
a row across streamed re-renders and never strands on <body>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: search dropdown pre-highlights the top hit; Enter opens it from the box

Replace roving focus with a combobox-style cursor: focus never leaves the
search box; the top hit is highlighted the moment results render (an
edited query re-highlights it), Down/Up move the highlight — following a
chosen hit across streamed rebuilds — Enter opens it, and
aria-activedescendant/listbox/option semantics track it for AT. Fixes the
double-ArrowDown (first press raced the debounce, and entering the list
took a press of its own).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: the found message waves — lift + tilt on landing from search

The dashed sticky ring's replacement: jumping to a hit adds .search-wave —
the bubble pops up and waves (pivot at its foot) over .flash's row tint,
then settles; prefers-reduced-motion swaps the movement for the old dashed
ring fading out on its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: search wave rocks in place — no lift, no revealed tint beneath

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: jump waits for a fresh thread mount; quicker wave

Landing on a hit in a thread that wasn't open raced the mount — openThread
resolves before the feed's first paint, so the history-paging loop bailed
on an empty feed and the jump (scroll, flash, wave) landed nowhere. Wait
bounded (≤2s) for the feed's rows before paging. Wave 1.2s → 0.85s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b4e7fcda6b19780fba03566d6e6db9b620aa47ac parent 8a82a49
4 files changed, +199 −39
  • changelog/2026-07-20-search-results-dropdown.md +7 −0
  • web/pages/index.html +15 −5
  • web/static/searchui.js +137 −23
  • web/static/style.css +40 −11
diff --git a/changelog/2026-07-20-search-results-dropdown.md b/changelog/2026-07-20-search-results-dropdown.md
new file mode 100644
index 0000000..91a00dc
--- /dev/null
+++ b/changelog/2026-07-20-search-results-dropdown.md
@@ -0,0 +1,7 @@
+🤖 Searching your messages now shows the results in a neat dropdown under the
+search box, floating over your chat list — instead of wedging themselves into
+the sidebar and pushing everything below them down. Picking a result takes
+you to the message and tidies the search away, and the whole thing works from
+the keyboard: the first result is highlighted as you type, so Enter opens it
+straight away — or arrow down to another, and Escape closes. And when you
+arrive, the message you were looking for gives you a little wave.
diff --git a/web/pages/index.html b/web/pages/index.html
index c703d6a..3343d08 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -168,7 +168,12 @@
</div>
</header>
<div class="chat-search hidden" id="chat-search">
- <input type="search" id="chat-filter" placeholder="Search chats" aria-label="Search chats" autocomplete="off" />
+ <!-- Combobox semantics for the message-results dropdown: focus stays
+ here while aria-activedescendant tracks the highlighted row
+ (searchui.js). aria-controls targets the lazily-built listbox. -->
+ <input type="search" id="chat-filter" placeholder="Search chats" aria-label="Search chats" autocomplete="off"
+ role="combobox" aria-autocomplete="list" aria-haspopup="listbox" aria-expanded="false"
+ aria-controls="msg-results-list" />
<!-- List filters: online-only + People/Bots/Apps categories (shell.js). -->
<div class="menu-wrap">
<button type="button" id="filter-btn" class="icon-btn filter-btn" aria-label="Filter chats" title="Filter chats"
@@ -187,10 +192,15 @@
</div>
</div>
</div>
- <!-- Message search results (#586, shell.js): filled while the search box
- holds a query; each row jumps to the message in its thread. Inline in
- the sidebar — occupies layout space, never floats. -->
- <div class="msg-results hidden" id="msg-results" role="region" aria-label="Message search results"></div>
+ <!-- Message search results (#586, searchui.js): filled while the search
+ box holds a query; each row jumps to the message in its thread. A
+ dropdown under the search bar, floating over the thread list like the
+ filter menu beside it (the never-float rule covers toasts/nudges, not
+ a user-invoked, transient surface); the zero-height anchor keeps it
+ out of the sidebar column's flow. -->
+ <div class="msg-results-anchor">
+ <div class="msg-results hidden" id="msg-results" role="region" aria-label="Message search results"></div>
+ </div>
<!-- Inline passkey-backup nudge (backup.js fills it in). It occupies layout
space in the sidebar — never a floating toast. -->
<div class="backup-banner hidden" id="backup-banner"></div>
diff --git a/web/static/searchui.js b/web/static/searchui.js
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);
+ });
+ }
}
diff --git a/web/static/style.css b/web/static/style.css
index eec546a..3bd1bf0 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1238,13 +1238,30 @@ select:disabled { opacity: 0.5; cursor: default; }
0%, 100% { background: transparent; }
25% { background: color-mix(in srgb, var(--accent) 16%, transparent); }
}
-/* sticky highlight for the current search result, until another is picked or
- search ends (unlike .flash, this doesn't fade on its own). A dashed ring
- OUTSIDE the bubble, on the page background — not a fill on the bubble
- itself — so it stays visible on "me" bubbles, whose background is often
- the same hue as --accent (a fill there nearly disappears). */
-.msg.search-active .bubble { outline: 2px dashed var(--accent); outline-offset: 3px; }
-
+/* Search landing (searchui.js): the found bubble gives a little wave — a
+ gentle rock in place, no lift (a lift exposes .flash's row tint beneath
+ and reads as a stray shadow). Pivot at the bubble's foot so it waves
+ rather than spins. */
+.msg.search-wave .bubble {
+ animation: search-wave 0.85s ease-in-out;
+ transform-origin: 50% 100%;
+}
+@keyframes search-wave {
+ 0%, 100% { transform: none; }
+ 20% { transform: rotate(-2.5deg); }
+ 45% { transform: rotate(2.5deg); }
+ 70% { transform: rotate(-1.5deg); }
+ 85% { transform: rotate(0.5deg); }
+}
+/* Reduced motion: no movement — the dashed ring the wave replaced, fading
+ away on its own. (After the base rule so it wins on equal specificity.) */
+@media (prefers-reduced-motion: reduce) {
+ .msg.search-wave .bubble { animation: search-found 2s ease forwards; }
+ @keyframes search-found {
+ 0%, 50% { outline: 2px dashed var(--accent); outline-offset: 3px; }
+ 100% { outline: 2px dashed transparent; outline-offset: 3px; }
+ }
+}
/* ---- reply-thread view (apps/chat.js): tap a quote or a "N Replies" tally and
the thread lifts over the transcript, iMessage-style — the root pinned on
top, its replies below, the real composer beneath staged to the root. The
@@ -2241,9 +2258,21 @@ input.filter-check:checked::before { transform: scale(1); }
input.filter-check:focus { outline: none; }
input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.chat-search input::placeholder { color: var(--muted); }
-/* Message search results (#586): an inline sidebar section under the search
- box — rows read like slim thread rows; everything keyboard-reachable. */
-.msg-results { flex: none; overflow-y: auto; max-height: 45%; padding: 0 8px 6px; border-bottom: 1px solid var(--border); }
+/* Message search results (#586): a dropdown under the search box, floating
+ over the thread list (same treatment as the filter menu — a user-invoked,
+ transient surface, not a toast) — rows read like slim thread rows;
+ everything keyboard-reachable. The zero-height anchor keeps the sidebar
+ column's flow untouched, so nothing below shifts while searching. */
+.msg-results-anchor { position: relative; flex: none; }
+.msg-results {
+ position: absolute; top: 2px; left: 12px; right: 12px;
+ z-index: 15; /* over the thread list, under the filter menu's 20 */
+ background: var(--panel); border: 1px solid var(--border);
+ border-radius: 14px; box-shadow: 0 12px 40px var(--shadow);
+ overflow-y: auto; overflow-x: hidden;
+ max-height: 60vh; max-height: 60dvh; /* the anchor has no height, so sidebar-relative % can't work */
+ padding: 0 8px 6px;
+}
.msg-results-count { font-size: 12px; font-weight: 600; color: var(--muted); padding: 6px 8px 4px; }
.msg-result {
display: block; width: 100%; text-align: left; box-sizing: border-box;
@@ -2252,7 +2281,7 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off
}
.msg-result:hover { background: var(--bubble-them); }
.msg-result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
-/* the result currently jumped to (its message carries .msg.search-active) */
+/* the keyboard-highlighted row — the top hit by default, so Enter just opens it */
.msg-result.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.msg-result-top { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; }
.msg-result-who { font-weight: 600; }