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:
Marcel
2026-06-09 12:43:47 +02:00
parent a619f950a5
commit 65d241f69e
3 changed files with 248 additions and 41 deletions

View 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>