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
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>
227 lines
7.4 KiB
TypeScript
227 lines
7.4 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
});
|
||
});
|