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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
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';
|
||||||
|
import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte';
|
||||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||||
|
|
||||||
@@ -30,13 +31,29 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let results: DocumentOption[] = $state([]);
|
|
||||||
let showDropdown = $state(false);
|
|
||||||
let loading = $state(false);
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
|
||||||
let inputEl: HTMLInputElement;
|
let inputEl: HTMLInputElement;
|
||||||
let dropdownStyle = $state('');
|
let dropdownStyle = $state('');
|
||||||
|
|
||||||
|
const picker = createTypeahead<DocumentOption>({
|
||||||
|
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() {
|
function updateDropdownPosition() {
|
||||||
if (!inputEl) return;
|
if (!inputEl) return;
|
||||||
const rect = inputEl.getBoundingClientRect();
|
const rect = inputEl.getBoundingClientRect();
|
||||||
@@ -44,40 +61,17 @@ function updateDropdownPosition() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleInput() {
|
function handleInput() {
|
||||||
showDropdown = true;
|
if (searchTerm.trim().length >= 1) {
|
||||||
clearTimeout(debounceTimer);
|
picker.setQuery(searchTerm);
|
||||||
debounceTimer = setTimeout(async () => {
|
} else {
|
||||||
if (searchTerm.length < 1) {
|
picker.close();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectDocument(doc: DocumentOption) {
|
function selectDocument(doc: DocumentOption) {
|
||||||
selectedDocuments = [...selectedDocuments, doc];
|
selectedDocuments = [...selectedDocuments, doc];
|
||||||
searchTerm = '';
|
searchTerm = '';
|
||||||
showDropdown = false;
|
picker.close();
|
||||||
results = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDocument(id: string | undefined) {
|
function removeDocument(id: string | undefined) {
|
||||||
@@ -103,7 +97,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
|||||||
<input type="hidden" name={hiddenInputName} value={doc.id} />
|
<input type="hidden" name={hiddenInputName} value={doc.id} />
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
<div class="relative" use:clickOutside onclickoutside={() => picker.close()}>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
||||||
>
|
>
|
||||||
@@ -136,24 +130,21 @@ function formatDocLabel(doc: DocumentOption): string {
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={() => {
|
onfocus={() => updateDropdownPosition()}
|
||||||
updateDropdownPosition();
|
|
||||||
showDropdown = true;
|
|
||||||
}}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showDropdown && (results.length > 0 || loading)}
|
{#if picker.isOpen && (filteredResults.length > 0 || picker.loading)}
|
||||||
<div
|
<div
|
||||||
style={dropdownStyle}
|
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"
|
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 loading}
|
{#if picker.loading}
|
||||||
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each results as doc (doc.id)}
|
{#each filteredResults as doc (doc.id)}
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
||||||
onclick={() => selectDocument(doc)}
|
onclick={() => selectDocument(doc)}
|
||||||
|
|||||||
124
frontend/src/lib/document/DocumentPickerDropdown.svelte
Normal file
124
frontend/src/lib/document/DocumentPickerDropdown.svelte
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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';
|
||||||
|
|
||||||
|
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||||
|
type DocumentOption = Pick<
|
||||||
|
DocumentListItem,
|
||||||
|
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
alreadyAddedIds?: Set<string>;
|
||||||
|
placeholder?: string;
|
||||||
|
onSelect: (doc: DocumentOption) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
alreadyAddedIds = new Set(),
|
||||||
|
placeholder = m.journey_add_document(),
|
||||||
|
onSelect
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const listboxId = 'doc-picker-listbox';
|
||||||
|
|
||||||
|
const picker = createTypeahead<DocumentOption>({
|
||||||
|
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
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let inputValue = $state('');
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const q = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
inputValue = q;
|
||||||
|
if (q.trim().length >= 1) {
|
||||||
|
picker.setQuery(q);
|
||||||
|
} else {
|
||||||
|
picker.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(doc: DocumentOption) {
|
||||||
|
if (alreadyAddedIds.has(doc.id!)) return;
|
||||||
|
inputValue = '';
|
||||||
|
picker.close();
|
||||||
|
onSelect(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDocLabel(doc: DocumentOption): string {
|
||||||
|
if (!doc.documentDate) return doc.title;
|
||||||
|
const label = formatDocumentDate(
|
||||||
|
doc.documentDate,
|
||||||
|
doc.metaDatePrecision as DatePrecision,
|
||||||
|
doc.metaDateEnd,
|
||||||
|
null,
|
||||||
|
getLocale()
|
||||||
|
);
|
||||||
|
return `${doc.title} · ${label}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div use:clickOutside onclickoutside={() => picker.close()} class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-expanded={picker.isOpen && picker.results.length > 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)}
|
||||||
|
<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}
|
||||||
|
{#each picker.results as doc (doc.id)}
|
||||||
|
{@const disabled = alreadyAddedIds.has(doc.id!)}
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
aria-selected={false}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
onclick={() => 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}
|
||||||
|
<span class="sr-only">{m.journey_already_added()}</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -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<typeof docFactory>[]) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user