refactor: migrate all Svelte components from Svelte 4 to Svelte 5 runes

- Replace `export let` with `$props()` and `$bindable()` across all components
- Replace `$:` reactive statements with `$derived()` and `$effect()`
- Replace `createEventDispatcher` with callback props (e.g. `onchange`)
- Replace `on:event` directives with inline event handlers (`onclick`, `oninput`, etc.)
- Replace `<slot />` with `{@render children()}` in layout
- Use `untrack()` for SSR-safe $state initialization from reactive props
- Replace `blur` + `setTimeout` anti-pattern in TagInput with `clickOutside` action
- Fix `page` store usage in layout to use `$app/state` directly
- 0 errors, 0 warnings after svelte-check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-17 11:43:26 +01:00
parent 25e095ea47
commit 4417fc9828
14 changed files with 388 additions and 441 deletions

View File

@@ -1,15 +1,20 @@
<script lang="ts">
type Person = { id?: string; firstName?: string; lastName?: string; alias?: string };
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
export let selectedPersons: Person[] = [];
interface Props {
selectedPersons?: Person[];
}
let searchTerm = '';
let results: Person[] = [];
let showDropdown = false;
let loading = false;
let { selectedPersons = $bindable([]) }: Props = $props();
let searchTerm = $state('');
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let inputEl: HTMLInputElement;
let dropdownStyle = '';
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
@@ -47,7 +52,7 @@
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !(e as Event).defaultPrevented) {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showDropdown = false;
}
};
@@ -56,7 +61,7 @@
}
</script>
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
{#each selectedPersons as person}
<input type="hidden" name="receiverIds" value={person.id} />
@@ -69,7 +74,7 @@
{person.firstName} {person.lastName}
<button
type="button"
on:click={() => removePerson(person.id)}
onclick={() => removePerson(person.id)}
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
aria-label="Entfernen"
>
@@ -85,8 +90,8 @@
type="text"
autocomplete="off"
bind:value={searchTerm}
on:input={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
oninput={handleInput}
onfocus={() => { 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"
/>
@@ -101,10 +106,10 @@
<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)}
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>

View File

@@ -1,30 +1,33 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
// Props
export let name: string;
export let label: string;
export let value: string = "";
export let initialName: string = "";
interface Props {
name: string;
label: string;
value?: string;
initialName?: string;
onchange?: (value: string) => void;
}
const dispatch = createEventDispatcher();
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
// Lokaler State
let searchTerm = initialName;
let searchTerm = $state('');
// Sync mit externen Änderungen (z.B. Reset Button)
$: searchTerm = initialName;
// Sync with external changes (e.g. reset button) — also sets the initial value
$effect(() => {
searchTerm = initialName;
});
let results: any[] = [];
let showDropdown = false;
let loading = false;
let debounceTimer: any;
let results: Person[] = $state([]);
let showDropdown = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
function handleInput() {
// Wenn der User tippt, ist die alte ID ungültig -> Reset
if (value && searchTerm !== initialName) {
value = "";
dispatch('change', { value: "" }); // Bescheid geben: Auswahl aufgehoben
value = '';
onchange?.('');
}
showDropdown = true;
@@ -38,13 +41,9 @@
loading = true;
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
if (res.ok) {
results = await res.json();
} else {
results = [];
}
results = res.ok ? await res.json() : [];
} catch (e) {
console.error("Suche fehlgeschlagen", e);
console.error('Suche fehlgeschlagen', e);
results = [];
} finally {
loading = false;
@@ -52,17 +51,15 @@
}, 300);
}
function selectPerson(person: any) {
value = person.id;
function selectPerson(person: Person) {
value = person.id!;
searchTerm = `${person.firstName} ${person.lastName}`;
showDropdown = false;
// --- NEU: Event feuern ---
dispatch('change', { value: person.id });
onchange?.(person.id!);
}
let inputEl: HTMLInputElement;
let dropdownStyle = '';
let dropdownStyle = $state('');
function updateDropdownPosition() {
if (!inputEl) return;
@@ -71,8 +68,8 @@
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: any) => {
if (node && !node.contains(event.target) && !event.defaultPrevented) {
const handleClick = (event: MouseEvent) => {
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
showDropdown = false;
}
};
@@ -83,7 +80,7 @@
}
</script>
<svelte:window on:scroll={updateDropdownPosition} on:resize={updateDropdownPosition} />
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
<div class="relative" use:clickOutside>
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
@@ -96,8 +93,8 @@
id="{name}-search"
autocomplete="off"
bind:value={searchTerm}
on:input={handleInput}
on:focus={() => { updateDropdownPosition(); showDropdown = true; }}
oninput={handleInput}
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
placeholder="Namen tippen..."
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
/>
@@ -111,10 +108,10 @@
<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 relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
on:click={() => selectPerson(person)}
onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
role="button"
tabindex="0"
>

View File

@@ -1,13 +1,16 @@
<script lang="ts">
export let tags: string[] = []; // Two-way binding
export let allowCreation = true;
interface Props {
tags?: string[];
allowCreation?: boolean;
}
let inputVal = '';
let suggestions: string[] = [];
let activeIndex = -1;
let showSuggestions = false;
let { tags = $bindable([]), allowCreation = true }: Props = $props();
let inputVal = $state('');
let suggestions: string[] = $state([]);
let activeIndex = $state(-1);
let showSuggestions = $state(false);
// Fetch suggestions from backend
async function fetchSuggestions(query: string) {
if (query.length < 2) {
suggestions = [];
@@ -17,13 +20,12 @@
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
if (res.ok) {
const data = await res.json();
// API returns Tag objects with { id, name }
const names: string[] = data.map((t: { name: string }) => t.name);
suggestions = names.filter((t) => !tags.includes(t));
showSuggestions = true;
}
} catch (e) {
console.error("Tag fetch error", e);
console.error('Tag fetch error', e);
}
}
@@ -47,8 +49,8 @@
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
addTag(suggestions[activeIndex]);
} else if(allowCreation) {
addTag(inputVal); // Add new tag
} else if (allowCreation) {
addTag(inputVal);
}
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
removeTag(tags.length - 1);
@@ -61,81 +63,86 @@
}
}
function handleInput() {
fetchSuggestions(inputVal);
function clickOutside(node: HTMLElement) {
const handleClick = (e: MouseEvent) => {
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
showSuggestions = false;
}
};
document.addEventListener('click', handleClick, true);
return { destroy() { document.removeEventListener('click', handleClick, true); } };
}
</script>
<div class="w-full">
<!-- Tag Container -->
<div
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy bg-white min-h-[42px]"
>
<!-- Render Selected Tags -->
{#each tags as tag, i}
<span
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
>
{tag}
<button
type="button"
on:click={() => removeTag(i)}
aria-label="Schlagwort entfernen"
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
>
<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}
<div class="w-full" use:clickOutside>
<!-- Tag Container -->
<div
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded focus-within:ring-1 focus-within:ring-brand-navy focus-within:border-brand-navy bg-white min-h-[42px]"
>
<!-- Render Selected Tags -->
{#each tags as tag, i}
<span
class="bg-brand-sand/30 text-brand-navy text-sm font-medium px-2 py-1 rounded flex items-center gap-1"
>
{tag}
<button
type="button"
onclick={() => removeTag(i)}
aria-label="Schlagwort entfernen"
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
>
<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 Field -->
<div class="relative flex-1 min-w-[120px]">
<input
type="text"
bind:value={inputVal}
on:input={handleInput}
on:keydown={handleKeydown}
on:focus={() => handleInput()}
on:blur={() => setTimeout(() => (showSuggestions = false), 200)}
placeholder={tags.length === 0
? allowCreation
? 'Schlagworte hinzufügen...'
: 'Nach Schlagworten filtern...'
: ''}
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/>
<!-- Input Field -->
<div class="relative flex-1 min-w-[120px]">
<input
type="text"
bind:value={inputVal}
oninput={() => fetchSuggestions(inputVal)}
onkeydown={handleKeydown}
onfocus={() => fetchSuggestions(inputVal)}
placeholder={tags.length === 0
? allowCreation
? 'Schlagworte hinzufügen...'
: 'Nach Schlagworten filtern...'
: ''}
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
/>
<!-- Typeahead Dropdown -->
{#if showSuggestions && suggestions.length > 0}
<ul
class="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded shadow-lg z-50 max-h-48 overflow-y-auto"
>
{#each suggestions as suggestion, i}
<li
role="option"
aria-selected={i === activeIndex}
tabindex="0"
class="px-3 py-2 text-sm cursor-pointer hover:bg-brand-sand/20 {i === activeIndex
? 'bg-brand-sand/20 text-brand-navy font-bold'
: 'text-gray-700'}"
on:click={() => addTag(suggestion)}
on:keydown={(e) => e.key === 'Enter' && addTag(suggestion)}
>
{suggestion}
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if allowCreation}
<p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p>
{/if}
<!-- Typeahead Dropdown -->
{#if showSuggestions && suggestions.length > 0}
<ul
class="absolute left-0 top-full mt-1 w-full bg-white border border-gray-200 rounded shadow-lg z-50 max-h-48 overflow-y-auto"
>
{#each suggestions as suggestion, i}
<li
role="option"
aria-selected={i === activeIndex}
tabindex="0"
class="px-3 py-2 text-sm cursor-pointer hover:bg-brand-sand/20 {i === activeIndex
? 'bg-brand-sand/20 text-brand-navy font-bold'
: 'text-gray-700'}"
onclick={() => addTag(suggestion)}
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
>
{suggestion}
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if allowCreation}
<p class="text-xs text-gray-400 mt-1">Enter drücken um Schlagwort zu erstellen.</p>
{/if}
</div>