fix(#248): complete ARIA combobox pattern in TagParentPicker — role="option", aria-activedescendant, keyboard nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-17 00:42:54 +02:00
parent 6f6ff8e9ed
commit 901483ab73
2 changed files with 75 additions and 9 deletions

View File

@@ -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<Tag>({
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();
}
}
</script>
<div class="relative" use:clickOutside onclickoutside={() => 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)}
<div
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
id="{name}-option-{i}"
role="option"
tabindex="-1"
aria-selected={i === typeahead.activeIndex}
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === typeahead.activeIndex ? 'bg-accent-bg' : ''}"
onclick={() => selectTag(tag)}
onkeydown={(e) => e.key === 'Enter' && selectTag(tag)}
role="button"
tabindex="0"
>
<span class="block truncate font-medium">{tag.name}</span>
{#if tag.parentId}

View File

@@ -83,7 +83,7 @@ describe('TagParentPicker selection', () => {
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
document.querySelector<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[role="button"]')!.click();
await page.getByRole('option', { name: 'Haus' }).click();
await vi.advanceTimersByTimeAsync(0);
expect(hiddenInput('parentId')?.value).toBe('t1');
const clearBtn = document.querySelector<HTMLElement>('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');
});
});