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:
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user