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; 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>({ const typeahead = createTypeahead<Tag>({
fetchUrl: async (q) => { fetchUrl: async (q) => {
const res = await fetch(`/api/tags?query=${encodeURIComponent(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() { function handleInput() {
const term = untrack(() => displayName); const term = untrack(() => displayName);
typeahead.setQuery(term); typeahead.setQuery(term);
// Reset active index whenever results are re-fetched
typeahead.setActiveIndex(-1);
} }
function selectTag(tag: Tag) { function selectTag(tag: Tag) {
@@ -51,6 +55,25 @@ function clearSelection() {
displayName = ''; displayName = '';
typeahead.close(); 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> </script>
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}> <div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
@@ -65,8 +88,12 @@ function clearSelection() {
aria-expanded={typeahead.isOpen} aria-expanded={typeahead.isOpen}
aria-controls="{name}-listbox" aria-controls="{name}-listbox"
aria-autocomplete="list" aria-autocomplete="list"
aria-activedescendant={typeahead.activeIndex >= 0
? `${name}-option-${typeahead.activeIndex}`
: undefined}
bind:value={displayName} bind:value={displayName}
oninput={handleInput} oninput={handleInput}
onkeydown={handleKeydown}
placeholder={m.admin_tag_parent_placeholder()} 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" 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" 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" 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 <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)} onclick={() => selectTag(tag)}
onkeydown={(e) => e.key === 'Enter' && selectTag(tag)} onkeydown={(e) => e.key === 'Enter' && selectTag(tag)}
role="button"
tabindex="0"
> >
<span class="block truncate font-medium">{tag.name}</span> <span class="block truncate font-medium">{tag.name}</span>
{#if tag.parentId} {#if tag.parentId}

View File

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