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>
This commit is contained in:
226
frontend/e2e/person-typeahead.spec.ts
Normal file
226
frontend/e2e/person-typeahead.spec.ts
Normal file
@@ -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<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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,6 +76,22 @@ const typeahead = createTypeahead<Person>({
|
|||||||
debounceMs: 300
|
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() {
|
function handleInput() {
|
||||||
if (value && searchTerm !== initialName) {
|
if (value && searchTerm !== initialName) {
|
||||||
value = '';
|
value = '';
|
||||||
@@ -88,6 +104,7 @@ function handleInput() {
|
|||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
onfocused?.();
|
onfocused?.();
|
||||||
|
updateDropdownPosition();
|
||||||
if (restrictToCorrespondentsOf) {
|
if (restrictToCorrespondentsOf) {
|
||||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -109,13 +126,47 @@ function selectPerson(person: Person) {
|
|||||||
value = person.id!;
|
value = person.id!;
|
||||||
searchTerm = person.displayName;
|
searchTerm = person.displayName;
|
||||||
typeahead.close();
|
typeahead.close();
|
||||||
|
activeIndex = -1;
|
||||||
onchange?.(person.id!);
|
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.
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
|
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
|
||||||
<label
|
<label
|
||||||
for={name}
|
for="{name}-search"
|
||||||
class={compact
|
class={compact
|
||||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||||
: 'block text-sm font-medium text-ink-2'}
|
: 'block text-sm font-medium text-ink-2'}
|
||||||
@@ -125,13 +176,22 @@ function selectPerson(person: Person) {
|
|||||||
<input type="hidden" name={name} bind:value={value} />
|
<input type="hidden" name={name} bind:value={value} />
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
type="text"
|
type="text"
|
||||||
id="{name}-search"
|
id="{name}-search"
|
||||||
|
role="combobox"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autofocus={autofocus}
|
autofocus={autofocus}
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-activedescendant={activeIndex >= 0 && typeahead.results[activeIndex]
|
||||||
|
? `${listboxId}-option-${typeahead.results[activeIndex].id}`
|
||||||
|
: undefined}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={handleFocus}
|
onfocus={handleFocus}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||||
class={large
|
class={large
|
||||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||||
@@ -140,29 +200,34 @@ function selectPerson(person: Person) {
|
|||||||
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
{#if isOpen}
|
||||||
<div
|
<ul
|
||||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
id={listboxId}
|
||||||
|
role="listbox"
|
||||||
|
style={dropdownStyle}
|
||||||
|
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||||
>
|
>
|
||||||
{#if typeahead.loading}
|
{#if typeahead.loading}
|
||||||
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
|
<li class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</li>
|
||||||
{:else}
|
{:else}
|
||||||
{#each typeahead.results as person (person.id)}
|
{#each typeahead.results as person, i (person.id)}
|
||||||
<div
|
<li
|
||||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
id="{listboxId}-option-{person.id}"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === activeIndex}
|
||||||
|
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === activeIndex ? 'bg-accent-bg' : ''}"
|
||||||
onclick={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||||
role="button"
|
tabindex="-1"
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="block truncate font-medium">
|
<span class="block truncate font-medium">
|
||||||
{person.displayName}
|
{person.displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||||
|
|
||||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||||
@@ -130,11 +130,11 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(input).toHaveValue('Max Mustermann');
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
.element(page.getByRole('option', { name: 'Max Mustermann' }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||||
});
|
});
|
||||||
@@ -145,7 +145,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await tick();
|
await tick();
|
||||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||||
@@ -158,7 +158,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
expect(onchange).toHaveBeenCalledWith('1');
|
expect(onchange).toHaveBeenCalledWith('1');
|
||||||
});
|
});
|
||||||
@@ -177,7 +177,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Ma');
|
await input.fill('Ma');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(input).toHaveValue('Max Mustermann');
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
});
|
});
|
||||||
@@ -194,7 +194,7 @@ describe('PersonTypeahead – clearing a selection', () => {
|
|||||||
|
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
expect(onchange).toHaveBeenCalledWith('1');
|
expect(onchange).toHaveBeenCalledWith('1');
|
||||||
onchange.mockClear();
|
onchange.mockClear();
|
||||||
@@ -285,3 +285,175 @@ describe('PersonTypeahead – click outside', () => {
|
|||||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ let {
|
|||||||
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
<div
|
<div
|
||||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="senderId"
|
name="senderId"
|
||||||
@@ -73,7 +73,7 @@ let {
|
|||||||
|
|
||||||
<!-- Receiver -->
|
<!-- Receiver -->
|
||||||
<div
|
<div
|
||||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="receiverId"
|
name="receiverId"
|
||||||
@@ -86,7 +86,7 @@ let {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
<div class="grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
||||||
<!-- Date From -->
|
<!-- Date From -->
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
Reference in New Issue
Block a user