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
|
||||
});
|
||||
|
||||
// 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.
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
|
||||
<label
|
||||
for={name}
|
||||
for="{name}-search"
|
||||
class={compact
|
||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||
: '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
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
autofocus={autofocus}
|
||||
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}
|
||||
onfocus={handleFocus}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||
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'
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||
<div
|
||||
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"
|
||||
{#if isOpen}
|
||||
<ul
|
||||
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}
|
||||
<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}
|
||||
{#each typeahead.results as person (person.id)}
|
||||
<div
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
||||
{#each typeahead.results as person, i (person.id)}
|
||||
<li
|
||||
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)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="block truncate font-medium">
|
||||
{person.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[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<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[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<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[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<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[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<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
<!-- Sender -->
|
||||
<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
|
||||
name="senderId"
|
||||
@@ -73,7 +73,7 @@ let {
|
||||
|
||||
<!-- Receiver -->
|
||||
<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
|
||||
name="receiverId"
|
||||
@@ -86,7 +86,7 @@ let {
|
||||
</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 -->
|
||||
<div>
|
||||
<label
|
||||
|
||||
Reference in New Issue
Block a user