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>
|
||||
<!--
|
||||
|
||||
@@ -82,8 +82,10 @@ type LooseRenderProps = {
|
||||
// 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
|
||||
// dropdown first; render() is a thin adapter over open()/update()/close().
|
||||
type OpenOptions = { focusOnMount?: boolean };
|
||||
|
||||
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;
|
||||
close: () => void;
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
@@ -155,7 +157,19 @@ function createMentionController(): MentionController {
|
||||
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
|
||||
// new one, and bump the request token so a previous open's in-flight fetch
|
||||
// cannot repopulate this dropdown.
|
||||
@@ -167,6 +181,8 @@ function createMentionController(): MentionController {
|
||||
target: document.body,
|
||||
props: {
|
||||
model: dropdownState,
|
||||
ondismiss: dismiss,
|
||||
focusOnMount: opts.focusOnMount ?? false,
|
||||
// MentionDropdown reads `editorQuery` off the shared state proxy via
|
||||
// this getter — Svelte 5's mount() does not expose settable prop
|
||||
// accessors, so we route through the proxy (same pattern as items).
|
||||
@@ -219,7 +235,7 @@ function commitRelink(pos: number): CommitFn {
|
||||
// the stored displayName.
|
||||
function requestRelink(getRect: () => DOMRect | null, displayName: string, pos: number) {
|
||||
if (!editor || !editor.isEditable) return;
|
||||
controller.open(getRect, displayName, commitRelink(pos));
|
||||
controller.open(getRect, displayName, commitRelink(pos), { focusOnMount: true });
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -957,4 +957,93 @@ describe('PersonMentionEditor — #628 re-edit pencil', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user