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
156 lines
4.5 KiB
Svelte
156 lines
4.5 KiB
Svelte
<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>
|