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

@@ -106,6 +106,17 @@ describe('createTypeahead', () => {
errorSpy.mockRestore();
});
it('sets loading immediately on setQuery so empty results read as pending, not "no results"', async () => {
const fetchUrl = vi.fn().mockResolvedValue([]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('foo');
// During the debounce window no fetch has run yet — callers must be able to
// distinguish "still searching" from "searched, zero hits".
expect(ta.loading).toBe(true);
await vi.advanceTimersByTimeAsync(300);
expect(ta.loading).toBe(false);
});
it('setActiveIndex updates activeIndex', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.activeIndex).toBe(-1);

View File

@@ -19,9 +19,11 @@ export function createTypeahead<T>(options: Options<T>) {
function setQuery(q: string) {
query = q;
isOpen = true;
// Set loading before the debounce fires so callers can distinguish
// "still searching" from "searched, zero hits" during the debounce window.
loading = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
error = false;
try {
results = await fetchUrl(q);