feat: add PersonMultiSelect component for chip-based multi-person selection

Replaces the native multi-select pattern with a typeahead + dismissible
chips UI. Uses fixed dropdown positioning (same getBoundingClientRect
trick as PersonTypeahead) to escape overflow:hidden parents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-16 10:23:21 +01:00
parent 62189d8bb3
commit 8d66a50652

View File

@@ -0,0 +1,117 @@
<script lang="ts">
type Person = { id?: string; firstName?: string; lastName?: string; alias?: string };
export let selectedPersons: Person[] = [];
let searchTerm = '';
let results: Person[] = [];
let showDropdown = false;
let loading = false;
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = '';
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function handleInput() {
showDropdown = true;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (searchTerm.length < 1) { results = []; return; }
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
if (res.ok) {
const all: Person[] = await res.json();
results = all.filter(p => !selectedPersons.some(s => s.id === p.id));
}
} catch { results = []; }
finally { loading = false; }
}, 300);
}
function selectPerson(person: Person) {
selectedPersons = [...selectedPersons, person];
searchTerm = '';
showDropdown = false;
results = [];
}
function removePerson(id: string | undefined) {
selectedPersons = selectedPersons.filter(p => p.id !== id);
}
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !(e as Event).defaultPrevented) {
showDropdown = false;
}
};
document.addEventListener('click', handleClick, true);
return { destroy() { document.removeEventListener('click', handleClick, true); } };
}
</script>
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
{#each selectedPersons as person}
<input type="hidden" name="receiverIds" value={person.id} />
{/each}
<div class="relative" use:clickOutside>
<div class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded bg-white min-h-[42px] focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy">
{#each selectedPersons as person}
<span class="inline-flex items-center gap-1 bg-brand-sand/40 text-brand-navy text-sm font-medium px-2 py-1 rounded">
{person.firstName} {person.lastName}
<button
type="button"
on:click={() => removePerson(person.id)}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
aria-label="Entfernen"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>
{/each}
<input
bind:this={inputEl}
type="text"
autocomplete="off"
bind:value={searchTerm}
on:input={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder={selectedPersons.length === 0 ? 'Namen tippen...' : ''}
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/>
</div>
{#if showDropdown && (results.length > 0 || loading)}
<div
style={dropdownStyle}
class="z-50 bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm"
>
{#if loading}
<div class="p-2 text-gray-500 text-sm">Suche...</div>
{:else}
{#each results as person}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
on:click={() => selectPerson(person)}
role="button"
tabindex="0"
>
{person.lastName}, {person.firstName}
</div>
{/each}
{/if}
</div>
{/if}
</div>