fix(picker): ARIA combobox — listbox only when results; no tabindex on options

Status/error/empty messages rendered outside <ul role="listbox"> so the
element only contains role="option" children (fixes aria-required-children
axe violation). Options no longer carry tabindex or onkeydown — keyboard
navigation stays on the input per ARIA combobox pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-10 19:30:18 +02:00
parent a65a55448e
commit 585244d65b
2 changed files with 71 additions and 24 deletions

View File

@@ -75,13 +75,6 @@ function handleKeydown(e: KeyboardEvent) {
picker.close(); picker.close();
} }
} }
function handleOptionKeydown(e: KeyboardEvent, doc: DocumentOption) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(doc);
}
}
</script> </script>
<div use:clickOutside onclickoutside={() => picker.close()} class="relative"> <div use:clickOutside onclickoutside={() => picker.close()} class="relative">
@@ -102,34 +95,45 @@ function handleOptionKeydown(e: KeyboardEvent, doc: DocumentOption) {
/> />
{#if picker.isOpen} {#if picker.isOpen}
<ul {#if picker.loading}
id={listboxId} <div
role="listbox" class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
class="ring-opacity-5 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black" >
> <p role="status" class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</p>
{#if picker.loading} </div>
<li class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</li> {:else if picker.error}
{:else if picker.error} <div
<li role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</li> class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
{:else if picker.results.length === 0} >
<li class="px-3 py-2 text-ink-2">{m.comp_typeahead_no_results()}</li> <p role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</p>
{:else} </div>
{:else if picker.results.length === 0}
<div
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_typeahead_no_results()}</p>
</div>
{:else}
<ul
id={listboxId}
role="listbox"
class="ring-opacity-5 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
{#each picker.results as doc, i (doc.id)} {#each picker.results as doc, i (doc.id)}
{@const disabled = alreadyAddedIds.has(doc.id!)} {@const disabled = alreadyAddedIds.has(doc.id!)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li <li
id={`${listboxId}-option-${i}`} id={`${listboxId}-option-${i}`}
role="option" role="option"
aria-selected={i === picker.activeIndex} aria-selected={i === picker.activeIndex}
aria-disabled={disabled} aria-disabled={disabled}
onclick={() => handleSelect(doc)} onclick={() => handleSelect(doc)}
onkeydown={(e) => handleOptionKeydown(e, doc)}
tabindex={disabled ? -1 : 0}
class={[ class={[
'px-3 py-2 text-ink select-none', 'px-3 py-2 text-ink select-none',
i === picker.activeIndex ? 'bg-muted' : '', i === picker.activeIndex ? 'bg-muted' : '',
disabled disabled
? 'cursor-default opacity-50' ? 'cursor-default opacity-50'
: 'cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none' : 'cursor-pointer hover:bg-muted'
].join(' ')} ].join(' ')}
> >
{formatDocumentOption(doc)} {formatDocumentOption(doc)}
@@ -138,7 +142,7 @@ function handleOptionKeydown(e: KeyboardEvent, doc: DocumentOption) {
{/if} {/if}
</li> </li>
{/each} {/each}
{/if} </ul>
</ul> {/if}
{/if} {/if}
</div> </div>

View File

@@ -198,3 +198,46 @@ describe('DocumentPickerDropdown — search failure', () => {
await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument(); await expect.element(page.getByText(m.comp_typeahead_error())).toBeInTheDocument();
}); });
}); });
describe('DocumentPickerDropdown — ARIA listbox integrity', () => {
it('does not render a listbox when results are empty (no aria-required-children violation)', async () => {
mockSearchResponse([]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'xyz');
await waitForDebounce();
// no-results message must be visible, but NOT inside a listbox
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it('does not render a listbox when loading (no aria-required-children violation)', async () => {
let resolveSearch!: (v: unknown) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockReturnValue(new Promise((resolve) => (resolveSearch = resolve)))
);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
// While in-flight, no listbox should exist
expect(document.querySelector('[role="listbox"]')).toBeNull();
resolveSearch({ ok: true, json: () => Promise.resolve({ items: [] }) });
});
it('option elements do not have tabindex (combobox pattern: focus stays on input)', async () => {
mockSearchResponse([docFactory('d1', 'Brief A'), docFactory('d2', 'Brief B')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
const options = document.querySelectorAll('[role="listbox"] [role="option"]');
expect(options.length).toBeGreaterThan(0);
options.forEach((opt) => {
expect(opt).not.toHaveAttribute('tabindex');
});
});
});