fix(picker): honest combobox — keyboard navigation, listbox roles, no-results state

The input declared role=combobox + aria-autocomplete=list while arrow keys
did nothing (WCAG 4.1.2). Wired the useTypeahead activeIndex the same way
PersonTypeahead does: ArrowUp/Down cycle, Enter/Space select, Escape closes,
aria-activedescendant tracks the active option; the list is a real listbox
with option roles again (the interim role downgrade is reverted). Zero hits
now render a 'Keine Treffer' row instead of silently vanishing, aria-expanded
matches the visible state, and the hook sets loading at setQuery so the
debounce window can't read as 'no results'. DocumentMultiSelect renders the
shared error state too. _uid counter replaced with $props.id().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-10 07:53:56 +02:00
parent 98e3d924e5
commit 7977d22d0b
9 changed files with 196 additions and 11 deletions

View File

@@ -302,6 +302,7 @@
"comp_multiselect_remove": "Entfernen",
"comp_multiselect_loading": "Suche...",
"comp_typeahead_error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
"comp_typeahead_no_results": "Keine Treffer",
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
"comp_taginput_remove": "Schlagwort entfernen",
@@ -1179,6 +1180,11 @@
"journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren.",
"journey_add_document": "Brief hinzufügen",
"journey_add_interlude": "Zwischentext hinzufügen",
"journey_interlude_label": "Zwischentext",
"journey_item_pending_remove": "wird entfernt…",
"journey_publish_disabled_hint": "Titel und mindestens ein Eintrag erforderlich.",
"journey_title_aria_label": "Titel der Lesereise",
"journey_intro_aria_label": "Einleitung der Lesereise",
"journey_note_add": "Notiz hinzufügen",
"journey_note_remove": "Notiz entfernen",
"journey_note_save_hint": "Wird gespeichert, wenn du das Feld verlässt.",

View File

@@ -302,6 +302,7 @@
"comp_multiselect_remove": "Remove",
"comp_multiselect_loading": "Searching...",
"comp_typeahead_error": "Search failed. Please try again.",
"comp_typeahead_no_results": "No matches",
"comp_taginput_placeholder_create": "Add tags...",
"comp_taginput_placeholder_filter": "Filter by tags...",
"comp_taginput_remove": "Remove tag",
@@ -1179,6 +1180,11 @@
"journey_selector_aria_live_hint": "Please select a type to continue.",
"journey_add_document": "Add letter",
"journey_add_interlude": "Add interlude",
"journey_interlude_label": "Interlude",
"journey_item_pending_remove": "removing…",
"journey_publish_disabled_hint": "A title and at least one entry are required.",
"journey_title_aria_label": "Title of the reading journey",
"journey_intro_aria_label": "Introduction of the reading journey",
"journey_note_add": "Add note",
"journey_note_remove": "Remove note",
"journey_note_save_hint": "Saved when you leave the field.",

View File

@@ -302,6 +302,7 @@
"comp_multiselect_remove": "Eliminar",
"comp_multiselect_loading": "Buscando...",
"comp_typeahead_error": "La búsqueda falló. Inténtelo de nuevo.",
"comp_typeahead_no_results": "Sin resultados",
"comp_taginput_placeholder_create": "Añadir etiquetas...",
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
"comp_taginput_remove": "Eliminar etiqueta",
@@ -1179,6 +1180,11 @@
"journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar.",
"journey_add_document": "Añadir carta",
"journey_add_interlude": "Añadir interludio",
"journey_interlude_label": "Interludio",
"journey_item_pending_remove": "eliminando…",
"journey_publish_disabled_hint": "Se requieren un título y al menos una entrada.",
"journey_title_aria_label": "Título del viaje de lectura",
"journey_intro_aria_label": "Introducción del viaje de lectura",
"journey_note_add": "Añadir nota",
"journey_note_remove": "Eliminar nota",
"journey_note_save_hint": "Se guarda al salir del campo.",

View File

