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';
|
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>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user