diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts index 5a97fb18..7ead48f9 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; +import { flushSync, mount, unmount } from 'svelte'; import MentionDropdown from './MentionDropdown.svelte'; import MentionDropdownFixture from './MentionDropdown.test-fixture.svelte'; import { m } from '$lib/paraglide/messages.js'; @@ -218,3 +219,121 @@ describe('MentionDropdown — search input', () => { await expect.element(page.getByRole('searchbox')).toHaveValue('Walter'); }); }); + +// ─── ArrowDown via exported onKeyDown (Sara #3 on PR #629) ────────────────── +// +// In production, Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level +// and forwards them to the dropdown via its exported onKeyDown(event) function +// — the dropdown itself has no DOM keydown listener. This test exercises the +// same export so a regression in highlightedIndex/selection logic is caught +// at the unit level. The full E2E focus-chain test is deferred to a separate +// issue (Playwright). + +describe('MentionDropdown — onKeyDown forwarding', () => { + it('ArrowDown advances aria-selected to the next option in the listbox', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const instance = mount(MentionDropdown, { + target: container, + props: { + model: baseModel({ + items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')] + }) + } + }); + try { + const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean }; + + // First option starts highlighted. + const first = container.querySelector('[data-test-person-id="p1"]') as HTMLElement; + const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement; + expect(first.getAttribute('aria-selected')).toBe('true'); + expect(second.getAttribute('aria-selected')).toBe('false'); + + let consumed = false; + flushSync(() => { + consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + }); + expect(consumed).toBe(true); + + expect(first.getAttribute('aria-selected')).toBe('false'); + expect(second.getAttribute('aria-selected')).toBe('true'); + } finally { + unmount(instance); + container.remove(); + } + }); + + it('ArrowUp wraps from the first option to the last', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const instance = mount(MentionDropdown, { + target: container, + props: { + model: baseModel({ + items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')] + }) + } + }); + try { + const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean }; + + let consumed = false; + flushSync(() => { + consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + }); + expect(consumed).toBe(true); + + const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement; + expect(second.getAttribute('aria-selected')).toBe('true'); + } finally { + unmount(instance); + container.remove(); + } + }); + + it('Enter invokes model.command with the currently highlighted item', async () => { + const command = vi.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + const instance = mount(MentionDropdown, { + target: container, + props: { + model: baseModel({ + items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')], + command + }) + } + }); + try { + const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean }; + + const consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(consumed).toBe(true); + expect(command).toHaveBeenCalledTimes(1); + expect(command.mock.calls[0][0].id).toBe('p1'); + } finally { + unmount(instance); + container.remove(); + } + }); + + it('Escape returns false so the suggestion plugin can handle it', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const instance = mount(MentionDropdown, { + target: container, + props: { + model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] }) + } + }); + try { + const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean }; + const consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(consumed).toBe(false); + } finally { + unmount(instance); + container.remove(); + } + }); +});