refactor(document-picker): downgrade listbox ARIA roles; fix unique listboxId
role=listbox + role=option without arrow-key navigation is misleading — the
WAI-ARIA combobox pattern requires aria-activedescendant handling that isn't
implemented. Downgraded to plain <ul>/<li>; input keeps role=combobox +
aria-controls pointing to the list id.
listboxId was a module-level constant so two simultaneous instances would share
the same DOM id. Fixed with a <script module> counter.
Updated spec queries from getByRole('option') to getByText() — tests behaviour,
not the ARIA implementation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
|
<script module>
|
||||||
|
let _uid = 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||||
@@ -19,7 +23,7 @@ let {
|
|||||||
onSelect
|
onSelect
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const listboxId = 'doc-picker-listbox';
|
const listboxId = `doc-picker-listbox-${++_uid}`;
|
||||||
|
|
||||||
const picker = createDocumentTypeahead();
|
const picker = createDocumentTypeahead();
|
||||||
|
|
||||||
@@ -61,7 +65,6 @@ function handleSelect(doc: DocumentOption) {
|
|||||||
{#if picker.isOpen && (picker.results.length > 0 || picker.loading)}
|
{#if picker.isOpen && (picker.results.length > 0 || picker.loading)}
|
||||||
<ul
|
<ul
|
||||||
id={listboxId}
|
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"
|
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}
|
{#if picker.loading}
|
||||||
@@ -70,8 +73,6 @@ function handleSelect(doc: DocumentOption) {
|
|||||||
{#each picker.results as doc (doc.id)}
|
{#each picker.results as doc (doc.id)}
|
||||||
{@const disabled = alreadyAddedIds.has(doc.id!)}
|
{@const disabled = alreadyAddedIds.has(doc.id!)}
|
||||||
<li
|
<li
|
||||||
role="option"
|
|
||||||
aria-selected={false}
|
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
onclick={() => handleSelect(doc)}
|
onclick={() => handleSelect(doc)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleSelect(doc)}
|
onkeydown={(e) => e.key === 'Enter' && handleSelect(doc)}
|
||||||
|
|||||||
@@ -53,9 +53,12 @@ describe('DocumentPickerDropdown — already-added indicator', () => {
|
|||||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
|
|
||||||
const disabledOption = page.getByRole('option', { name: /Brief von Eugenie/i });
|
const disabledItem = page
|
||||||
await expect.element(disabledOption).toHaveAttribute('aria-disabled', 'true');
|
.getByText(/Brief von Eugenie/i)
|
||||||
// Screen-reader text "bereits enthalten" must be present in the option
|
.element()
|
||||||
|
.closest('li')!;
|
||||||
|
expect(disabledItem.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
// Screen-reader text "bereits enthalten" must be present in the item
|
||||||
await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument();
|
await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -69,7 +72,7 @@ describe('DocumentPickerDropdown — selection', () => {
|
|||||||
|
|
||||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
await userEvent.click(page.getByRole('option', { name: /Brief von Eugenie/i }));
|
await userEvent.click(page.getByText(/Brief von Eugenie/i));
|
||||||
|
|
||||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
|
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
|
||||||
});
|
});
|
||||||
@@ -85,8 +88,7 @@ describe('DocumentPickerDropdown — selection', () => {
|
|||||||
|
|
||||||
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
await userEvent.fill(page.getByRole('combobox'), 'Brief');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
// aria-disabled items are not "enabled" — userEvent refuses them; use force click
|
await page.getByText(/Brief von Eugenie/i).click({ force: true });
|
||||||
await page.getByRole('option', { name: /Brief von Eugenie/i }).click({ force: true });
|
|
||||||
|
|
||||||
expect(onSelect).not.toHaveBeenCalled();
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user