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
committed by marcel
parent 4a93543645
commit 2430092e43
3 changed files with 162 additions and 5 deletions

View File

@@ -2,7 +2,7 @@
import type { components } from '$lib/generated/api'; 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 // 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 { formatLifeDateRange } from '$lib/person/personLifeDates';
import { untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
// Layered defence cap on the @mention search query length (CWE-400 // Layered defence cap on the @mention search query length (CWE-400
// amplification). The <input maxlength> attribute below caps direct // amplification). The <input maxlength> attribute below caps direct
@@ -31,17 +31,42 @@ type DropdownState = {
let { let {
model, model,
editorQuery = '', editorQuery = '',
onSearch = () => {} onSearch = () => {},
ondismiss = () => {},
focusOnMount = false
}: { }: {
model: DropdownState; model: DropdownState;
/** Text typed after `@` in the host editor. Mirrors into the search input /** Text typed after `@` in the host editor. Mirrors into the search input
* until the user takes manual ownership by typing into the input itself. */ * until the user takes manual ownership by typing into the input itself. */
editorQuery?: string; editorQuery?: string;
onSearch?: (query: string) => void; 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(); } = $props();
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH))); let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
let userHasEdited = $state(false); 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 // Intent-revealing alias used by both the persistent aria-live announcer and
// the visible empty-state copy. Folding the duplicated rule into one $derived // 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" /> <path d="m20 20-3.5-3.5" stroke-linecap="round" />
</svg> </svg>
<input <input
bind:this={searchInput}
id="mention-search" id="mention-search"
type="search" type="search"
data-test-search-input data-test-search-input
@@ -194,8 +220,34 @@ function selectItem(item: Person) {
oninput={() => { oninput={() => {
userHasEdited = true; userHasEdited = true;
}} }}
onkeydown={handleSearchKeydown}
onmousedown={(e) => e.stopPropagation()} 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>
</div> </div>
<!-- <!--

View File

@@ -82,8 +82,10 @@ type LooseRenderProps = {
// and (#628) the pencil re-edit affordance — so at most one dropdown is ever // and (#628) the pencil re-edit affordance — so at most one dropdown is ever
// mounted (the AC-6 single-dropdown invariant). open() closes any prior // mounted (the AC-6 single-dropdown invariant). open() closes any prior
// dropdown first; render() is a thin adapter over open()/update()/close(). // dropdown first; render() is a thin adapter over open()/update()/close().
type OpenOptions = { focusOnMount?: boolean };
type MentionController = { type MentionController = {
open: (clientRect: RectGetter, query: string, commit: CommitFn) => void; open: (clientRect: RectGetter, query: string, commit: CommitFn, opts?: OpenOptions) => void;
update: (clientRect: RectGetter, query: string, commit: CommitFn) => void; update: (clientRect: RectGetter, query: string, commit: CommitFn) => void;
close: () => void; close: () => void;
onKeyDown: (event: KeyboardEvent) => boolean; onKeyDown: (event: KeyboardEvent) => boolean;
@@ -155,7 +157,19 @@ function createMentionController(): MentionController {
dropdownState.editorQuery = query.slice(0, MAX_QUERY_LENGTH); dropdownState.editorQuery = query.slice(0, MAX_QUERY_LENGTH);
}; };
const open = (clientRect: RectGetter, query: string, commit: CommitFn) => { // Close the dropdown without touching the document and return focus to the
// editor — wired to the dropdown's × control and the re-edit Escape path.
const dismiss = () => {
close();
editor?.commands.focus();
};
const open = (
clientRect: RectGetter,
query: string,
commit: CommitFn,
opts: OpenOptions = {}
) => {
// Single-dropdown invariant: tear down any open dropdown before mounting a // Single-dropdown invariant: tear down any open dropdown before mounting a
// new one, and bump the request token so a previous open's in-flight fetch // new one, and bump the request token so a previous open's in-flight fetch
// cannot repopulate this dropdown. // cannot repopulate this dropdown.
@@ -167,6 +181,8 @@ function createMentionController(): MentionController {
target: document.body, target: document.body,
props: { props: {
model: dropdownState, model: dropdownState,
ondismiss: dismiss,
focusOnMount: opts.focusOnMount ?? false,
// MentionDropdown reads `editorQuery` off the shared state proxy via // MentionDropdown reads `editorQuery` off the shared state proxy via
// this getter — Svelte 5's mount() does not expose settable prop // this getter — Svelte 5's mount() does not expose settable prop
// accessors, so we route through the proxy (same pattern as items). // accessors, so we route through the proxy (same pattern as items).
@@ -219,7 +235,7 @@ function commitRelink(pos: number): CommitFn {
// the stored displayName. // the stored displayName.
function requestRelink(getRect: () => DOMRect | null, displayName: string, pos: number) { function requestRelink(getRect: () => DOMRect | null, displayName: string, pos: number) {
if (!editor || !editor.isEditable) return; if (!editor || !editor.isEditable) return;
controller.open(getRect, displayName, commitRelink(pos)); controller.open(getRect, displayName, commitRelink(pos), { focusOnMount: true });
} }
onMount(() => { onMount(() => {

View File

@@ -957,4 +957,93 @@ describe('PersonMentionEditor — #628 re-edit pencil', () => {
expect(host.snapshot.value).not.toContain('@Anna'); expect(host.snapshot.value).not.toContain('@Anna');
}); });
}); });
// AC-4 — dismiss leaves the node byte-identical on BOTH close paths ---------
function dismissButton() {
return page.getByRole('button', { name: m.person_mention_dismiss_label() });
}
it('AC-4: Escape closes the re-edit dropdown leaving the node byte-identical', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
search.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
);
await vi.waitFor(async () => {
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
expect(host.snapshot.value).toBe('@Aug ');
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
});
it('AC-4: the visible dismiss control closes the re-edit dropdown leaving the node byte-identical', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await expect.element(dismissButton()).toBeVisible();
const dismissEl = (await dismissButton().element()) as HTMLElement;
dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(async () => {
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
expect(host.snapshot.value).toBe('@Aug ');
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
});
// AC-9 parity — the re-edit dropdown is keyboard-operable on its own --------
it('re-edit open focuses the search input so keyboard users land in the field', async () => {
mockFetchEmpty();
await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
await vi.waitFor(() => {
expect(document.activeElement).toBe(search);
});
});
it('re-edit dropdown is keyboard-navigable: ArrowDown then Enter relinks', async () => {
mockFetchWithPersons(); // [AUGUSTE (p-aug, highlighted), ANNA (p-anna)]
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
search.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
);
search.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
);
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
expect(host.snapshot.mentionedPersons[0].displayName).toBe('Aug');
});
});
// The × control is shared with the fresh-@ dropdown ------------------------
it('the fresh-@ dropdown also exposes the shared dismiss control', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await expect.element(dismissButton()).toBeVisible();
});
}); });