diff --git a/frontend/src/lib/components/TagParentPicker.svelte b/frontend/src/lib/components/TagParentPicker.svelte index 9e12bd10..a67faef9 100644 --- a/frontend/src/lib/components/TagParentPicker.svelte +++ b/frontend/src/lib/components/TagParentPicker.svelte @@ -25,6 +25,8 @@ $effect(() => { displayName = initialName; }); +// Uses fetch directly (not the typed api client) because this component runs in the browser +// where the typed api client is not available, and the tags endpoint needs no auth cookie. const typeahead = createTypeahead({ fetchUrl: async (q) => { const res = await fetch(`/api/tags?query=${encodeURIComponent(q)}`); @@ -38,6 +40,8 @@ const filteredResults = $derived(typeahead.results.filter((t) => !excludeIds.inc function handleInput() { const term = untrack(() => displayName); typeahead.setQuery(term); + // Reset active index whenever results are re-fetched + typeahead.setActiveIndex(-1); } function selectTag(tag: Tag) { @@ -51,6 +55,25 @@ function clearSelection() { displayName = ''; typeahead.close(); } + +function handleKeydown(e: KeyboardEvent) { + if (!typeahead.isOpen) return; + const len = filteredResults.length; + if (len === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + typeahead.setActiveIndex((typeahead.activeIndex + 1) % len); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + typeahead.setActiveIndex((typeahead.activeIndex - 1 + len) % len); + } else if (e.key === 'Enter' && typeahead.activeIndex >= 0) { + e.preventDefault(); + selectTag(filteredResults[typeahead.activeIndex]); + } else if (e.key === 'Escape') { + typeahead.close(); + } +}
typeahead.close()}> @@ -65,8 +88,12 @@ function clearSelection() { aria-expanded={typeahead.isOpen} aria-controls="{name}-listbox" aria-autocomplete="list" + aria-activedescendant={typeahead.activeIndex >= 0 + ? `${name}-option-${typeahead.activeIndex}` + : undefined} bind:value={displayName} oninput={handleInput} + onkeydown={handleKeydown} placeholder={m.admin_tag_parent_placeholder()} class="mt-1 block w-full rounded-md border border-line bg-surface p-2 pr-8 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" /> @@ -96,13 +123,15 @@ function clearSelection() { role="listbox" 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" > - {#each filteredResults as tag (tag.id)} + {#each filteredResults as tag, i (tag.id)}
selectTag(tag)} onkeydown={(e) => e.key === 'Enter' && selectTag(tag)} - role="button" - tabindex="0" > {tag.name} {#if tag.parentId} diff --git a/frontend/src/lib/components/TagParentPicker.svelte.spec.ts b/frontend/src/lib/components/TagParentPicker.svelte.spec.ts index f2c2bb0c..e5311983 100644 --- a/frontend/src/lib/components/TagParentPicker.svelte.spec.ts +++ b/frontend/src/lib/components/TagParentPicker.svelte.spec.ts @@ -83,7 +83,7 @@ describe('TagParentPicker – selection', () => { await input.fill('H'); await vi.advanceTimersByTimeAsync(300); - document.querySelector('[role="button"]')!.click(); + await page.getByRole('option', { name: 'Haus' }).click(); await vi.advanceTimersByTimeAsync(0); expect(hiddenInput('parentId')?.value).toBe('t1'); @@ -97,7 +97,7 @@ describe('TagParentPicker – selection', () => { await input.fill('H'); await vi.advanceTimersByTimeAsync(300); - document.querySelector('[role="button"]')!.click(); + await page.getByRole('option', { name: 'Haus' }).click(); await vi.advanceTimersByTimeAsync(0); await expect @@ -113,15 +113,52 @@ describe('TagParentPicker – selection', () => { await input.fill('H'); await vi.advanceTimersByTimeAsync(300); - document.querySelector('[role="button"]')!.click(); + await page.getByRole('option', { name: 'Haus' }).click(); await vi.advanceTimersByTimeAsync(0); expect(hiddenInput('parentId')?.value).toBe('t1'); - const clearBtn = document.querySelector('button[aria-label="Auswahl entfernen"]')!; - clearBtn.click(); + await page.getByRole('button', { name: 'Auswahl entfernen' }).click(); await vi.advanceTimersByTimeAsync(0); expect(hiddenInput('parentId')?.value).toBe(''); }); }); + +// ─── ARIA combobox ──────────────────────────────────────────────────────────── + +describe('TagParentPicker – ARIA combobox', () => { + it('ArrowDown moves aria-activedescendant to first option', async () => { + mockFetchWithTags([ + { id: 't1', name: 'Haus' }, + { id: 't2', name: 'Garten' } + ]); + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + await input.fill('a'); + await vi.advanceTimersByTimeAsync(300); + + // Dropdown is open — arrow down should highlight first option + const el = await input.element(); + el.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }) + ); + await vi.advanceTimersByTimeAsync(0); + + expect(el.getAttribute('aria-activedescendant')).toBe('parentId-option-0'); + }); + + it('listbox items have role="option" with ids', async () => { + mockFetchWithTags([{ id: 't1', name: 'Haus' }]); + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + await input.fill('H'); + await vi.advanceTimersByTimeAsync(300); + + await expect.element(page.getByRole('option', { name: 'Haus' })).toBeInTheDocument(); + const option = await page.getByRole('option', { name: 'Haus' }).element(); + expect(option.id).toBe('parentId-option-0'); + }); +});