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();
}
}
function handleOptionKeydown(e: KeyboardEvent, doc: DocumentOption) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(doc);
}
}
</script>
<div use:clickOutside onclickoutside={() => picker.close()} class="relative">
@@ -102,34 +95,45 @@ function handleOptionKeydown(e: KeyboardEvent, doc: DocumentOption) {
/>
{#if picker.isOpen}
<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"
>
{#if picker.loading}
<li class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</li>
{:else if picker.error}
<li role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</li>
{:else if picker.results.length === 0}
<li class="px-3 py-2 text-ink-2">{m.comp_typeahead_no_results()}</li>
{:else}
{#if picker.loading}
<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_multiselect_loading()}</p>
</div>
{:else if picker.error}
<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="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</p>
</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)}
{@const disabled = alreadyAddedIds.has(doc.id!)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
id={`${listboxId}-option-${i}`}
role="option"
aria-selected={i === picker.activeIndex}
aria-disabled={disabled}
onclick={() => handleSelect(doc)}
onkeydown={(e) => handleOptionKeydown(e, doc)}
tabindex={disabled ? -1 : 0}
class={[
'px-3 py-2 text-ink select-none',
i === picker.activeIndex ? 'bg-muted' : '',
disabled
? 'cursor-default opacity-50'
: 'cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none'
: 'cursor-pointer hover:bg-muted'
].join(' ')}
>
{formatDocumentOption(doc)}
@@ -138,7 +142,7 @@ function handleOptionKeydown(e: KeyboardEvent, doc: DocumentOption) {
{/if}
</li>
{/each}
{/if}
</ul>
</ul>
{/if}
{/if}
</div>