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:
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user