feat(transcription): dismiss + keyboard-operate the re-edit dropdown (#628 AC-4/AC-9)

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>
This commit is contained in:
Marcel
2026-06-02 19:40:37 +02:00
parent 02c95b9cfc
commit 9deaaae3e8
3 changed files with 162 additions and 5 deletions

View File

@@ -2,7 +2,7 @@
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 { untrack } from 'svelte';
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
@@ -31,17 +31,42 @@ type DropdownState = {
let {
model,
editorQuery = '',
onSearch = () => {}
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
@@ -184,6 +209,7 @@ function selectItem(item: Person) {
<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
@@ -194,8 +220,34 @@ function selectItem(item: Person) {
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>
<!--