From 65d241f69eaf08e4b46229b1fba1641aa6f110da Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:43:47 +0200 Subject: [PATCH] feat(journey-editor): build DocumentPickerDropdown + refactor DocumentMultiSelect New DocumentPickerDropdown: single-select document search with aria-disabled for already-added items and sr-only "bereits enthalten" hint. DocumentMultiSelect refactored to use createTypeahead, removing raw setTimeout/debounceTimer. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/document/DocumentMultiSelect.svelte | 73 +++++------ .../document/DocumentPickerDropdown.svelte | 124 ++++++++++++++++++ .../DocumentPickerDropdown.svelte.spec.ts | 92 +++++++++++++ 3 files changed, 248 insertions(+), 41 deletions(-) create mode 100644 frontend/src/lib/document/DocumentPickerDropdown.svelte create mode 100644 frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts diff --git a/frontend/src/lib/document/DocumentMultiSelect.svelte b/frontend/src/lib/document/DocumentMultiSelect.svelte index fbfd59a9..fba80f83 100644 --- a/frontend/src/lib/document/DocumentMultiSelect.svelte +++ b/frontend/src/lib/document/DocumentMultiSelect.svelte @@ -2,6 +2,7 @@ import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; import { clickOutside } from '$lib/shared/actions/clickOutside'; +import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte'; import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate'; import { getLocale } from '$lib/paraglide/runtime.js'; @@ -30,13 +31,29 @@ let { }: Props = $props(); let searchTerm = $state(''); -let results: DocumentOption[] = $state([]); -let showDropdown = $state(false); -let loading = $state(false); -let debounceTimer: ReturnType; let inputEl: HTMLInputElement; let dropdownStyle = $state(''); +const picker = createTypeahead({ + fetchUrl: (q) => + fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`) + .then((r) => r.json()) + .then((b: { items: DocumentListItem[] }) => + b.items.map((it) => ({ + id: it.id, + title: it.title, + documentDate: it.documentDate, + metaDatePrecision: it.metaDatePrecision, + metaDateEnd: it.metaDateEnd + })) + ) +}); + +// Filter out already-selected documents from typeahead results. +const filteredResults = $derived( + picker.results.filter((d) => !selectedDocuments.some((s) => s.id === d.id)) +); + function updateDropdownPosition() { if (!inputEl) return; const rect = inputEl.getBoundingClientRect(); @@ -44,40 +61,17 @@ function updateDropdownPosition() { } function handleInput() { - showDropdown = true; - clearTimeout(debounceTimer); - debounceTimer = setTimeout(async () => { - if (searchTerm.length < 1) { - results = []; - return; - } - loading = true; - try { - const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`); - if (res.ok) { - const body: { items: DocumentListItem[] } = await res.json(); - const docs: DocumentOption[] = body.items.map((it) => ({ - id: it.id, - title: it.title, - documentDate: it.documentDate, - metaDatePrecision: it.metaDatePrecision, - metaDateEnd: it.metaDateEnd - })); - results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id)); - } - } catch { - results = []; - } finally { - loading = false; - } - }, 300); + if (searchTerm.trim().length >= 1) { + picker.setQuery(searchTerm); + } else { + picker.close(); + } } function selectDocument(doc: DocumentOption) { selectedDocuments = [...selectedDocuments, doc]; searchTerm = ''; - showDropdown = false; - results = []; + picker.close(); } function removeDocument(id: string | undefined) { @@ -103,7 +97,7 @@ function formatDocLabel(doc: DocumentOption): string { {/each} -
(showDropdown = false)}> +
picker.close()}>
@@ -136,24 +130,21 @@ function formatDocLabel(doc: DocumentOption): string { autocomplete="off" bind:value={searchTerm} oninput={handleInput} - onfocus={() => { - updateDropdownPosition(); - showDropdown = true; - }} + onfocus={() => updateDropdownPosition()} placeholder={placeholder} class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0" />
- {#if showDropdown && (results.length > 0 || loading)} + {#if picker.isOpen && (filteredResults.length > 0 || picker.loading)}
- {#if loading} + {#if picker.loading}
{m.comp_multiselect_loading()}
{:else} - {#each results as doc (doc.id)} + {#each filteredResults as doc (doc.id)}
selectDocument(doc)} diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte b/frontend/src/lib/document/DocumentPickerDropdown.svelte new file mode 100644 index 00000000..e9254baf --- /dev/null +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte @@ -0,0 +1,124 @@ + + +
picker.close()} class="relative"> + 0} + aria-controls={listboxId} + aria-autocomplete="list" + placeholder={placeholder} + value={inputValue} + oninput={handleInput} + 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)} +
    + {#if picker.loading} +
  • {m.comp_multiselect_loading()}
  • + {:else} + {#each picker.results as doc (doc.id)} + {@const disabled = alreadyAddedIds.has(doc.id!)} +
  • handleSelect(doc)} + onkeydown={(e) => e.key === 'Enter' && handleSelect(doc)} + tabindex={disabled ? -1 : 0} + class={[ + 'px-3 py-2 text-ink select-none', + disabled + ? 'cursor-default opacity-50' + : 'cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none' + ].join(' ')} + > + {formatDocLabel(doc)} + {#if disabled} + {m.journey_already_added()} + {/if} +
  • + {/each} + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts new file mode 100644 index 00000000..7529af4f --- /dev/null +++ b/frontend/src/lib/document/DocumentPickerDropdown.svelte.spec.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import DocumentPickerDropdown from './DocumentPickerDropdown.svelte'; + +const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); + +const docFactory = (id: string, title: string) => ({ + id, + title, + documentDate: '1880-01-01', + metaDatePrecision: 'DAY' as const, + metaDateEnd: undefined +}); + +function mockSearchResponse(items: ReturnType[]) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ items }) + }) + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('DocumentPickerDropdown โ€” empty query guard', () => { + it('does not call fetch on empty query', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + render(DocumentPickerDropdown, { onSelect: vi.fn() }); + await userEvent.fill(page.getByRole('combobox'), ''); + await waitForDebounce(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('DocumentPickerDropdown โ€” already-added indicator', () => { + it('shows already-added document as aria-disabled with sr-only hint', async () => { + mockSearchResponse([docFactory('d1', 'Brief von Eugenie'), docFactory('d2', 'Brief 2')]); + + render(DocumentPickerDropdown, { + alreadyAddedIds: new Set(['d1']), + onSelect: vi.fn() + }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + + const disabledOption = page.getByRole('option', { name: /Brief von Eugenie/i }); + await expect.element(disabledOption).toHaveAttribute('aria-disabled', 'true'); + // Screen-reader text "bereits enthalten" must be present in the option + await expect.element(page.getByText(/bereits enthalten/i)).toBeInTheDocument(); + }); +}); + +describe('DocumentPickerDropdown โ€” selection', () => { + it('calls onSelect with the item when a non-disabled option is clicked', async () => { + const onSelect = vi.fn(); + mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]); + + render(DocumentPickerDropdown, { onSelect }); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await userEvent.click(page.getByRole('option', { name: /Brief von Eugenie/i })); + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' })); + }); + + it('does not call onSelect when an aria-disabled option is clicked', 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.click(page.getByRole('option', { name: /Brief von Eugenie/i })); + + expect(onSelect).not.toHaveBeenCalled(); + }); +});