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:
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user