feat(lesereisen): implement lesereisen
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s

This commit was merged in pull request #787.
This commit is contained in:
2026-06-12 14:04:02 +02:00
parent 4bcf568ed4
commit b33d0eb850
142 changed files with 11643 additions and 917 deletions

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside';
import {
createDocumentTypeahead,
formatDocumentOption,
type DocumentOption
} from './documentTypeahead';
interface Props {
alreadyAddedIds?: Set<string>;
placeholder?: string;
/** Set when a visible <label for> is wired externally — replaces the aria-label fallback. */
inputId?: string;
onSelect: (doc: DocumentOption) => void;
}
let {
alreadyAddedIds = new Set(),
placeholder = m.journey_add_document(),
inputId = undefined,
onSelect
}: Props = $props();
const uid = $props.id();
const listboxId = `doc-picker-listbox-${uid}`;
const resolvedInputId = $derived(inputId ?? `doc-picker-input-${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 {
picker.close();
}
}
function handleSelect(doc: DocumentOption) {
if (alreadyAddedIds.has(doc.id!)) return;
inputValue = '';
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();
}
}
</script>
<div use:clickOutside onclickoutside={() => picker.close()} class="relative">
<input
type="text"
role="combobox"
autocomplete="off"
id={resolvedInputId}
aria-label={inputId ? undefined : placeholder}
aria-expanded={picker.isOpen}
aria-controls={picker.isOpen && !picker.loading && !picker.error && picker.results.length > 0
? listboxId
: undefined}
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}
{#if picker.loading}
<div
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</p>
</div>
{:else if picker.error}
<div
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
<p role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</p>
</div>
{:else if picker.results.length === 0}
<div
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
>
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_typeahead_no_results()}</p>
</div>
{:else}
<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"
>
{#each picker.results as doc, i (doc.id)}
{@const disabled = alreadyAddedIds.has(doc.id!)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
id={`${listboxId}-option-${i}`}
role="option"
aria-selected={i === picker.activeIndex}
aria-disabled={disabled}
onclick={() => handleSelect(doc)}
class={[
'px-3 py-2 text-ink select-none',
i === picker.activeIndex ? 'bg-muted' : '',
disabled
? 'cursor-default opacity-50'
: 'cursor-pointer hover:bg-muted'
].join(' ')}
>
{formatDocumentOption(doc)}
{#if disabled}
<span class="sr-only">{m.journey_already_added()}</span>
{/if}
</li>
{/each}
</ul>
{/if}
{/if}
</div>