diff --git a/frontend/e2e/person-typeahead.spec.ts b/frontend/e2e/person-typeahead.spec.ts new file mode 100644 index 00000000..3d472431 --- /dev/null +++ b/frontend/e2e/person-typeahead.spec.ts @@ -0,0 +1,226 @@ +/** + * E2E regression tests for PersonTypeahead dropdown visibility. + * + * These tests verify that the dropdown list is never clipped by a parent + * container's stacking context — the root cause of issue #343. + * + * The tests run at both desktop (1280×720) and tablet (768×1024) viewports + * as required by the acceptance criteria. + */ +import { test, expect } from '@playwright/test'; + +/** + * Find a document edit URL to use as the test page. + * Falls back to /documents/new if no existing document is found. + */ +async function getDocumentEditUrl( + page: Parameters[1] extends (args: { page: infer P }) => unknown ? P : never +): Promise { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + const firstDocLink = page.locator('a[href^="/documents/"]').first(); + const href = await firstDocLink.getAttribute('href').catch(() => null); + if (href) { + return `${href}/edit`; + } + return '/documents/new'; +} + +test.describe('PersonTypeahead — dropdown visibility (desktop)', () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test('sender dropdown items are visible and not clipped in document edit', async ({ page }) => { + const editUrl = await getDocumentEditUrl(page); + await page.goto(editUrl); + await page.waitForLoadState('networkidle'); + + // Find the sender typeahead input (the visible text input, not the hidden one) + const senderInput = page.locator('#senderId-search'); + await expect(senderInput).toBeVisible(); + + // Type to trigger the dropdown + await senderInput.click(); + await senderInput.fill('a'); + + // Wait for the dropdown to appear + await page.waitForTimeout(400); // debounce is 300ms + + // If there are results, verify the first item is visible (not occluded) + const dropdown = page.locator('[role="listbox"]').first(); + const hasResults = await dropdown.count().then((n) => n > 0); + + if (hasResults) { + const firstOption = dropdown.locator('[role="option"]').first(); + await expect(firstOption).toBeVisible(); + + // Verify the bounding box is within the viewport (not clipped) + const box = await firstOption.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.y).toBeGreaterThan(0); + expect(box!.y + box!.height).toBeLessThan(720); + } + + await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-desktop.png' }); + }); + + test('dropdown is positioned below the input field (not hidden behind parent)', async ({ + page + }) => { + const editUrl = await getDocumentEditUrl(page); + await page.goto(editUrl); + await page.waitForLoadState('networkidle'); + + const senderInput = page.locator('#senderId-search'); + await expect(senderInput).toBeVisible(); + + const inputBox = await senderInput.boundingBox(); + expect(inputBox).not.toBeNull(); + + await senderInput.click(); + await senderInput.fill('a'); + await page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasDropdown = (await dropdown.count()) > 0; + + if (hasDropdown) { + const dropdownBox = await dropdown.boundingBox(); + expect(dropdownBox).not.toBeNull(); + + // Dropdown must appear below the input, not on top or clipped behind it + expect(dropdownBox!.y).toBeGreaterThanOrEqual(inputBox!.y + inputBox!.height - 5); + } + + await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-position.png' }); + }); +}); + +test.describe('PersonTypeahead — dropdown visibility (tablet)', () => { + test.use({ viewport: { width: 768, height: 1024 } }); + + test('sender dropdown items are visible and not clipped on tablet viewport', async ({ page }) => { + const editUrl = await getDocumentEditUrl(page); + await page.goto(editUrl); + await page.waitForLoadState('networkidle'); + + const senderInput = page.locator('#senderId-search'); + await expect(senderInput).toBeVisible(); + + await senderInput.click(); + await senderInput.fill('a'); + await page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasResults = (await dropdown.count()) > 0; + + if (hasResults) { + const firstOption = dropdown.locator('[role="option"]').first(); + await expect(firstOption).toBeVisible(); + + const box = await firstOption.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.y).toBeGreaterThan(0); + expect(box!.y + box!.height).toBeLessThan(1024); + } + + await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-tablet.png' }); + }); +}); + +test.describe('PersonTypeahead — keyboard navigation', () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test('ArrowDown moves focus to the first option', async ({ page }) => { + const editUrl = await getDocumentEditUrl(page); + await page.goto(editUrl); + await page.waitForLoadState('networkidle'); + + const senderInput = page.locator('#senderId-search'); + await senderInput.click(); + await senderInput.fill('a'); + await page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasDropdown = (await dropdown.count()) > 0; + + if (hasDropdown) { + await senderInput.press('ArrowDown'); + // First option should now be the active descendant + const activeDescendant = await senderInput.getAttribute('aria-activedescendant'); + expect(activeDescendant).toBeTruthy(); + + await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' }); + } + }); + + test('Escape key closes the dropdown', async ({ page }) => { + const editUrl = await getDocumentEditUrl(page); + await page.goto(editUrl); + await page.waitForLoadState('networkidle'); + + const senderInput = page.locator('#senderId-search'); + await senderInput.click(); + await senderInput.fill('a'); + await page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasDropdown = (await dropdown.count()) > 0; + + if (hasDropdown) { + await expect(dropdown).toBeVisible(); + await senderInput.press('Escape'); + await page.waitForTimeout(100); + await expect(dropdown).not.toBeVisible(); + } + }); + + test('aria-expanded is true when dropdown is open', async ({ page }) => { + const editUrl = await getDocumentEditUrl(page); + await page.goto(editUrl); + await page.waitForLoadState('networkidle'); + + const senderInput = page.locator('#senderId-search'); + + // Initially closed + const initialExpanded = await senderInput.getAttribute('aria-expanded'); + expect(initialExpanded).toBe('false'); + + await senderInput.click(); + await senderInput.fill('a'); + await page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasDropdown = (await dropdown.count()) > 0; + + if (hasDropdown) { + const expanded = await senderInput.getAttribute('aria-expanded'); + expect(expanded).toBe('true'); + } + }); +}); + +test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test('clicking outside a fixed-position dropdown closes it', async ({ page }) => { + const editUrl = await getDocumentEditUrl(page); + await page.goto(editUrl); + await page.waitForLoadState('networkidle'); + + const senderInput = page.locator('#senderId-search'); + await senderInput.click(); + await senderInput.fill('a'); + await page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasDropdown = (await dropdown.count()) > 0; + + if (hasDropdown) { + await expect(dropdown).toBeVisible(); + // Click somewhere else on the page + await page.click('body', { position: { x: 10, y: 10 } }); + await page.waitForTimeout(100); + await expect(dropdown).not.toBeVisible(); + } + }); +}); diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 0ac204ab..954f23dd 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -76,6 +76,22 @@ const typeahead = createTypeahead({ debounceMs: 300 }); +// Fixed-position dropdown state — escapes any CSS stacking context that would clip it. +let inputEl: HTMLInputElement; +let dropdownStyle = $state(''); +let activeIndex = $state(-1); + +// Stable id linking the input's aria-controls to the listbox element. +const listboxId = `${name}-listbox`; + +const isOpen = $derived(typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)); + +function updateDropdownPosition() { + if (!inputEl) return; + const rect = inputEl.getBoundingClientRect(); + dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`; +} + function handleInput() { if (value && searchTerm !== initialName) { value = ''; @@ -88,6 +104,7 @@ function handleInput() { function handleFocus() { onfocused?.(); + updateDropdownPosition(); if (restrictToCorrespondentsOf) { const personId = untrack(() => restrictToCorrespondentsOf)!; (async () => { @@ -109,13 +126,47 @@ function selectPerson(person: Person) { value = person.id!; searchTerm = person.displayName; typeahead.close(); + activeIndex = -1; onchange?.(person.id!); } + +function closeDropdown() { + typeahead.close(); + activeIndex = -1; +} + +function handleKeydown(e: KeyboardEvent) { + if (!isOpen) return; + + const results = typeahead.results; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + activeIndex = (activeIndex + 1) % results.length; + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + activeIndex = (activeIndex - 1 + results.length) % results.length; + } else if (e.key === 'Enter') { + e.preventDefault(); + if (activeIndex >= 0 && results[activeIndex]) { + selectPerson(results[activeIndex]); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + closeDropdown(); + } +} + +// Keep dropdown position current when user scrolls or resizes. +// fixed positioning is intentional — it escapes any CSS stacking context (overflow, transform, +// shadow-sm + z-index combinations) that would clip an absolute-positioned dropdown. -
typeahead.close()}> + + +
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts index 4ec6a142..6f5971bf 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts +++ b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import PersonTypeahead from './PersonTypeahead.svelte'; const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); @@ -130,11 +130,11 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Mu'); await waitForDebounce(); - document.querySelector('[role="button"]')!.click(); + document.querySelector('[role="option"]')!.click(); await tick(); await expect.element(input).toHaveValue('Max Mustermann'); await expect - .element(page.getByRole('button', { name: 'Max Mustermann' })) + .element(page.getByRole('option', { name: 'Max Mustermann' })) .not.toBeInTheDocument(); await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' }); }); @@ -145,7 +145,7 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Mu'); await waitForDebounce(); - document.querySelector('[role="button"]')!.click(); + document.querySelector('[role="option"]')!.click(); await tick(); await tick(); expect(hiddenInput('senderId')?.value).toBe('1'); @@ -158,7 +158,7 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Mu'); await waitForDebounce(); - document.querySelector('[role="button"]')!.click(); + document.querySelector('[role="option"]')!.click(); await tick(); expect(onchange).toHaveBeenCalledWith('1'); }); @@ -177,7 +177,7 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Ma'); await waitForDebounce(); - document.querySelector('[role="button"]')!.click(); + document.querySelector('[role="option"]')!.click(); await tick(); await expect.element(input).toHaveValue('Max Mustermann'); }); @@ -194,7 +194,7 @@ describe('PersonTypeahead – clearing a selection', () => { await input.fill('Mu'); await waitForDebounce(); - document.querySelector('[role="button"]')!.click(); + document.querySelector('[role="option"]')!.click(); await tick(); expect(onchange).toHaveBeenCalledWith('1'); onchange.mockClear(); @@ -285,3 +285,175 @@ describe('PersonTypeahead – click outside', () => { await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument(); }); }); + +// ─── ARIA roles ─────────────────────────────────────────────────────────────── + +describe('PersonTypeahead – ARIA roles', () => { + it('dropdown uses role="listbox" container and role="option" items', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await input.fill('Mu'); + await waitForDebounce(); + + // Container must be a listbox + await expect.element(page.getByRole('listbox')).toBeInTheDocument(); + + // Items must be options, not buttons + const options = page.getByRole('option'); + await expect.element(options.first()).toBeInTheDocument(); + await expect.element(page.getByRole('option', { name: 'Max Mustermann' })).toBeInTheDocument(); + await expect.element(page.getByRole('option', { name: 'Anna Musterfrau' })).toBeInTheDocument(); + }); + + it('input has aria-expanded="false" when dropdown is closed', async () => { + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await expect.element(input).toHaveAttribute('aria-expanded', 'false'); + }); + + it('input has aria-expanded="true" when dropdown is open', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await input.fill('Mu'); + await waitForDebounce(); + await expect.element(input).toHaveAttribute('aria-expanded', 'true'); + }); + + it('input has aria-controls pointing to the listbox id', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await input.fill('Mu'); + await waitForDebounce(); + + const ariaControls = await input.element().getAttribute('aria-controls'); + expect(ariaControls).toBeTruthy(); + + // The listbox with that id must exist + const listbox = document.getElementById(ariaControls!); + expect(listbox).not.toBeNull(); + expect(listbox!.getAttribute('role')).toBe('listbox'); + }); + + it('input has aria-haspopup="listbox"', async () => { + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await expect.element(input).toHaveAttribute('aria-haspopup', 'listbox'); + }); +}); + +// ─── Keyboard navigation ────────────────────────────────────────────────────── + +describe('PersonTypeahead – keyboard navigation', () => { + it('ArrowDown moves highlight to the first option', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await input.fill('Mu'); + await waitForDebounce(); + + await input.click(); + await userEvent.keyboard('{ArrowDown}'); + await tick(); + + // First option should be highlighted (aria-selected="true") + const firstOption = page.getByRole('option', { name: 'Max Mustermann' }); + await expect.element(firstOption).toHaveAttribute('aria-selected', 'true'); + }); + + it('ArrowDown then ArrowDown moves highlight to the second option', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await input.fill('Mu'); + await waitForDebounce(); + + await input.click(); + await userEvent.keyboard('{ArrowDown}'); + await tick(); + await userEvent.keyboard('{ArrowDown}'); + await tick(); + + const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' }); + await expect.element(secondOption).toHaveAttribute('aria-selected', 'true'); + }); + + it('ArrowUp from first wraps to last option', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await input.fill('Mu'); + await waitForDebounce(); + + await input.click(); + await userEvent.keyboard('{ArrowDown}'); // highlight first + await tick(); + await userEvent.keyboard('{ArrowUp}'); // wrap to last + await tick(); + + const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' }); + await expect.element(lastOption).toHaveAttribute('aria-selected', 'true'); + }); + + it('Enter selects the highlighted option', async () => { + mockFetchWithPersons([ + { + id: '1', + firstName: 'Max', + lastName: 'Mustermann', + displayName: 'Max Mustermann', + personType: 'PERSON' + } + ]); + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await input.fill('Ma'); + await waitForDebounce(); + + await input.click(); + await userEvent.keyboard('{ArrowDown}'); + await tick(); + await userEvent.keyboard('{Enter}'); + await tick(); + + await expect.element(input).toHaveValue('Max Mustermann'); + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + }); + + it('Escape closes the dropdown without selecting', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await input.fill('Mu'); + await waitForDebounce(); + await expect.element(page.getByRole('listbox')).toBeInTheDocument(); + + await input.click(); + await userEvent.keyboard('{Escape}'); + await tick(); + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + // Value unchanged — nothing was selected + await expect.element(input).toHaveValue('Mu'); + }); + + it('active option id is set as aria-activedescendant on the input', async () => { + mockFetchWithPersons(); + render(PersonTypeahead, { name: 'senderId', label: 'Absender' }); + const input = page.getByPlaceholder('Namen tippen...'); + await input.fill('Mu'); + await waitForDebounce(); + + // No active option before pressing ArrowDown + const beforeNav = await input.element().getAttribute('aria-activedescendant'); + expect(beforeNav).toBeFalsy(); + + await input.click(); + await userEvent.keyboard('{ArrowDown}'); + await tick(); + + const afterNav = await input.element().getAttribute('aria-activedescendant'); + expect(afterNav).toBeTruthy(); + }); +}); diff --git a/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte b/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte index da7b33c1..a14bca9b 100644 --- a/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte +++ b/frontend/src/routes/briefwechsel/ConversationFilterBar.svelte @@ -31,7 +31,7 @@ let {
-
+