fix(picker): honest combobox — keyboard navigation, listbox roles, no-results state

The input declared role=combobox + aria-autocomplete=list while arrow keys
did nothing (WCAG 4.1.2). Wired the useTypeahead activeIndex the same way
PersonTypeahead does: ArrowUp/Down cycle, Enter/Space select, Escape closes,
aria-activedescendant tracks the active option; the list is a real listbox
with option roles again (the interim role downgrade is reverted). Zero hits
now render a 'Keine Treffer' row instead of silently vanishing, aria-expanded
matches the visible state, and the hook sets loading at setQuery so the
debounce window can't read as 'no results'. DocumentMultiSelect renders the
shared error state too. _uid counter replaced with $props.id().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-10 07:53:56 +02:00
parent 98e3d924e5
commit 7977d22d0b
9 changed files with 196 additions and 11 deletions

View File

@@ -95,6 +95,89 @@ describe('DocumentPickerDropdown — selection', () => {
});
});
describe('DocumentPickerDropdown — keyboard navigation', () => {
it('selects the first option via ArrowDown then Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
it('does not select an aria-disabled option on Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).not.toHaveBeenCalled();
});
it('closes the dropdown on Escape', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
it('points aria-activedescendant at the active option', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
const input = page.getByRole('combobox');
await userEvent.fill(input, 'Brief');
await waitForDebounce();
expect(input.element().getAttribute('aria-activedescendant')).toBeNull();
await userEvent.keyboard('{ArrowDown}');
const activeId = input.element().getAttribute('aria-activedescendant');
expect(activeId).toMatch(/-option-0$/);
const firstOption = page
.getByText(/Brief von Eugenie/i)
.element()
.closest('li')!;
expect(firstOption.id).toBe(activeId);
expect(firstOption.getAttribute('aria-selected')).toBe('true');
});
});
describe('DocumentPickerDropdown — no results', () => {
it('shows a non-interactive no-results row when the search returns zero hits', async () => {
mockSearchResponse([]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'xyz');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — search failure', () => {
it('shows an error message when the search request fails instead of vanishing', async () => {
// 500 from /api/documents/search — must surface, not render as "no results"