diff --git a/frontend/src/routes/feed/+page.svelte b/frontend/src/routes/feed/+page.svelte index e1f7063..503b276 100644 --- a/frontend/src/routes/feed/+page.svelte +++ b/frontend/src/routes/feed/+page.svelte @@ -120,30 +120,59 @@ let allUploaders = $derived([...new Set(uploads.map((u) => u.uploader_name))].sort()); + // The suggestion SOURCE is frozen for as long as the dropdown is open. + // + // `allTags`/`allUploaders` derive from `uploads`, which mutates under us constantly: every + // `upload-processed` / `new-upload` SSE triggers `refreshFeedInPlace`, which replaces the array. + // `allTags` is ordered by FREQUENCY, so a photo landing mid-interaction can REORDER the open + // dropdown — the user presses "#wedding" and the list reshuffles under their finger. At a party, + // where photos stream in the whole time, that is a live mis-tap hazard, not a theoretical one. + // (It also detaches the button mid-press: the handler is `onmousedown`, so a re-render between + // mousedown and mouseup destroys the element being pressed.) + // + // Freezing on focus keeps the list the user is looking at identical to the list they act on. + // Typing still filters — it just filters a stable source. New photos appear in the suggestions + // the next time the dropdown is opened, which is soon enough for a filter picker. + let frozenTags = $state([]); + let frozenUploaders = $state([]); + + // Re-snapshot only when actually opening. Called on focus AND on click/input, because after a + // selection the input keeps focus (the dropdown suppresses the blur) — so `onfocus` will not + // fire again, and without these the picker could never be reopened without clicking away first. + // Guarding on `showAutocomplete` keeps typing from re-freezing on every keystroke, which would + // let the list churn again mid-interaction and undo the point of freezing it. + function openAutocomplete() { + if (!showAutocomplete) { + frozenTags = allTags; + frozenUploaders = allUploaders; + } + showAutocomplete = true; + } + let suggestions = $derived.by((): Filter[] => { const q = searchQuery.trim(); if (!q) { // Show top suggestions on focus if (!showAutocomplete) return []; return [ - ...allUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })), - ...allTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t })), + ...frozenUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })), + ...frozenTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t })), ]; } if (q.startsWith('#')) { const prefix = q.slice(1).toLowerCase(); - return allTags + return frozenTags .filter((t) => t.startsWith(prefix)) .slice(0, 8) .map((t) => ({ type: 'tag' as const, value: t })); } const lower = q.toLowerCase(); return [ - ...allUploaders + ...frozenUploaders .filter((u) => u.toLowerCase().includes(lower)) .slice(0, 4) .map((u) => ({ type: 'user' as const, value: u })), - ...allTags + ...frozenTags .filter((t) => t.includes(lower)) .slice(0, 4) .map((t) => ({ type: 'tag' as const, value: t })), @@ -568,10 +597,12 @@ placeholder="Nutzer oder #Tag suchen…" bind:value={searchQuery} onfocus={(e) => { - showAutocomplete = true; + openAutocomplete(); // Push the input above the virtual keyboard so suggestions stay visible. (e.currentTarget as HTMLInputElement).scrollIntoView({ block: 'center', behavior: 'smooth' }); }} + onclick={openAutocomplete} + oninput={openAutocomplete} onblur={() => setTimeout(() => (showAutocomplete = false), 150)} class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500" /> @@ -590,11 +621,31 @@ {#if showAutocomplete && suggestions.length > 0} -
- {#each suggestions as item} + +
e.preventDefault()} + role="listbox" + tabindex="-1" + > + {#each suggestions as item (item.type + ':' + item.value)}