feat(transcription): wire dropdown search input to editor @-text

For issue #380. The search input mirrors the @-text the user types until
the user takes ownership by typing into the input itself. After that,
the input owns its own state and editor typing no longer overrides it.

Two empty states now exist:
- "Namen eingeben…" when the search input is empty (AC-4)
- "Keine Personen gefunden" when the search input has a query but the
  list is empty (existing behavior)

The dropdown reads editorQuery through the shared $state proxy via a
getter prop, matching the established pattern for model.items.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-19 21:03:13 +02:00
committed by marcel
parent ff3e8fb755
commit bdc0b112b6
4 changed files with 86 additions and 16 deletions

View File

@@ -20,17 +20,25 @@ type DropdownState = {
let {
model,
initialQuery = '',
editorQuery = '',
onSearch = () => {}
}: {
model: DropdownState;
initialQuery?: string;
/** Text typed after `@` in the host editor. Mirrors into the search input
* until the user takes manual ownership by typing into the input itself. */
editorQuery?: string;
onSearch?: (query: string) => void;
} = $props();
// initialQuery is a one-shot prop — PersonMentionEditor mounts a fresh dropdown
// with the typed text on each Tiptap onStart, so we deliberately snapshot here.
let searchQuery = $state(untrack(() => initialQuery));
let searchQuery = $state(untrack(() => editorQuery));
let userHasEdited = $state(false);
// Mirror the editor's typed text until the user takes ownership.
$effect(() => {
if (!userHasEdited) {
searchQuery = editorQuery;
}
});
// highlightedIndex must be both writable (keyboard handler mutates it) and
// reset when `items` changes (so it never points past the end of a new list).
@@ -153,15 +161,24 @@ function selectItem(item: Person) {
class="min-h-[44px] w-full bg-transparent font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
placeholder={m.person_mention_search_prompt()}
bind:value={searchQuery}
oninput={(e) => onSearch(e.currentTarget.value)}
oninput={(e) => {
userHasEdited = true;
onSearch(e.currentTarget.value);
}}
onmousedown={(e) => e.stopPropagation()}
/>
</div>
</div>
{#if model.items.length === 0}
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
{m.person_mention_popup_empty()}
</p>
{#if searchQuery.trim() === ''}
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
{m.person_mention_search_prompt()}
</p>
{:else}
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
{m.person_mention_popup_empty()}
</p>
{/if}
<!--
Empty-state escape hatch — without it the transcriber has to close
the dropdown, navigate to /persons/new, come back, and re-type the