From 9deaaae3e88c3a80110b8e12c2523718a5d80aa5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 2 Jun 2026 19:40:37 +0200 Subject: [PATCH] feat(transcription): dismiss + keyboard-operate the re-edit dropdown (#628 AC-4/AC-9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../shared/discussion/MentionDropdown.svelte | 56 +++++++++++- .../discussion/PersonMentionEditor.svelte | 22 ++++- .../PersonMentionEditor.svelte.spec.ts | 89 +++++++++++++++++++ 3 files changed, 162 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte b/frontend/src/lib/shared/discussion/MentionDropdown.svelte index c647f434..48ee6db7 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte @@ -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 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) { { userHasEdited = true; }} + onkeydown={handleSearchKeydown} onmousedown={(e) => e.stopPropagation()} /> + +