From 39ddf90725701d1ecfcd3013101d7392d586aa9e Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 15:52:45 +0200 Subject: [PATCH] refactor(MentionDropdown): receive reactive state via single 'model' prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Svelte 5's mount() does not return prop accessors — setting 'instance.items = newValue' is a no-op. Switching to a single $state proxy passed as 'model' lets the parent mutate fields and have the dropdown react. The prop is named 'model' (not 'state') because the $state rune name shadows a 'state' identifier in Svelte 5 templates. Position class also switches from absolute to fixed so viewport- relative DOMRect coordinates from clientRect() work when the dropdown is mounted on document.body. Co-Authored-By: Claude Opus 4.7 --- .../src/lib/components/MentionDropdown.svelte | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) 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)}