Files
familienarchiv/frontend/e2e/person-typeahead.spec.ts
Marcel 69b940c227
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m2s
CI / Unit & Component Tests (pull_request) Failing after 3m22s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Failing after 2m51s
fix(persons): fix PersonTypeahead dropdown clipping with fixed positioning
The dropdown was clipped by parent containers using overflow, transform,
or stacking context via shadow-sm + z-index combinations. Adopts the same
fixed-position strategy as PersonMultiSelect: binds to the input element,
computes position via getBoundingClientRect(), and registers svelte:window
scroll/resize listeners to keep it current.

Also adds full ARIA combobox pattern (role=combobox, aria-expanded,
aria-haspopup, aria-controls, aria-activedescendant) and keyboard
navigation (ArrowDown/Up, Enter, Escape) matching TagInput's reference
implementation.

Removes the now-dead z-30/z-10 z-index workarounds from ConversationFilterBar.

Closes #343

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:59:54 +02:00

227 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<typeof test>[1] extends (args: { page: infer P }) => unknown ? P : never
): Promise<string> {
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();
}
});
});