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
parent 02c95b9cfc
commit 9deaaae3e8
3 changed files with 162 additions and 5 deletions

View File

@@ -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();
});
});