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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user