feat(frontend): add MentionDropdown — Tiptap suggestion-compatible person dropdown
Replaces PersonMentionEditor's inline popup for the Tiptap migration. Mounted imperatively to document.body by the suggestion plugin's render() lifecycle. Supports flip-upward strategy when viewport space is tight (Leonie #5602 mobile keyboard concern). 44px touch targets, WCAG accessible. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
143
frontend/src/lib/components/MentionDropdown.svelte
Normal file
143
frontend/src/lib/components/MentionDropdown.svelte
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
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<Person[]>([]);
|
||||
let command = $state<(item: Person) => void>(() => {});
|
||||
let clientRect = $state<(() => DOMRect | null) | null>(null);
|
||||
|
||||
// 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).
|
||||
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
|
||||
// is the correct pattern here. The autofixer suggestion to use $derived does not
|
||||
// apply: the mutation in onKeyDown is not a derivation.
|
||||
let highlightedIndex = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
// Read items to subscribe; reset index whenever the list is replaced.
|
||||
void items;
|
||||
highlightedIndex = 0;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Positioning — flip strategy: open upward when there is not enough room
|
||||
// below the cursor to show the dropdown without clipping the viewport.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Position = {
|
||||
top: string | null;
|
||||
bottom: string | null;
|
||||
left: string;
|
||||
};
|
||||
|
||||
const DROPDOWN_CLEARANCE_PX = 200;
|
||||
|
||||
const position = $derived.by<Position>(() => {
|
||||
if (!clientRect) return { top: '0px', bottom: null, left: '0px' };
|
||||
const rect = clientRect();
|
||||
if (!rect) return { top: '0px', bottom: null, left: '0px' };
|
||||
|
||||
// Some editors report a caret DOMRect with zero width; fall back to rect.x.
|
||||
const left = `${rect.width === 0 ? rect.x : rect.left}px`;
|
||||
|
||||
if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
|
||||
// Not enough space below — anchor bottom of dropdown to top of caret.
|
||||
return {
|
||||
top: null,
|
||||
bottom: `${window.innerHeight - rect.top}px`,
|
||||
left
|
||||
};
|
||||
}
|
||||
|
||||
return { top: `${rect.bottom}px`, bottom: null, left };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard handler — exported so Tiptap's render() can forward events.
|
||||
// Returns true when the event is consumed (prevents the editor's default).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function onKeyDown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'ArrowDown') {
|
||||
highlightedIndex = (highlightedIndex + 1) % Math.max(items.length, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
highlightedIndex =
|
||||
(highlightedIndex - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
const selected = items[highlightedIndex];
|
||||
if (selected) {
|
||||
command(selected);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Escape: let the suggestion plugin handle it (return false = not consumed).
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectItem(item: Person) {
|
||||
command(item);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Mounted imperatively to document.body by the Tiptap suggestion plugin.
|
||||
Positioned absolutely relative to the viewport using inline styles derived
|
||||
from the Tiptap clientRect() callback.
|
||||
|
||||
SECURITY: This component receives pre-filtered Person[] items from the
|
||||
parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
|
||||
proxy injecting the auth_token cookie as the Authorization header.
|
||||
Mounted in transcribe mode behind WRITE_ALL — never reachable to
|
||||
unauthenticated users.
|
||||
-->
|
||||
<div
|
||||
class="absolute z-20 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||
role="listbox"
|
||||
aria-label={m.person_mention_btn_label()}
|
||||
style:top={position.top}
|
||||
style:bottom={position.bottom}
|
||||
style:left={position.left}
|
||||
>
|
||||
{#if items.length === 0}
|
||||
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||||
{m.person_mention_popup_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
{#each items as person, i (person.id)}
|
||||
<div
|
||||
class={[
|
||||
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
|
||||
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-mint ring-inset'
|
||||
]}
|
||||
role="option"
|
||||
aria-selected={i === highlightedIndex}
|
||||
data-test-person-id={person.id}
|
||||
tabindex="-1"
|
||||
onmousedown={(e) => {
|
||||
// Prevent blur on the editor before the selection fires.
|
||||
e.preventDefault();
|
||||
selectItem(person);
|
||||
}}
|
||||
>
|
||||
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
|
||||
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
|
||||
<span class="truncate font-sans text-xs text-ink-3">
|
||||
{formatLifeDateRange(person.birthYear, person.deathYear)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user