refactor(MentionDropdown): receive reactive state via single 'model' prop

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 15:52:45 +02:00
parent e5634c301e
commit 39ddf90725

View File

@@ -5,11 +5,18 @@ import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
// All reactive state is driven externally by the Tiptap suggestion plugin. // The dropdown receives a single reactive state object. PersonMentionEditor
// The parent writes to these after mount() via the exported bindings. // mutates fields on this object (model.items = ..., etc.) and Svelte's $state
let items = $state<Person[]>([]); // proxy reactivity propagates the change here. This is the supported way to
let command = $state<(item: Person) => void>(() => {}); // update an imperatively-mounted Svelte 5 component — `mount` does not return
let clientRect = $state<(() => DOMRect | null) | null>(null); // 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 // 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). // 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); let highlightedIndex = $state(0);
$effect(() => { $effect(() => {
// Read items to subscribe; reset index whenever the list is replaced. // Read model.items to subscribe; reset index whenever the list is replaced.
void items; void model.items;
highlightedIndex = 0; highlightedIndex = 0;
}); });
@@ -38,8 +45,9 @@ type Position = {
const DROPDOWN_CLEARANCE_PX = 200; const DROPDOWN_CLEARANCE_PX = 200;
const position = $derived.by<Position>(() => { const position = $derived.by<Position>(() => {
if (!clientRect) return { top: '0px', bottom: null, left: '0px' }; const cr = model.clientRect;
const rect = clientRect(); if (!cr) return { top: '0px', bottom: null, left: '0px' };
const rect = cr();
if (!rect) return { top: '0px', bottom: null, left: '0px' }; if (!rect) return { top: '0px', bottom: null, left: '0px' };
// Some editors report a caret DOMRect with zero width; fall back to rect.x. // Some editors report a caret DOMRect with zero width; fall back to rect.x.
@@ -63,21 +71,21 @@ const position = $derived.by<Position>(() => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function onKeyDown(event: KeyboardEvent): boolean { export function onKeyDown(event: KeyboardEvent): boolean {
const len = model.items.length;
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
highlightedIndex = (highlightedIndex + 1) % Math.max(items.length, 1); highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
return true; return true;
} }
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
highlightedIndex = highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
(highlightedIndex - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1);
return true; return true;
} }
if (event.key === 'Enter') { if (event.key === 'Enter') {
const selected = items[highlightedIndex]; const selected = model.items[highlightedIndex];
if (selected) { if (selected) {
command(selected); model.command(selected);
} }
return true; return true;
} }
@@ -87,7 +95,7 @@ export function onKeyDown(event: KeyboardEvent): boolean {
} }
function selectItem(item: Person) { function selectItem(item: Person) {
command(item); model.command(item);
} }
</script> </script>
@@ -103,19 +111,19 @@ function selectItem(item: Person) {
unauthenticated users. unauthenticated users.
--> -->
<div <div
class="absolute z-20 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg" class="fixed z-20 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox" role="listbox"
aria-label={m.person_mention_btn_label()} aria-label={m.person_mention_btn_label()}
style:top={position.top} style:top={position.top}
style:bottom={position.bottom} style:bottom={position.bottom}
style:left={position.left} style:left={position.left}
> >
{#if items.length === 0} {#if model.items.length === 0}
<p class="px-3 py-2.5 font-sans text-sm text-ink-3"> <p class="px-3 py-2.5 font-sans text-sm text-ink-3">
{m.person_mention_popup_empty()} {m.person_mention_popup_empty()}
</p> </p>
{:else} {:else}
{#each items as person, i (person.id)} {#each model.items as person, i (person.id)}
<div <div
class={[ class={[
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas', 'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',