@@ -100,13 +100,15 @@ function removeDocument(id: string | undefined) {
/>
</div>
{#if picker.isOpen && (filteredResults.length > 0 || picker.loading)}
{#if picker.isOpen && (filteredResults.length > 0 || picker.loading || picker.error)}
<div
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
>
{#if picker.loading}
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
{:else if picker.error}
<div role="alert" class="p-2 text-sm text-danger">{m.comp_typeahead_error()}</div>
{:else}
{#each filteredResults as doc (doc.id)}
<div

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
import { m } from '$lib/paraglide/messages.js';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
@@ -124,6 +125,28 @@ describe('DocumentMultiSelect — search and select', () => {
});
});
describe('DocumentMultiSelect — search failure', () => {
it('shows an error row when the search request fails instead of looking like "no results"', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
})
);
render(DocumentMultiSelect);
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
await waitForDebounce();
const alert = page.getByRole('alert');
await expect.element(alert).toBeInTheDocument();
await expect.element(alert).toHaveTextContent(m.comp_typeahead_error());
});
});
describe('DocumentMultiSelect — remove', () => {
it('removes a chip when its × button is clicked', async () => {
render(DocumentMultiSelect, {

View File

@@ -1,7 +1,3 @@
<script module>
let _uid = 0;
</script>
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
@@ -23,15 +19,23 @@ let {
onSelect
}: Props = $props();
const listboxId = `doc-picker-listbox-${++_uid}`;
const uid = $props.id();
const listboxId = `doc-picker-listbox-${uid}`;
const picker = createDocumentTypeahead();
let inputValue = $state('');
const activeOptionId = $derived(
picker.isOpen && picker.activeIndex >= 0 && picker.results[picker.activeIndex]
? `${listboxId}-option-${picker.activeIndex}`
: undefined
);
function handleInput(e: Event) {
const q = (e.currentTarget as HTMLInputElement).value;
inputValue = q;
picker.setActiveIndex(-1);
if (q.trim().length >= 1) {
picker.setQuery(q);
} else {
@@ -45,6 +49,39 @@ function handleSelect(doc: DocumentOption) {
picker.close();
onSelect(doc);
}
function handleKeydown(e: KeyboardEvent) {
if (!picker.isOpen) return;
const results = picker.results;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (results.length > 0) {
picker.setActiveIndex((picker.activeIndex + 1) % results.length);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (results.length > 0) {
picker.setActiveIndex((picker.activeIndex - 1 + results.length) % results.length);
}
} else if (e.key === 'Enter') {
e.preventDefault();
const active = results[picker.activeIndex];
// handleSelect is a no-op for already-added (aria-disabled) options.
if (active) handleSelect(active);
} else if (e.key === 'Escape') {
e.preventDefault();
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">
@@ -53,34 +90,43 @@ function handleSelect(doc: DocumentOption) {
role="combobox"
autocomplete="off"
aria-label={placeholder}
aria-expanded={picker.isOpen && picker.results.length > 0}
aria-expanded={picker.isOpen}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeOptionId}
placeholder={placeholder}
value={inputValue}
oninput={handleInput}
onkeydown={handleKeydown}
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if picker.isOpen && (picker.results.length > 0 || picker.loading || picker.error)}
{#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}
{#each picker.results as doc (doc.id)}
{#each picker.results as doc, i (doc.id)}
{@const disabled = alreadyAddedIds.has(doc.id!)}
<li
id={`${listboxId}-option-${i}`}
role="option"
aria-selected={i === picker.activeIndex}
aria-disabled={disabled}
onclick={() => handleSelect(doc)}
onkeydown={(e) => e.key === 'Enter' && 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'

View File

@@ -95,6 +95,89 @@ describe('DocumentPickerDropdown — selection', () => {
});
});
describe('DocumentPickerDropdown — keyboard navigation', () => {
it('selects the first option via ArrowDown then Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
it('does not select an aria-disabled option on Enter', async () => {
const onSelect = vi.fn();
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, {
alreadyAddedIds: new Set(['d1']),
onSelect
});
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
expect(onSelect).not.toHaveBeenCalled();
});
it('closes the dropdown on Escape', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'Brief');
await waitForDebounce();
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
it('points aria-activedescendant at the active option', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
const input = page.getByRole('combobox');
await userEvent.fill(input, 'Brief');
await waitForDebounce();
expect(input.element().getAttribute('aria-activedescendant')).toBeNull();
await userEvent.keyboard('{ArrowDown}');
const activeId = input.element().getAttribute('aria-activedescendant');
expect(activeId).toMatch(/-option-0$/);
const firstOption = page
.getByText(/Brief von Eugenie/i)
.element()
.closest('li')!;
expect(firstOption.id).toBe(activeId);
expect(firstOption.getAttribute('aria-selected')).toBe('true');
});
});
describe('DocumentPickerDropdown — no results', () => {
it('shows a non-interactive no-results row when the search returns zero hits', async () => {
mockSearchResponse([]);
render(DocumentPickerDropdown, { onSelect: vi.fn() });
await userEvent.fill(page.getByRole('combobox'), 'xyz');
await waitForDebounce();
await expect.element(page.getByText(m.comp_typeahead_no_results())).toBeInTheDocument();
});
});
describe('DocumentPickerDropdown — search failure', () => {
it('shows an error message when the search request fails instead of vanishing', async () => {
// 500 from /api/documents/search — must surface, not render as "no results"

View File

@@ -106,6 +106,17 @@ describe('createTypeahead', () => {
errorSpy.mockRestore();
});
it('sets loading immediately on setQuery so empty results read as pending, not "no results"', async () => {
const fetchUrl = vi.fn().mockResolvedValue([]);
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
ta.setQuery('foo');
// During the debounce window no fetch has run yet — callers must be able to
// distinguish "still searching" from "searched, zero hits".
expect(ta.loading).toBe(true);
await vi.advanceTimersByTimeAsync(300);
expect(ta.loading).toBe(false);
});
it('setActiveIndex updates activeIndex', () => {
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
expect(ta.activeIndex).toBe(-1);

View File

@@ -19,9 +19,11 @@ export function createTypeahead<T>(options: Options<T>) {
function setQuery(q: string) {
query = q;
isOpen = true;
// Set loading before the debounce fires so callers can distinguish
// "still searching" from "searched, zero hits" during the debounce window.
loading = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
loading = true;
error = false;
try {
results = await fetchUrl(q);