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:
117
frontend/src/lib/components/PersonMultiSelect.svelte
Normal file
117
frontend/src/lib/components/PersonMultiSelect.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user