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()} /> + +