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:
@@ -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>
|
||||
|
||||
@@ -198,3 +198,46 @@ describe('DocumentPickerDropdown — search failure', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user