fix(frontend): enforce lint locally and in CI, fix all pre-existing violations
## Pre-commit hook
- Add .husky/pre-commit at repo root: runs `cd frontend && npm run lint`
- Update prepare script in package.json to auto-configure git hooks path
on npm install (git -C .. config core.hooksPath .husky)
- Add lint step to CI unit-tests job so it catches issues before tests run
- Add generated dirs to .prettierignore (paraglide_bak*, test-results, .auth)
- Add src/lib/paraglide_bak* to .gitignore so ESLint can ignore them
## ESLint fixes (all pre-existing)
- Disable svelte/no-navigation-without-resolve: false positive in SvelteKit
(rule targets Svelte 5 standalone routing, not SvelteKit <a href>)
- Fix svelte/require-each-key: add (item.id)/(item) keys to all {#each} blocks
across 10 files — improves Svelte reconciliation performance
- Fix svelte/prefer-writable-derived in PersonTypeahead: $state+$effect → $derived
- Fix svelte/prefer-svelte-reactivity: URLSearchParams → SvelteURLSearchParams,
Map → SvelteMap (enables Svelte reactive tracking)
- Fix @typescript-eslint/no-unused-vars: remove dead imports/variables
## Prettier
- Run npm run format to bring all source files in line with .prettierrc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,123 +1,143 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
selectedPersons?: Person[];
|
||||
}
|
||||
interface Props {
|
||||
selectedPersons?: Person[];
|
||||
}
|
||||
|
||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||
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 = $state('');
|
||||
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 = $state('');
|
||||
|
||||
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 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 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 selectPerson(person: Person) {
|
||||
selectedPersons = [...selectedPersons, person];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
}
|
||||
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter(p => p.id !== id);
|
||||
}
|
||||
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.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return { destroy() { document.removeEventListener('click', handleClick, true); } };
|
||||
}
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
{#each selectedPersons as person}
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<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"
|
||||
onclick={() => removePerson(person.id)}
|
||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none ml-0.5"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<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="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
|
||||
>
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-brand-sand/40 px-2 py-1 text-sm font-medium text-brand-navy"
|
||||
>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePerson(person.id)}
|
||||
class="ml-0.5 text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<svg class="h-3 w-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}
|
||||
oninput={handleInput}
|
||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
|
||||
class="flex-1 min-w-[120px] border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={selectedPersons.length === 0 ? m.comp_multiselect_placeholder() : ''}
|
||||
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||
/>
|
||||
</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">{m.comp_multiselect_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person}
|
||||
<div
|
||||
class="cursor-pointer select-none py-2 pl-3 pr-9 hover:bg-brand-sand/30 text-gray-900"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{person.lastName}, {person.firstName}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-sm text-gray-500">{m.comp_multiselect_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person (person.id)}
|
||||
<div
|
||||
class="cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-brand-sand/30"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{person.lastName}, {person.firstName}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,129 +1,126 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
onchange?: (value: string) => void;
|
||||
}
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
onchange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
|
||||
let { name, label, value = $bindable(''), initialName = '', onchange }: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let searchTerm = $derived(initialName);
|
||||
|
||||
// Sync with external changes (e.g. reset button) — also sets the initial value
|
||||
$effect(() => {
|
||||
searchTerm = initialName;
|
||||
});
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
let results: Person[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
function handleInput() {
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = '';
|
||||
onchange?.('');
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = '';
|
||||
onchange?.('');
|
||||
}
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
|
||||
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)}`);
|
||||
results = res.ok ? await res.json() : [];
|
||||
} catch (e) {
|
||||
console.error('Suche fehlgeschlagen', e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (searchTerm.length < 1) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(searchTerm)}`);
|
||||
results = res.ok ? await res.json() : [];
|
||||
} catch (e) {
|
||||
console.error('Suche fehlgeschlagen', e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
function selectPerson(person: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
|
||||
function selectPerson(person: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = `${person.firstName} ${person.lastName}`;
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
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 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 clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() { document.removeEventListener('click', handleClick, true); }
|
||||
};
|
||||
}
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<label for={name} class="block text-sm font-medium text-gray-700">{label}</label>
|
||||
|
||||
<input type="hidden" {name} bind:value={value} />
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={m.comp_typeahead_placeholder()}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={() => { updateDropdownPosition(); showDropdown = true; }}
|
||||
placeholder={m.comp_typeahead_placeholder()}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
{#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 focus:outline-none sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-gray-500 text-sm">{m.comp_typeahead_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person}
|
||||
<div
|
||||
class="cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-blue-100 text-gray-900"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="font-medium block truncate">
|
||||
{person.lastName}, {person.firstName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-sm text-gray-500">{m.comp_typeahead_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person (person.id)}
|
||||
<div
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-gray-900 select-none hover:bg-blue-100"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="block truncate font-medium">
|
||||
{person.lastName}, {person.firstName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
|
||||
@@ -1,150 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
allowCreation?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
allowCreation?: boolean;
|
||||
}
|
||||
|
||||
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
||||
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
||||
|
||||
let inputVal = $state('');
|
||||
let suggestions: string[] = $state([]);
|
||||
let activeIndex = $state(-1);
|
||||
let showSuggestions = $state(false);
|
||||
let inputVal = $state('');
|
||||
let suggestions: string[] = $state([]);
|
||||
let activeIndex = $state(-1);
|
||||
let showSuggestions = $state(false);
|
||||
|
||||
async function fetchSuggestions(query: string) {
|
||||
if (query.length < 2) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
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);
|
||||
}
|
||||
}
|
||||
async function fetchSuggestions(query: string) {
|
||||
if (query.length < 2) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
tags = [...tags, trimmed];
|
||||
}
|
||||
inputVal = '';
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
function addTag(tag: string) {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
tags = [...tags, trimmed];
|
||||
}
|
||||
inputVal = '';
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
function removeTag(index: number) {
|
||||
tags = tags.filter((_, i) => i !== index);
|
||||
}
|
||||
function removeTag(index: number) {
|
||||
tags = tags.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
||||
addTag(suggestions[activeIndex]);
|
||||
} else if (allowCreation) {
|
||||
addTag(inputVal);
|
||||
}
|
||||
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
||||
removeTag(tags.length - 1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % suggestions.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||
}
|
||||
}
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && suggestions[activeIndex]) {
|
||||
addTag(suggestions[activeIndex]);
|
||||
} else if (allowCreation) {
|
||||
addTag(inputVal);
|
||||
}
|
||||
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
||||
removeTag(tags.length - 1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % suggestions.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||
}
|
||||
}
|
||||
|
||||
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); } };
|
||||
}
|
||||
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" 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={m.comp_taginput_remove()}
|
||||
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}
|
||||
<!-- Tag Container -->
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-gray-300 bg-white p-2 focus-within:border-brand-navy focus-within:ring-1 focus-within:ring-brand-navy"
|
||||
>
|
||||
<!-- Render Selected Tags -->
|
||||
{#each tags as tag, i (i)}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded bg-brand-sand/30 px-2 py-1 text-sm font-medium text-brand-navy"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(i)}
|
||||
aria-label={m.comp_taginput_remove()}
|
||||
class="text-brand-navy/50 hover:text-red-500 focus:outline-none"
|
||||
>
|
||||
<svg class="h-3 w-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}
|
||||
oninput={() => fetchSuggestions(inputVal)}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={() => fetchSuggestions(inputVal)}
|
||||
placeholder={tags.length === 0
|
||||
<!-- Input Field -->
|
||||
<div class="relative min-w-[120px] flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={inputVal}
|
||||
oninput={() => fetchSuggestions(inputVal)}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={() => fetchSuggestions(inputVal)}
|
||||
placeholder={tags.length === 0
|
||||
? allowCreation
|
||||
? m.comp_taginput_placeholder_create()
|
||||
: m.comp_taginput_placeholder_filter()
|
||||
: ''}
|
||||
class="w-full h-full border-none p-1 focus:ring-0 text-sm bg-transparent outline-none"
|
||||
/>
|
||||
class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||
/>
|
||||
|
||||
<!-- 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'
|
||||
<!-- Typeahead Dropdown -->
|
||||
{#if showSuggestions && suggestions.length > 0}
|
||||
<ul
|
||||
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
{#each suggestions as suggestion, i (i)}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
tabindex="0"
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-brand-sand/20 {i === activeIndex
|
||||
? 'bg-brand-sand/20 font-bold text-brand-navy'
|
||||
: '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">{m.comp_taginput_create_hint()}</p>
|
||||
{/if}
|
||||
onclick={() => addTag(suggestion)}
|
||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if allowCreation}
|
||||
<p class="mt-1 text-xs text-gray-400">{m.comp_taginput_create_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -32,17 +32,13 @@ afterEach(() => {
|
||||
describe('TagInput – rendering', () => {
|
||||
it('shows creation placeholder when allowCreation=true and no tags', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Schlagworte hinzufügen...'))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder('Schlagworte hinzufügen...')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' });
|
||||
});
|
||||
|
||||
it('shows filter placeholder when allowCreation=false', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Nach Schlagworten filtern...'))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder('Nach Schlagworten filtern...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing tags as chips', async () => {
|
||||
|
||||
Reference in New Issue
Block a user