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