diff --git a/frontend/src/lib/components/MentionDropdown.svelte b/frontend/src/lib/components/MentionDropdown.svelte index 7d01145a..acfaae49 100644 --- a/frontend/src/lib/components/MentionDropdown.svelte +++ b/frontend/src/lib/components/MentionDropdown.svelte @@ -5,11 +5,18 @@ import { m } from '$lib/paraglide/messages.js'; type Person = components['schemas']['Person']; -// All reactive state is driven externally by the Tiptap suggestion plugin. -// The parent writes to these after mount() via the exported bindings. -let items = $state([]); -let command = $state<(item: Person) => void>(() => {}); -let clientRect = $state<(() => DOMRect | null) | null>(null); +// The dropdown receives a single reactive state object. PersonMentionEditor +// mutates fields on this object (model.items = ..., etc.) and Svelte's $state +// proxy reactivity propagates the change here. This is the supported way to +// update an imperatively-mounted Svelte 5 component — `mount` does not return +// settable prop accessors. +type DropdownState = { + items: Person[]; + command: (item: Person) => void; + clientRect: (() => DOMRect | null) | null; +}; + +let { model }: { model: DropdownState } = $props(); // 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). @@ -19,8 +26,8 @@ let clientRect = $state<(() => DOMRect | null) | null>(null); let highlightedIndex = $state(0); $effect(() => { - // Read items to subscribe; reset index whenever the list is replaced. - void items; + // Read model.items to subscribe; reset index whenever the list is replaced. + void model.items; highlightedIndex = 0; }); @@ -38,8 +45,9 @@ type Position = { const DROPDOWN_CLEARANCE_PX = 200; const position = $derived.by(() => { - if (!clientRect) return { top: '0px', bottom: null, left: '0px' }; - const rect = clientRect(); + const cr = model.clientRect; + if (!cr) return { top: '0px', bottom: null, left: '0px' }; + const rect = cr(); if (!rect) return { top: '0px', bottom: null, left: '0px' }; // Some editors report a caret DOMRect with zero width; fall back to rect.x. @@ -63,21 +71,21 @@ const position = $derived.by(() => { // --------------------------------------------------------------------------- export function onKeyDown(event: KeyboardEvent): boolean { + const len = model.items.length; if (event.key === 'ArrowDown') { - highlightedIndex = (highlightedIndex + 1) % Math.max(items.length, 1); + highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1); return true; } if (event.key === 'ArrowUp') { - highlightedIndex = - (highlightedIndex - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1); + highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1); return true; } if (event.key === 'Enter') { - const selected = items[highlightedIndex]; + const selected = model.items[highlightedIndex]; if (selected) { - command(selected); + model.command(selected); } return true; } @@ -87,7 +95,7 @@ export function onKeyDown(event: KeyboardEvent): boolean { } function selectItem(item: Person) { - command(item); + model.command(item); } @@ -103,19 +111,19 @@ function selectItem(item: Person) { unauthenticated users. -->
- {#if items.length === 0} + {#if model.items.length === 0}

{m.person_mention_popup_empty()}

{:else} - {#each items as person, i (person.id)} + {#each model.items as person, i (person.id)}