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:
@@ -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>
|
||||
<!--
|
||||
|
||||
Reference in New Issue
Block a user