Adds a visible × dismiss control to MentionDropdown (shared by the fresh-@ and re-edit paths) and, for the re-edit path which has no Tiptap suggestion plugin to forward keys, focuses the search input on open and handles its own keyboard: Escape dismisses (AC-4), Arrow/Enter reuse the exported selection logic so the dropdown is navigable on its own (AC-9 parity with the fresh-@ dropdown). Both close paths (Escape + ×) leave the mention node attrs + text byte-identical (AC-4) — close() never touches the document. Controller wires ondismiss=close (+refocus editor) and focusOnMount only for the re-edit open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
328 lines
12 KiB
Svelte
328 lines
12 KiB
Svelte
<script lang="ts">
|
||
import type { components } from '$lib/generated/api';
|
||
// eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable
|
||
import { formatLifeDateRange } from '$lib/person/personLifeDates';
|
||
import { onMount, untrack } from 'svelte';
|
||
import { m } from '$lib/paraglide/messages.js';
|
||
// Layered defence cap on the @mention search query length (CWE-400
|
||
// amplification). The <input maxlength> attribute below caps direct
|
||
// user edits, but the editor-mirror path (Tiptap contenteditable -> mirror
|
||
// $effect -> searchQuery) is not covered by `maxlength` since the
|
||
// contenteditable has no such enforcement. Clipping at the mirror keeps
|
||
// the cap honest from both paths. Tracked server-side separately.
|
||
// Nora #1 on PR #629. Hoisted to mentionConstants.ts so the host editor
|
||
// (PersonMentionEditor) can clip the inserted displayName to the same cap
|
||
// — see Felix #3 on PR #629.
|
||
import { MAX_QUERY_LENGTH } from './mentionConstants';
|
||
|
||
type Person = components['schemas']['Person'];
|
||
|
||
// 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,
|
||
editorQuery = '',
|
||
onSearch = () => {},
|
||
ondismiss = () => {},
|
||
focusOnMount = false
|
||
}: {
|
||
model: DropdownState;
|
||
/** Text typed after `@` in the host editor. Mirrors into the search input
|
||
* until the user takes manual ownership by typing into the input itself. */
|
||
editorQuery?: string;
|
||
onSearch?: (query: string) => void;
|
||
/** Closes the dropdown without touching the document — invoked by the visible
|
||
* × dismiss control and by Escape on the re-edit path (#628 AC-4). */
|
||
ondismiss?: () => void;
|
||
/** Re-edit (#628) opens with the search field focused; the fresh-@ path keeps
|
||
* focus in the editor so typing flows to the contenteditable. */
|
||
focusOnMount?: boolean;
|
||
} = $props();
|
||
|
||
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
|
||
let userHasEdited = $state(false);
|
||
let searchInput: HTMLInputElement;
|
||
|
||
onMount(() => {
|
||
if (focusOnMount) searchInput?.focus();
|
||
});
|
||
|
||
// Re-edit has no Tiptap suggestion plugin to forward keys, so the search input
|
||
// handles its own navigation: Escape dismisses (and is prevented from clearing
|
||
// the native search field), Arrow/Enter reuse the exported selection logic.
|
||
function handleSearchKeydown(event: KeyboardEvent) {
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
ondismiss();
|
||
return;
|
||
}
|
||
if (onKeyDown(event)) event.preventDefault();
|
||
}
|
||
|
||
// Intent-revealing alias used by both the persistent aria-live announcer and
|
||
// the visible empty-state copy. Folding the duplicated rule into one $derived
|
||
// keeps the two branches in lockstep. Felix #3 on PR #629 round 4.
|
||
const isQueryEmpty = $derived(searchQuery.trim() === '');
|
||
|
||
// Mirror the editor's typed text until the user takes ownership.
|
||
//
|
||
// Why `$state + $effect` (not `$derived`): `searchQuery` is also written by
|
||
// `bind:value` on the <input> below, so it needs to be a mutable `$state`.
|
||
// A `$derived` would be read-only and would clobber direct user edits on
|
||
// every editor keystroke. The `userHasEdited` latch pins ownership once the
|
||
// user types into the input. Felix #1 on PR #629.
|
||
$effect(() => {
|
||
if (!userHasEdited) {
|
||
searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH);
|
||
}
|
||
});
|
||
|
||
// Fire onSearch whenever the effective query changes — covers both the
|
||
// editor mirror and direct input edits. This is the only place onSearch
|
||
// fires; when the dropdown is unmounted, the effect is disposed and no
|
||
// further fetches occur.
|
||
$effect(() => {
|
||
onSearch(searchQuery);
|
||
});
|
||
|
||
// 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 model.items to subscribe; reset index whenever the list is replaced.
|
||
void model.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>(() => {
|
||
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.
|
||
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 {
|
||
const len = model.items.length;
|
||
if (event.key === 'ArrowDown') {
|
||
highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
|
||
return true;
|
||
}
|
||
|
||
if (event.key === 'ArrowUp') {
|
||
highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
|
||
return true;
|
||
}
|
||
|
||
if (event.key === 'Enter') {
|
||
const selected = model.items[highlightedIndex];
|
||
if (selected) {
|
||
model.command(selected);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Escape: let the suggestion plugin handle it (return false = not consumed).
|
||
return false;
|
||
}
|
||
|
||
function selectItem(item: Person) {
|
||
model.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="fixed z-50 w-72 max-w-[calc(100vw-1rem)] 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}
|
||
>
|
||
<div class="border-b border-line px-3 py-2">
|
||
<label class="sr-only" for="mention-search">{m.person_mention_search_label()}</label>
|
||
<div class="flex items-center gap-2">
|
||
<svg
|
||
aria-hidden="true"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
class="h-5 w-5 shrink-0 text-ink-2"
|
||
>
|
||
<circle cx="11" cy="11" r="7" />
|
||
<path d="m20 20-3.5-3.5" stroke-linecap="round" />
|
||
</svg>
|
||
<input
|
||
bind:this={searchInput}
|
||
id="mention-search"
|
||
type="search"
|
||
data-test-search-input
|
||
maxlength={MAX_QUERY_LENGTH}
|
||
class="min-h-[44px] w-full bg-transparent font-sans text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
|
||
placeholder={m.person_mention_search_prompt()}
|
||
bind:value={searchQuery}
|
||
oninput={() => {
|
||
userHasEdited = true;
|
||
}}
|
||
onkeydown={handleSearchKeydown}
|
||
onmousedown={(e) => e.stopPropagation()}
|
||
/>
|
||
<!--
|
||
Visible dismiss control (#628 AC-4) — shared by the fresh-@ and the
|
||
re-edit paths. onmousedown preventDefault keeps the editor selection
|
||
from blurring before onclick fires; onclick handles both pointer and
|
||
keyboard (Enter/Space) activation.
|
||
-->
|
||
<button
|
||
type="button"
|
||
data-test-dismiss
|
||
aria-label={m.person_mention_dismiss_label()}
|
||
class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-sm text-ink-2 hover:bg-canvas focus:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
|
||
onmousedown={(e) => e.preventDefault()}
|
||
onclick={() => ondismiss()}
|
||
>
|
||
<svg
|
||
aria-hidden="true"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
class="h-5 w-5"
|
||
>
|
||
<path d="M6 6l12 12M18 6 6 18" stroke-linecap="round" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!--
|
||
Persistent aria-live region — lives ABOVE the conditional branches so the
|
||
element never unmounts when items transition between empty and populated.
|
||
VoiceOver in particular swallows announcements from freshly-mounted live
|
||
regions, and the previous (conditional-inside) markup silently dropped
|
||
the "N persons found" announcement when results populated. Leonie #3 on
|
||
PR #629 round 3.
|
||
-->
|
||
<p class="sr-only" aria-live="polite">
|
||
{#if model.items.length === 0}
|
||
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
|
||
{:else if model.items.length === 1}
|
||
{m.person_mention_results_count_singular()}
|
||
{:else}
|
||
{m.person_mention_results_count_plural({ count: model.items.length })}
|
||
{/if}
|
||
</p>
|
||
{#if model.items.length === 0}
|
||
<!--
|
||
Visible empty-state copy — visual-only. The persistent sr-only <p>
|
||
above is the sole AT announcer; this one is hidden from screen readers
|
||
via aria-hidden="true" so VoiceOver does not double-announce
|
||
(NVDA de-dups, VoiceOver does not). Leonie S-2 on PR #629 round 4.
|
||
Do NOT add an aria-live attribute here — that would re-introduce
|
||
the duplicate announcement.
|
||
-->
|
||
<p aria-hidden="true" class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
|
||
</p>
|
||
<!--
|
||
Empty-state escape hatch — without it the transcriber has to close
|
||
the dropdown, navigate to /persons/new, come back, and re-type the
|
||
query. target=_blank keeps the document and editor state intact;
|
||
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
|
||
-->
|
||
<a
|
||
href="/persons/new"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
|
||
onmousedown={(e) => e.preventDefault()}
|
||
>
|
||
{m.person_mention_create_new()}
|
||
<span aria-hidden="true">→</span>
|
||
</a>
|
||
{:else}
|
||
{#each model.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',
|
||
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
|
||
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
|
||
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
|
||
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy 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>
|