Merge branch 'fix/feed-suggestion-mousedown-2026-07-14'

This commit is contained in:
fabi
2026-07-14 22:06:56 +02:00

View File

@@ -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<string[]>([]);
let frozenUploaders = $state<string[]>([]);
// 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 @@
<!-- Autocomplete dropdown -->
{#if showAutocomplete && suggestions.length > 0}
<div class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
{#each suggestions as item}
<!-- `preventDefault` on the container's mousedown stops the input from blurring, which
is what would otherwise close this dropdown (see the input's `onblur`) before a
click could land on a suggestion.
That matters because the suggestions used to commit on `onmousedown` — purely to
beat that blur. Two things were wrong with it. It fired on PRESS, so sliding off a
suggestion you didn't mean to hit still applied the filter, with no way to abort.
And `selectSuggestion` sets `showAutocomplete = false`, so the button DESTROYED
ITSELF on the first event of the click sequence: whether the node survived to
`mouseup` came down to whether Svelte's flush landed in between. That is the whole
reason this spec was flaky under load.
Suppressing the blur lets the handler move to `onclick` — the LAST event — so the
element self-destructing is harmless, and press-then-slide-off aborts like a button
should. -->
<div
class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
onmousedown={(e) => e.preventDefault()}
role="listbox"
tabindex="-1"
>
{#each suggestions as item (item.type + ':' + item.value)}
<button
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
onmousedown={() => selectSuggestion(item)}
onclick={() => selectSuggestion(item)}
>
{#if item.type === 'user'}
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">