feat(#248): add TagParentPicker combobox component with excludeIds filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
115
frontend/src/lib/components/TagParentPicker.svelte
Normal file
115
frontend/src/lib/components/TagParentPicker.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
||||
|
||||
type Tag = components['schemas']['Tag'];
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
value?: string;
|
||||
excludeIds?: string[];
|
||||
initialName?: string;
|
||||
}
|
||||
|
||||
let { name, value = $bindable(''), excludeIds = [], initialName = '' }: Props = $props();
|
||||
|
||||
// displayName must be both prop-derived AND locally writable (user typing), so $state +
|
||||
// $effect is the correct pattern here — writable $derived is read-only and won't work.
|
||||
// eslint-disable-next-line svelte/prefer-writable-derived
|
||||
let displayName = $state(initialName);
|
||||
|
||||
$effect(() => {
|
||||
displayName = initialName;
|
||||
});
|
||||
|
||||
const typeahead = createTypeahead<Tag>({
|
||||
fetchUrl: async (q) => {
|
||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(q)}`);
|
||||
return res.ok ? await res.json() : [];
|
||||
},
|
||||
debounceMs: 300
|
||||
});
|
||||
|
||||
const filteredResults = $derived(typeahead.results.filter((t) => !excludeIds.includes(t.id)));
|
||||
|
||||
function handleInput() {
|
||||
const term = untrack(() => displayName);
|
||||
typeahead.setQuery(term);
|
||||
}
|
||||
|
||||
function selectTag(tag: Tag) {
|
||||
value = tag.id;
|
||||
displayName = tag.name;
|
||||
typeahead.close();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
value = '';
|
||||
displayName = '';
|
||||
typeahead.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={typeahead.isOpen}
|
||||
aria-controls="{name}-listbox"
|
||||
aria-autocomplete="list"
|
||||
bind:value={displayName}
|
||||
oninput={handleInput}
|
||||
placeholder={m.admin_tag_parent_placeholder()}
|
||||
class="mt-1 block w-full rounded-md border border-line bg-surface p-2 pr-8 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
|
||||
{#if value}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearSelection}
|
||||
aria-label="Auswahl entfernen"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2 text-ink-3 hover:text-ink focus:outline-none"
|
||||
>
|
||||
<svg class="h-4 w-4" 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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if typeahead.isOpen && filteredResults.length > 0}
|
||||
<div
|
||||
id="{name}-listbox"
|
||||
role="listbox"
|
||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||
>
|
||||
{#each filteredResults as tag (tag.id)}
|
||||
<div
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
||||
onclick={() => selectTag(tag)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectTag(tag)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="block truncate font-medium">{tag.name}</span>
|
||||
{#if tag.parentId}
|
||||
<span class="block truncate text-xs text-ink-3">{tag.parentId}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
127
frontend/src/lib/components/TagParentPicker.svelte.spec.ts
Normal file
127
frontend/src/lib/components/TagParentPicker.svelte.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TagParentPicker from './TagParentPicker.svelte';
|
||||
|
||||
function hiddenInput(name: string) {
|
||||
return document.querySelector<HTMLInputElement>(`input[type="hidden"][name="${name}"]`);
|
||||
}
|
||||
|
||||
function mockFetchWithTags(tags: { id: string; name: string; parentId?: string }[]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(tags)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagParentPicker – rendering', () => {
|
||||
it('renders the text input', async () => {
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hidden input with correct name', async () => {
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hiddenInput('parentId')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagParentPicker – search', () => {
|
||||
it('typing shows dropdown results', async () => {
|
||||
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('H');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await expect.element(page.getByText('Haus')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters excludeIds from results', async () => {
|
||||
mockFetchWithTags([
|
||||
{ id: 't1', name: 'Haus' },
|
||||
{ id: 't2', name: 'Garten' }
|
||||
]);
|
||||
render(TagParentPicker, { name: 'parentId', excludeIds: ['t1'] });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('a');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await expect.element(page.getByText('Haus')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Garten')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selection ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagParentPicker – selection', () => {
|
||||
it('selecting an option sets the hidden input value', async () => {
|
||||
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('H');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(hiddenInput('parentId')?.value).toBe('t1');
|
||||
});
|
||||
|
||||
it('clear button appears when value is set', async () => {
|
||||
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('H');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Auswahl entfernen' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clear button resets value', async () => {
|
||||
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('H');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(hiddenInput('parentId')?.value).toBe('t1');
|
||||
|
||||
const clearBtn = document.querySelector<HTMLElement>('button[aria-label="Auswahl entfernen"]')!;
|
||||
clearBtn.click();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(hiddenInput('parentId')?.value).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user