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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user