refactor: move person domain components and utils to lib/person/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,202 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||
|
||||
export type RelFormData = {
|
||||
relatedPersonId: string;
|
||||
relationType: RelationType;
|
||||
fromYear?: number;
|
||||
toYear?: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
personId: string;
|
||||
onSubmit?: (data: RelFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
let { personId, onSubmit }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let addType = $state<RelationType>('PARENT_OF');
|
||||
let addRelatedPersonId = $state('');
|
||||
let addRelatedPersonName = $state('');
|
||||
let addFromYear = $state('');
|
||||
let addToYear = $state('');
|
||||
let callbackError = $state<string | null>(null);
|
||||
|
||||
const yearError = $derived.by(() => {
|
||||
const from = addFromYear.trim();
|
||||
const to = addToYear.trim();
|
||||
if (!from || !to) return null;
|
||||
const fromInt = parseInt(from, 10);
|
||||
const toInt = parseInt(to, 10);
|
||||
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
|
||||
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
|
||||
});
|
||||
|
||||
const selfError = $derived(
|
||||
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
|
||||
);
|
||||
|
||||
const submitDisabled = $derived(
|
||||
yearError !== null || selfError !== null || addRelatedPersonId === ''
|
||||
);
|
||||
|
||||
function reset() {
|
||||
addType = 'PARENT_OF';
|
||||
addRelatedPersonId = '';
|
||||
addRelatedPersonName = '';
|
||||
addFromYear = '';
|
||||
addToYear = '';
|
||||
callbackError = null;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
open = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
async function handleCallbackSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
if (submitDisabled || !onSubmit) return;
|
||||
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
|
||||
const from = parseInt(addFromYear.trim(), 10);
|
||||
if (!Number.isNaN(from)) data.fromYear = from;
|
||||
const to = parseInt(addToYear.trim(), 10);
|
||||
if (!Number.isNaN(to)) data.toYear = to;
|
||||
try {
|
||||
await onSubmit(data);
|
||||
open = false;
|
||||
reset();
|
||||
} catch {
|
||||
callbackError = m.error_internal_error();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet formFields()}
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_type()}</span>
|
||||
<select
|
||||
name="relationType"
|
||||
bind:value={addType}
|
||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||
>
|
||||
<optgroup label={m.relation_form_group_family()}>
|
||||
<option value="PARENT_OF">{m.relation_parent_of()}</option>
|
||||
<option value="SPOUSE_OF">{m.relation_spouse_of()}</option>
|
||||
<option value="SIBLING_OF">{m.relation_sibling_of()}</option>
|
||||
</optgroup>
|
||||
<optgroup label={m.relation_form_group_social()}>
|
||||
<option value="FRIEND">{m.relation_friend()}</option>
|
||||
<option value="COLLEAGUE">{m.relation_colleague()}</option>
|
||||
<option value="EMPLOYER">{m.relation_employer()}</option>
|
||||
<option value="DOCTOR">{m.relation_doctor()}</option>
|
||||
<option value="NEIGHBOR">{m.relation_neighbor()}</option>
|
||||
<option value="OTHER">{m.relation_other()}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<div>
|
||||
<PersonTypeahead
|
||||
name="relatedPersonId"
|
||||
label="Person"
|
||||
bind:value={addRelatedPersonId}
|
||||
initialName={addRelatedPersonName}
|
||||
excludePersonId={personId}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="font-sans text-xs font-medium text-ink-2"
|
||||
>{m.relation_form_field_from_year()}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="fromYear"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
bind:value={addFromYear}
|
||||
placeholder={m.relation_form_year_placeholder()}
|
||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="toYear"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
bind:value={addToYear}
|
||||
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
|
||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||
/>
|
||||
{#if yearError}
|
||||
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
|
||||
{yearError}
|
||||
</p>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
{#if selfError}
|
||||
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
|
||||
{/if}
|
||||
{#if callbackError}
|
||||
<p class="mt-2 text-xs text-red-700" role="alert">{callbackError}</p>
|
||||
{/if}
|
||||
<div class="mt-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancel}
|
||||
class="rounded-sm border border-line bg-surface px-3 py-1.5 font-sans text-xs font-medium text-ink-2 transition hover:bg-muted"
|
||||
>
|
||||
{m.relation_btn_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitDisabled}
|
||||
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
|
||||
>
|
||||
{m.relation_btn_add()}
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if !open}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = true)}
|
||||
class="mt-2 inline-flex items-center gap-1 font-sans text-xs font-medium text-ink-2 hover:text-ink"
|
||||
>
|
||||
{m.stammbaum_panel_add_rel()}
|
||||
</button>
|
||||
{:else if onSubmit}
|
||||
<form onsubmit={handleCallbackSubmit} class="mt-3 rounded-sm border border-line bg-muted/40 p-3">
|
||||
{@render formFields()}
|
||||
</form>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addRelationship"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') {
|
||||
open = false;
|
||||
reset();
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
|
||||
>
|
||||
{@render formFields()}
|
||||
</form>
|
||||
{/if}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import AddRelationshipForm from './AddRelationshipForm.svelte';
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AddRelationshipForm', () => {
|
||||
it('shows add-relationship button initially and no form', async () => {
|
||||
render(AddRelationshipForm, { personId: 'person-1' });
|
||||
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows relationType select when add button is clicked', async () => {
|
||||
render(AddRelationshipForm, { personId: 'person-1' });
|
||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides form and shows button when cancel is clicked', async () => {
|
||||
render(AddRelationshipForm, { personId: 'person-1' });
|
||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
|
||||
);
|
||||
cancelBtn!.click();
|
||||
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submit is disabled when no person is selected', async () => {
|
||||
render(AddRelationshipForm, { personId: 'person-1' });
|
||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('form has no server action when onSubmit prop is provided', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
|
||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
const form = document.querySelector('form');
|
||||
expect(form?.hasAttribute('action')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows year-range error when toYear is before fromYear', async () => {
|
||||
render(AddRelationshipForm, { personId: 'person-1' });
|
||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
|
||||
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
|
||||
fromInput.value = '1935';
|
||||
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
|
||||
toInput.value = '1920';
|
||||
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { abbreviateName, getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
|
||||
type Props = {
|
||||
person: Person;
|
||||
abbreviated: boolean;
|
||||
};
|
||||
|
||||
let { person, abbreviated }: Props = $props();
|
||||
|
||||
const name = $derived(abbreviated ? abbreviateName(person) : person.displayName);
|
||||
const avatarColor = $derived(personAvatarColor(person.id));
|
||||
const initials = $derived(getInitials(person.displayName));
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-muted px-2 py-0.5 hover:border-primary hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<span
|
||||
class="flex h-[25px] w-[25px] shrink-0 items-center justify-center rounded-full text-[13px] font-bold text-white"
|
||||
style="background-color: {avatarColor}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span class="text-[14px] font-semibold text-ink">{name}</span>
|
||||
</a>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import PersonChip from './PersonChip.svelte';
|
||||
import OverflowPillDisplay from './OverflowPillDisplay.svelte';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
|
||||
type Props = {
|
||||
sender: Person | null | undefined;
|
||||
receivers: Person[];
|
||||
abbreviated: boolean;
|
||||
extraCount: number;
|
||||
};
|
||||
|
||||
let { sender, receivers, abbreviated, extraCount }: Props = $props();
|
||||
|
||||
const visibleReceivers = $derived(receivers.slice(0, 2));
|
||||
</script>
|
||||
|
||||
<div class="hidden min-w-0 items-center gap-1.5 overflow-hidden xs:flex">
|
||||
{#if sender}
|
||||
<PersonChip person={sender} abbreviated={abbreviated} />
|
||||
{/if}
|
||||
|
||||
{#if sender && receivers.length > 0}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6 shrink-0 opacity-40"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each visibleReceivers as receiver, i (receiver.id)}
|
||||
<span class={i === 1 ? 'hidden md:contents' : ''}>
|
||||
<PersonChip person={receiver} abbreviated={abbreviated} />
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if extraCount > 0}
|
||||
<OverflowPillDisplay extraCount={extraCount} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,303 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import { chipLabel, otherName } from '$lib/relationshipLabels';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { LoadState } from '$lib/types/personHoverCard';
|
||||
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
type Props = {
|
||||
personId: string;
|
||||
cardId: string;
|
||||
position: { top: number; left: number };
|
||||
state: LoadState;
|
||||
onmouseenter?: () => void;
|
||||
onmouseleave?: () => void;
|
||||
};
|
||||
|
||||
let { personId, cardId, position, state, onmouseenter, onmouseleave }: Props = $props();
|
||||
|
||||
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
|
||||
'PARENT_OF',
|
||||
'SPOUSE_OF',
|
||||
'SIBLING_OF'
|
||||
]);
|
||||
const NOTES_MAX = 120;
|
||||
|
||||
const familyChips = $derived(
|
||||
state.status === 'loaded'
|
||||
? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
|
||||
: []
|
||||
);
|
||||
|
||||
const dateRange = $derived(
|
||||
state.status === 'loaded'
|
||||
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
|
||||
: ''
|
||||
);
|
||||
|
||||
/**
|
||||
* Cut the notes excerpt at the last word boundary inside the NOTES_MAX
|
||||
* window. Mid-word truncation is especially ugly in German compound nouns
|
||||
* ("…Familienzu…"), so prefer the previous space if there is one within
|
||||
* a reasonable distance. Fall back to a hard cut for strings with no
|
||||
* spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5.
|
||||
*/
|
||||
function truncateAtWordBoundary(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
const window = text.slice(0, max);
|
||||
const lastSpace = window.lastIndexOf(' ');
|
||||
// If the last space is too close to the start (< 70% of the window) we'd
|
||||
// produce a near-empty excerpt — fall back to the hard cut instead.
|
||||
const minBoundary = Math.floor(max * 0.7);
|
||||
const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window;
|
||||
return cut + '…';
|
||||
}
|
||||
|
||||
const notesExcerpt = $derived.by(() => {
|
||||
if (state.status !== 'loaded') return null;
|
||||
const notes = state.person.notes;
|
||||
if (!notes) return null;
|
||||
return truncateAtWordBoundary(notes, NOTES_MAX);
|
||||
});
|
||||
|
||||
// Accessible name for the region landmark — required by WCAG 1.3.1.
|
||||
// Falls back to a localised loading label so axe-core never sees an unnamed
|
||||
// region (Leonie FINDING-02 / Elicit NFR concern).
|
||||
const ariaLabel = $derived(
|
||||
state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()
|
||||
);
|
||||
|
||||
// aria-busy="true" while loading so SR clients know the region's contents
|
||||
// will change. Cleared on loaded/error so the new content is announced.
|
||||
const ariaBusy = $derived(state.status === 'loading');
|
||||
|
||||
const showMaidenName = $derived(
|
||||
state.status === 'loaded' &&
|
||||
!!state.person.alias &&
|
||||
state.person.alias !== state.person.lastName &&
|
||||
state.person.alias !== state.person.displayName
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="person-hover-card"
|
||||
data-testid="person-hover-card"
|
||||
id={cardId}
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={ariaBusy ? 'true' : undefined}
|
||||
style:position="fixed"
|
||||
style:top={`${position.top}px`}
|
||||
style:left={`${position.left}px`}
|
||||
onmouseenter={onmouseenter}
|
||||
onmouseleave={onmouseleave}
|
||||
onfocusin={onmouseenter}
|
||||
onfocusout={(e) => {
|
||||
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) {
|
||||
onmouseleave?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if state.status === 'loading'}
|
||||
<div
|
||||
data-testid="person-hover-card-skeleton"
|
||||
class="skeleton"
|
||||
role="status"
|
||||
aria-label={m.person_mention_loading()}
|
||||
>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
{:else if state.status === 'error'}
|
||||
<div data-testid="person-hover-card-error" class="error-message">
|
||||
{m.person_mention_load_error()}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div data-testid="person-hover-card-content" class="content">
|
||||
<div class="header">
|
||||
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
|
||||
{#if dateRange}
|
||||
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
|
||||
{/if}
|
||||
{#if showMaidenName}
|
||||
<div class="maiden" data-testid="person-hover-card-maiden">
|
||||
{m.person_born_name_prefix()}
|
||||
{state.person.alias}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if familyChips.length > 0}
|
||||
<div class="chips" data-testid="person-hover-card-chips">
|
||||
{#each familyChips as chip (chip.id)}
|
||||
<span class="chip">
|
||||
<span class="chip-type">{chipLabel(chip, personId)}:</span>
|
||||
{otherName(chip, personId)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if notesExcerpt}
|
||||
<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
|
||||
{/if}
|
||||
<div class="footer">
|
||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||
<span class="hint">{m.person_mention_hover_hint()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.person-hover-card {
|
||||
width: 320px;
|
||||
min-height: 180px;
|
||||
background-color: var(--c-surface);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
color: var(--c-ink);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* On touch devices the card is suppressed entirely — tap navigates directly. */
|
||||
@media (hover: none) {
|
||||
.person-hover-card {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.skeleton .bar {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--c-line);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton .bar:nth-child(1) {
|
||||
width: 60%;
|
||||
}
|
||||
.skeleton .bar:nth-child(2) {
|
||||
width: 40%;
|
||||
}
|
||||
.skeleton .bar:nth-child(3) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton .bar {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-surface);
|
||||
margin: -14px -16px 12px;
|
||||
padding: 12px 16px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dates,
|
||||
.maiden {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--c-surface) 75%, transparent);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
background-color: var(--c-accent-bg);
|
||||
color: var(--c-ink);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.chip-type {
|
||||
font-weight: 600;
|
||||
/* opacity 0.7 on --c-ink: ~5.6:1 light, ~7.1:1 dark — WCAG AA ✓ */
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 13px;
|
||||
color: var(--c-ink-2);
|
||||
line-height: 1.4;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 13px;
|
||||
color: var(--c-ink-2);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--c-line);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.open-link {
|
||||
color: var(--c-ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--c-ink-3);
|
||||
}
|
||||
</style>
|
||||
@@ -1,373 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonHoverCard from './PersonHoverCard.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
const AUGUSTE: Person = {
|
||||
id: 'p-aug',
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true,
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
} as unknown as Person;
|
||||
|
||||
const POSITION = { top: 100, left: 200 };
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('PersonHoverCard — loading state', () => {
|
||||
it('shows the skeleton when state.status is loading', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders three skeleton bars', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar');
|
||||
expect(bars.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — error state', () => {
|
||||
it('shows a generic error message when state.status is error', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'error' }
|
||||
});
|
||||
await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still allows the link footer to navigate (link present in error state)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'error' }
|
||||
});
|
||||
// The card root must show the footer link even when the body errored —
|
||||
// click navigation works regardless of fetch outcome.
|
||||
const link = document.querySelector('a[href="/persons/p-aug"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — loaded state', () => {
|
||||
it('renders the person displayName as the header name', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the life-date range when birthYear and deathYear are present', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the life-date line when both years are missing', async () => {
|
||||
const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: noDates, relationships: [] }
|
||||
});
|
||||
const dates = document.querySelector('[data-testid="person-hover-card-dates"]');
|
||||
expect(dates).toBeNull();
|
||||
});
|
||||
|
||||
it('renders "geb. <alias>" when alias is set', async () => {
|
||||
const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withAlias, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('geb. Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the maiden name line when alias is null', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]');
|
||||
expect(maiden).toBeNull();
|
||||
});
|
||||
|
||||
it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => {
|
||||
const relationships: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-spouse',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Otto Raddatz',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-friend',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Karl Friend',
|
||||
relationType: 'FRIEND'
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-sibling',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Marie Sister',
|
||||
relationType: 'SIBLING_OF'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||||
});
|
||||
await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Marie Sister')).toBeInTheDocument();
|
||||
// Non-family relationship type must be filtered out
|
||||
const friendChip = page.getByText('Karl Friend');
|
||||
await expect.element(friendChip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the other person name when hovered person is the object (relatedPersonId) in a PARENT_OF row', async () => {
|
||||
// Storage: Heinrich PARENT_OF Auguste. When viewing Auguste's card,
|
||||
// the chip must show "Heinrich" (the parent), not "Auguste" (herself).
|
||||
const relationships: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r-parent',
|
||||
personId: 'p-heinrich',
|
||||
relatedPersonId: 'p-aug',
|
||||
personDisplayName: 'Heinrich Raddatz',
|
||||
relatedPersonDisplayName: 'Auguste Raddatz',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||||
});
|
||||
await expect.element(page.getByText('Heinrich Raddatz')).toBeInTheDocument();
|
||||
// Auguste must NOT appear as her own parent chip name
|
||||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||||
expect(chips?.textContent).not.toContain('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('omits the chips section entirely when no family relationships', async () => {
|
||||
const onlyFriend: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-friend',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Karl Friend',
|
||||
relationType: 'FRIEND'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
|
||||
});
|
||||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||||
expect(chips).toBeNull();
|
||||
});
|
||||
|
||||
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
|
||||
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withNotes, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => {
|
||||
// Single 150-char word with no spaces: word-boundary cut would yield nothing,
|
||||
// so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length).
|
||||
const long = 'x'.repeat(150);
|
||||
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||
expect(notes.textContent).toBe('x'.repeat(120) + '…');
|
||||
});
|
||||
|
||||
it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => {
|
||||
// 150-char string with spaces — must cut at the last space, not mid-word.
|
||||
const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3);
|
||||
// length is 180, last space at idx ≤120
|
||||
const withLongNotes = { ...AUGUSTE, notes: sentence } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||
const text = notes.textContent ?? '';
|
||||
// Ends with ellipsis
|
||||
expect(text.endsWith('…')).toBe(true);
|
||||
// Last char before the ellipsis is NOT a half-word — verify by checking that
|
||||
// the position right before … is the end of a word (i.e., there's no letter
|
||||
// further along in the original text immediately after our cut point).
|
||||
const cut = text.slice(0, -1); // strip the …
|
||||
// Find this cut substring in the original sentence
|
||||
const idx = sentence.indexOf(cut);
|
||||
expect(idx).toBe(0);
|
||||
const charAfterCut = sentence[cut.length];
|
||||
// The next char should be a space — confirming we cut on a boundary
|
||||
expect(charAfterCut).toBe(' ');
|
||||
});
|
||||
|
||||
it('omits notes section when notes is null', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
|
||||
expect(notes).toBeNull();
|
||||
});
|
||||
|
||||
it('footer renders an anchor link to /persons/{personId}', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const link = document.querySelector('a[href="/persons/p-aug"]')!;
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — accessibility', () => {
|
||||
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('sets aria-busy="true" while loading so SR announces the state change on load', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-busy')).toBe('true');
|
||||
});
|
||||
|
||||
it('does not set aria-busy when loaded (so the loaded content is announced)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
// aria-busy is either absent or "false"
|
||||
const busy = root.getAttribute('aria-busy');
|
||||
expect(busy === null || busy === 'false').toBe(true);
|
||||
});
|
||||
|
||||
it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('names the region with a generic loading label while loading', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
// Region must have an accessible name in every state — axe-core flags
|
||||
// role="region" without aria-label / aria-labelledby.
|
||||
expect(root.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-xyz',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.id).toBe('card-xyz');
|
||||
});
|
||||
|
||||
it('positions itself fixed at the given top/left', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: { top: 333, left: 444 },
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
|
||||
expect(root.style.top).toBe('333px');
|
||||
expect(root.style.left).toBe('444px');
|
||||
expect(root.style.position).toBe('fixed');
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
selectedPersons?: Person[];
|
||||
}
|
||||
|
||||
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('');
|
||||
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
{/each}
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<div
|
||||
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
||||
>
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||
>
|
||||
{person.displayName}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePerson(person.id)}
|
||||
class="ml-0.5 text-ink/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="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="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
||||
{:else}
|
||||
{#each results as person (person.id)}
|
||||
<div
|
||||
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{person.displayName}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,292 +0,0 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonMultiSelect from './PersonMultiSelect.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const PERSONS = [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
firstName: 'Karl',
|
||||
lastName: 'König',
|
||||
displayName: 'Karl König',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
];
|
||||
|
||||
function mockFetch(persons = PERSONS) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(persons)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function receiverInputs() {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLInputElement>('input[type="hidden"][name="receiverIds"]')
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMultiSelect – rendering', () => {
|
||||
it('renders the text input with placeholder when no persons selected', async () => {
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-multiselect-empty.png' });
|
||||
});
|
||||
|
||||
it('renders pre-selected persons as chips', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-multiselect-with-chips.png' });
|
||||
});
|
||||
|
||||
it('renders hidden inputs for each selected person', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
await tick();
|
||||
const inputs = receiverInputs();
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0].value).toBe('1');
|
||||
expect(inputs[1].value).toBe('2');
|
||||
});
|
||||
|
||||
it('hides the placeholder when persons are selected', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selecting persons ────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMultiSelect – selecting persons', () => {
|
||||
it('adds a person chip on result click', async () => {
|
||||
mockFetch();
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Max Mustermann').click();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/person-multiselect-one-selected.png'
|
||||
});
|
||||
});
|
||||
|
||||
it('can select multiple persons sequentially', async () => {
|
||||
mockFetch();
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
const input = page.getByRole('textbox');
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Max Mustermann').click();
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Anna Musterfrau').click();
|
||||
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
await page.screenshot({
|
||||
path: 'test-results/screenshots/person-multiselect-two-selected.png'
|
||||
});
|
||||
});
|
||||
|
||||
it('filters already-selected persons from search results', async () => {
|
||||
mockFetch();
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
// Chip still shows "Max Mustermann" but the dropdown item (role=button) must be filtered out
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
||||
.not.toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: 'Anna Musterfrau' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a result with Enter key', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]);
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Max Mustermann').click();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Removing persons ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMultiSelect – removing persons', () => {
|
||||
it('removes a chip when its × button is clicked', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
// Buttons have aria-label="Entfernen"
|
||||
const removeButtons = page.getByRole('button', { name: 'Entfernen' });
|
||||
await removeButtons.first().click();
|
||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes the corresponding hidden input when a chip is removed', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
await page.getByRole('button', { name: 'Entfernen' }).first().click();
|
||||
await tick();
|
||||
const inputs = receiverInputs();
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].value).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Click outside ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMultiSelect – click outside', () => {
|
||||
it('closes the dropdown when clicking outside', async () => {
|
||||
mockFetch();
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
personType: string;
|
||||
}
|
||||
|
||||
let { personType }: Props = $props();
|
||||
|
||||
const config = $derived.by(() => {
|
||||
switch (personType) {
|
||||
case 'INSTITUTION':
|
||||
return { label: m.person_type_INSTITUTION(), icon: 'building' as const };
|
||||
case 'GROUP':
|
||||
return { label: m.person_type_GROUP(), icon: 'people' as const };
|
||||
case 'UNKNOWN':
|
||||
return { label: m.person_type_UNKNOWN(), icon: 'question' as const };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if config}
|
||||
<span
|
||||
class="badge inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium"
|
||||
class:badge-institution={personType === 'INSTITUTION'}
|
||||
class:badge-group={personType === 'GROUP'}
|
||||
class:badge-unknown={personType === 'UNKNOWN'}
|
||||
>
|
||||
{#if config.icon === 'building'}
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-2 0v-2M5 21H3m2 0v-2m4-12h2m-2 4h2m4-4h2m-2 4h2"
|
||||
/>
|
||||
</svg>
|
||||
{:else if config.icon === 'people'}
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{config.label}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.badge-institution {
|
||||
background-color: var(--c-badge-institution-bg);
|
||||
color: var(--c-badge-institution-text);
|
||||
border-color: var(--c-badge-institution-border);
|
||||
}
|
||||
.badge-group {
|
||||
background-color: var(--c-badge-group-bg);
|
||||
color: var(--c-badge-group-text);
|
||||
border-color: var(--c-badge-group-border);
|
||||
}
|
||||
.badge-unknown {
|
||||
background-color: var(--c-badge-unknown-bg);
|
||||
color: var(--c-badge-unknown-text);
|
||||
border-color: var(--c-badge-unknown-border);
|
||||
}
|
||||
</style>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { radioGroupNav } from '$lib/actions/radioGroupNav';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
|
||||
|
||||
let {
|
||||
value = 'PERSON',
|
||||
name = 'personType',
|
||||
onchange
|
||||
}: { value?: string; name?: string; onchange?: (type: PersonType) => void } = $props();
|
||||
|
||||
let selected = $state<PersonType>(
|
||||
untrack(() => (TYPES.includes(value as PersonType) ? (value as PersonType) : 'PERSON'))
|
||||
);
|
||||
|
||||
let announcement = $state('');
|
||||
|
||||
const labels: Record<PersonType, () => string> = {
|
||||
PERSON: m.person_type_PERSON,
|
||||
INSTITUTION: m.person_type_INSTITUTION,
|
||||
GROUP: m.person_type_GROUP,
|
||||
UNKNOWN: m.person_type_UNKNOWN
|
||||
};
|
||||
|
||||
function select(type: PersonType) {
|
||||
selected = type;
|
||||
announcement = m.a11y_type_changed({ type: labels[type]() });
|
||||
onchange?.(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={m.form_label_person_type()}
|
||||
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
|
||||
>
|
||||
{#each TYPES as type (type)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
value={type}
|
||||
aria-checked={selected === type}
|
||||
tabindex={selected === type ? 0 : -1}
|
||||
onclick={() => select(type)}
|
||||
class="min-h-[48px] cursor-pointer rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected === type
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||
>
|
||||
{labels[type]()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name={name} value={selected} />
|
||||
|
||||
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
|
||||
import PersonTypeSelector from './PersonTypeSelector.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('PersonTypeSelector', () => {
|
||||
it('radiogroup has an accessible name via aria-label', () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const radiogroup = container.querySelector('[role="radiogroup"]');
|
||||
expect(radiogroup).not.toBeNull();
|
||||
expect(radiogroup!.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hidden input value updates when user navigates with ArrowRight', async () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||
expect(hiddenInput.value).toBe('PERSON');
|
||||
|
||||
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
||||
personButton.focus();
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
|
||||
expect(hiddenInput.value).toBe('INSTITUTION');
|
||||
});
|
||||
|
||||
it('hidden input value updates when user navigates with ArrowLeft (wraps around)', async () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||
expect(hiddenInput.value).toBe('PERSON');
|
||||
|
||||
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
||||
personButton.focus();
|
||||
await userEvent.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(hiddenInput.value).toBe('UNKNOWN');
|
||||
});
|
||||
it('exactly one button is aria-checked=true for the initial value', () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'INSTITUTION' });
|
||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||
const checked = buttons.filter((b) => b.getAttribute('aria-checked') === 'true');
|
||||
const unchecked = buttons.filter((b) => b.getAttribute('aria-checked') === 'false');
|
||||
expect(checked).toHaveLength(1);
|
||||
expect(unchecked).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('aria-checked=true moves to clicked button on click', async () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||
const groupButton = buttons.find((b) => b.getAttribute('value') === 'GROUP') as HTMLElement;
|
||||
await userEvent.click(groupButton);
|
||||
expect(groupButton.getAttribute('aria-checked')).toBe('true');
|
||||
const others = buttons.filter((b) => b !== groupButton);
|
||||
for (const btn of others) {
|
||||
expect(btn.getAttribute('aria-checked')).toBe('false');
|
||||
}
|
||||
});
|
||||
|
||||
it('selected button has tabindex=0, unselected buttons have tabindex=-1', () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||
const selected = buttons.find((b) => b.getAttribute('aria-checked') === 'true');
|
||||
const unselected = buttons.filter((b) => b.getAttribute('aria-checked') !== 'true');
|
||||
expect(selected!.getAttribute('tabindex')).toBe('0');
|
||||
for (const btn of unselected) {
|
||||
expect(btn.getAttribute('tabindex')).toBe('-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
<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';
|
||||
import FieldLabelBadge from '$lib/document/FieldLabelBadge.svelte';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
initialName?: string;
|
||||
suggestedName?: string;
|
||||
placeholder?: string;
|
||||
compact?: boolean;
|
||||
large?: boolean;
|
||||
autofocus?: boolean;
|
||||
required?: boolean;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
excludePersonId?: string;
|
||||
badge?: 'additive' | 'replace';
|
||||
onchange?: (value: string) => void;
|
||||
onfocused?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
value = $bindable(''),
|
||||
initialName = '',
|
||||
suggestedName = '',
|
||||
placeholder,
|
||||
compact = false,
|
||||
large = false,
|
||||
autofocus = false,
|
||||
required = false,
|
||||
restrictToCorrespondentsOf,
|
||||
excludePersonId,
|
||||
badge,
|
||||
onchange,
|
||||
onfocused
|
||||
}: Props = $props();
|
||||
|
||||
// searchTerm 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 searchTerm = $state(initialName);
|
||||
|
||||
// Sync display text when the selected person changes externally (e.g. swap, navigation).
|
||||
$effect(() => {
|
||||
searchTerm = initialName;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedName;
|
||||
if (suggested && !untrack(() => value)) {
|
||||
searchTerm = suggested;
|
||||
}
|
||||
});
|
||||
|
||||
const typeahead = createTypeahead<Person>({
|
||||
fetchUrl: async (term) => {
|
||||
const personId = restrictToCorrespondentsOf;
|
||||
const excludeId = excludePersonId;
|
||||
const filter = (results: Person[]) =>
|
||||
excludeId ? results.filter((p) => p.id !== excludeId) : results;
|
||||
if (personId) {
|
||||
const url =
|
||||
term.length >= 1
|
||||
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
|
||||
: `/api/persons/${personId}/correspondents`;
|
||||
const res = await fetch(url);
|
||||
return res.ok ? filter(await res.json()) : [];
|
||||
}
|
||||
if (term.length < 1) return [];
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
|
||||
return res.ok ? filter(await res.json()) : [];
|
||||
},
|
||||
debounceMs: 300
|
||||
});
|
||||
|
||||
// Fixed-position dropdown state — escapes any CSS stacking context that would clip it.
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
let activeIndex = $state(-1);
|
||||
|
||||
// Stable id linking the input's aria-controls to the listbox element.
|
||||
const listboxId = `${name}-listbox`;
|
||||
|
||||
const isOpen = $derived(typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading));
|
||||
|
||||
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() {
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = '';
|
||||
onchange?.('');
|
||||
}
|
||||
|
||||
const term = untrack(() => searchTerm);
|
||||
typeahead.setQuery(term);
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
onfocused?.();
|
||||
updateDropdownPosition();
|
||||
if (restrictToCorrespondentsOf) {
|
||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/persons/${personId}/correspondents`);
|
||||
const persons: Person[] = res.ok ? await res.json() : [];
|
||||
typeahead.openWith(persons);
|
||||
} catch (e) {
|
||||
console.error('Suche fehlgeschlagen', e);
|
||||
typeahead.openWith([]);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
typeahead.openWith(typeahead.results);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPerson(person: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = person.displayName;
|
||||
typeahead.close();
|
||||
activeIndex = -1;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
typeahead.close();
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!isOpen) return;
|
||||
|
||||
const results = typeahead.results;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % results.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + results.length) % results.length;
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && results[activeIndex]) {
|
||||
selectPerson(results[activeIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Keep dropdown position current when user scrolls or resizes.
|
||||
// fixed positioning is intentional — it escapes any CSS stacking context (overflow, transform,
|
||||
// shadow-sm + z-index combinations) that would clip an absolute-positioned dropdown.
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
|
||||
<label
|
||||
for="{name}-search"
|
||||
class={compact
|
||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||
: 'block text-sm font-medium text-ink-2'}
|
||||
>{label}{#if required}*{/if}{#if badge}<FieldLabelBadge variant={badge} />{/if}</label
|
||||
>
|
||||
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
autofocus={autofocus}
|
||||
bind:value={searchTerm}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 && typeahead.results[activeIndex]
|
||||
? `${listboxId}-option-${typeahead.results[activeIndex].id}`
|
||||
: undefined}
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||
class={large
|
||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: compact
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
/>
|
||||
|
||||
{#if isOpen}
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
style={dropdownStyle}
|
||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||
>
|
||||
{#if typeahead.loading}
|
||||
<li class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</li>
|
||||
{:else}
|
||||
{#each typeahead.results as person, i (person.id)}
|
||||
<li
|
||||
id="{listboxId}-option-{person.id}"
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === activeIndex ? 'bg-accent-bg' : ''}"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="block truncate font-medium">
|
||||
{person.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,478 +0,0 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const PERSONS = [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
];
|
||||
|
||||
function mockFetchWithPersons(persons = PERSONS) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(persons)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchFailure() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
}
|
||||
|
||||
function hiddenInput(name: string) {
|
||||
return document.querySelector<HTMLInputElement>(`input[type="hidden"][name="${name}"]`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – rendering', () => {
|
||||
it('renders the label and text input', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
await expect.element(page.getByText('Absender')).toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-empty.png' });
|
||||
});
|
||||
|
||||
it('pre-fills the visible input from initialName', async () => {
|
||||
render(PersonTypeahead, {
|
||||
name: 'senderId',
|
||||
label: 'Absender',
|
||||
initialName: 'Max Mustermann'
|
||||
});
|
||||
// The $effect that syncs initialName runs after mount — poll until the value appears
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).toHaveValue('Max Mustermann');
|
||||
});
|
||||
|
||||
it('renders a hidden input with the correct name attribute', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hidden input starts with the provided value', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender', value: '42' });
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('42');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – search', () => {
|
||||
it('opens the dropdown with results after typing', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
|
||||
});
|
||||
|
||||
it('shows loading indicator while fetching', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Suche...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no dropdown when the search returns empty results', async () => {
|
||||
mockFetchWithPersons([]);
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('XYZ');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Suche...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no results when fetch fails', async () => {
|
||||
mockFetchFailure();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selection ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – selection', () => {
|
||||
it('fills the visible input and closes dropdown on click', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect
|
||||
.element(page.getByRole('option', { name: 'Max Mustermann' }))
|
||||
.not.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||
});
|
||||
|
||||
it('sets the hidden input value to the selected person id', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||
});
|
||||
|
||||
it('calls onchange with the person id on selection', async () => {
|
||||
mockFetchWithPersons();
|
||||
const onchange = vi.fn();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('selects a result with Enter key', async () => {
|
||||
mockFetchWithPersons([
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]);
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Clearing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – clearing a selection', () => {
|
||||
it('clears the hidden value when user edits the visible input after a selection', async () => {
|
||||
mockFetchWithPersons();
|
||||
const onchange = vi.fn();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
onchange.mockClear();
|
||||
|
||||
await input.fill('x');
|
||||
await waitForDebounce();
|
||||
expect(onchange).toHaveBeenCalledWith('');
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Correspondent mode ───────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – correspondent mode', () => {
|
||||
it('fetches correspondents immediately on focus when restrictToCorrespondentsOf is set', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, {
|
||||
name: 'receiverId',
|
||||
label: 'Empfänger',
|
||||
restrictToCorrespondentsOf: 'person-a-id'
|
||||
});
|
||||
|
||||
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
|
||||
await waitForDebounce();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/persons/person-a-id/correspondents')
|
||||
);
|
||||
});
|
||||
|
||||
it('shows correspondent results immediately on focus without typing', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, {
|
||||
name: 'receiverId',
|
||||
label: 'Empfänger',
|
||||
restrictToCorrespondentsOf: 'person-a-id'
|
||||
});
|
||||
|
||||
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
|
||||
await waitForDebounce();
|
||||
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses correspondents endpoint with q param when typing', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, {
|
||||
name: 'receiverId',
|
||||
label: 'Empfänger',
|
||||
restrictToCorrespondentsOf: 'person-a-id'
|
||||
});
|
||||
|
||||
await page.getByPlaceholder('Namen tippen...').fill('Anna');
|
||||
await waitForDebounce();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/persons/person-a-id/correspondents?q=Anna')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses normal persons endpoint when restrictToCorrespondentsOf is not set', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
|
||||
await page.getByPlaceholder('Namen tippen...').fill('Anna');
|
||||
await waitForDebounce();
|
||||
|
||||
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Anna'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Click outside ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – click outside', () => {
|
||||
it('closes the dropdown when clicking outside the component', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ARIA roles ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – ARIA roles', () => {
|
||||
it('dropdown uses role="listbox" container and role="option" items', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
// Container must be a listbox
|
||||
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
// Items must be options, not buttons
|
||||
const options = page.getByRole('option');
|
||||
await expect.element(options.first()).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('option', { name: 'Max Mustermann' })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('option', { name: 'Anna Musterfrau' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('input has aria-expanded="false" when dropdown is closed', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await expect.element(input).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('input has aria-expanded="true" when dropdown is open', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(input).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('input has aria-controls pointing to the listbox id', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
const ariaControls = await input.element().getAttribute('aria-controls');
|
||||
expect(ariaControls).toBeTruthy();
|
||||
|
||||
// The listbox with that id must exist
|
||||
const listbox = document.getElementById(ariaControls!);
|
||||
expect(listbox).not.toBeNull();
|
||||
expect(listbox!.getAttribute('role')).toBe('listbox');
|
||||
});
|
||||
|
||||
it('input has aria-haspopup="listbox"', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await expect.element(input).toHaveAttribute('aria-haspopup', 'listbox');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – keyboard navigation', () => {
|
||||
it('ArrowDown moves highlight to the first option', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
|
||||
// First option should be highlighted (aria-selected="true")
|
||||
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
||||
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('ArrowDown then ArrowDown moves highlight to the second option', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
|
||||
const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
||||
await expect.element(secondOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('ArrowUp from first wraps to last option', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}'); // highlight first
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowUp}'); // wrap to last
|
||||
await tick();
|
||||
|
||||
const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
||||
await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('ArrowDown from last wraps to first option', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}'); // highlight first (index 0)
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowDown}'); // highlight second (index 1 = last)
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowDown}'); // wrap to first (index 0)
|
||||
await tick();
|
||||
|
||||
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
||||
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('Enter selects the highlighted option', async () => {
|
||||
mockFetchWithPersons([
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]);
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Escape closes the dropdown without selecting', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await tick();
|
||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||
// Value unchanged — nothing was selected
|
||||
await expect.element(input).toHaveValue('Mu');
|
||||
});
|
||||
|
||||
it('active option id is set as aria-activedescendant on the input', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
// No active option before pressing ArrowDown
|
||||
const beforeNav = await input.element().getAttribute('aria-activedescendant');
|
||||
expect(beforeNav).toBeFalsy();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
|
||||
const afterNav = await input.element().getAttribute('aria-activedescendant');
|
||||
expect(afterNav).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
chipLabel: string;
|
||||
otherName: string;
|
||||
yearRange?: string;
|
||||
canWrite: boolean;
|
||||
relId: string;
|
||||
}
|
||||
|
||||
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
|
||||
</script>
|
||||
|
||||
<li class="flex items-center gap-2 py-2">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{chipLabel}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
|
||||
{otherName}
|
||||
</span>
|
||||
{#if yearRange}
|
||||
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span>
|
||||
{/if}
|
||||
{#if canWrite}
|
||||
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">
|
||||
<input type="hidden" name="relId" value={relId} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="{m.btn_delete()} — {otherName}"
|
||||
class="inline-flex h-11 w-11 items-center justify-center text-ink-3 transition-colors hover:text-red-600"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</li>
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import RelationshipChip from './RelationshipChip.svelte';
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseProps = {
|
||||
chipLabel: 'Elternteil',
|
||||
otherName: 'Anna Schmidt',
|
||||
yearRange: '',
|
||||
canWrite: false,
|
||||
relId: 'rel-1'
|
||||
};
|
||||
|
||||
describe('RelationshipChip', () => {
|
||||
it('renders the chip label', async () => {
|
||||
render(RelationshipChip, baseProps);
|
||||
await expect.element(page.getByText('Elternteil')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the other person name', async () => {
|
||||
render(RelationshipChip, baseProps);
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows year range when provided', async () => {
|
||||
render(RelationshipChip, { ...baseProps, yearRange: '1920–1980' });
|
||||
await expect.element(page.getByText('1920–1980')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show year range span when empty', async () => {
|
||||
render(RelationshipChip, { ...baseProps, yearRange: '' });
|
||||
expect(document.querySelector('[data-testid="year-range"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows delete button when canWrite is true', async () => {
|
||||
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides delete button when canWrite is false', async () => {
|
||||
render(RelationshipChip, { ...baseProps, canWrite: false });
|
||||
expect(document.querySelector('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => {
|
||||
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||
const btn = document.querySelector('button')!;
|
||||
expect(btn.className).toContain('h-11');
|
||||
expect(btn.className).toContain('w-11');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
<script lang="ts">
|
||||
type Props = { label: string };
|
||||
let { label }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-accent bg-accent/25 px-2 py-px font-sans text-[10px] font-bold tracking-[0.07em] text-ink uppercase"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
@@ -1,174 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import RelationshipChip from '$lib/components/RelationshipChip.svelte';
|
||||
import AddRelationshipForm from '$lib/components/AddRelationshipForm.svelte';
|
||||
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/relationshipLabels';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
|
||||
|
||||
interface Props {
|
||||
personId: string;
|
||||
familyMember: boolean;
|
||||
relationships: RelationshipDTO[];
|
||||
inferredRelationships: InferredRelationshipWithPersonDTO[];
|
||||
canWrite: boolean;
|
||||
relationshipError?: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
personId,
|
||||
familyMember,
|
||||
relationships,
|
||||
inferredRelationships,
|
||||
canWrite,
|
||||
relationshipError = null
|
||||
}: Props = $props();
|
||||
|
||||
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||
|
||||
const sortedDirect = $derived([...relationships].sort(byTypeThenYear));
|
||||
const topDerived = $derived(inferredRelationships.slice(0, 5));
|
||||
|
||||
function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number {
|
||||
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
|
||||
if (order !== 0) return order;
|
||||
return (a.fromYear ?? 0) - (b.fromYear ?? 0);
|
||||
}
|
||||
|
||||
function relationTypeOrder(t: RelationType | undefined): number {
|
||||
const order: Record<string, number> = {
|
||||
PARENT_OF: 1,
|
||||
SPOUSE_OF: 2,
|
||||
SIBLING_OF: 3,
|
||||
FRIEND: 4,
|
||||
COLLEAGUE: 5,
|
||||
EMPLOYER: 6,
|
||||
DOCTOR: 7,
|
||||
NEIGHBOR: 8,
|
||||
OTHER: 9
|
||||
};
|
||||
return order[t ?? 'OTHER'] ?? 99;
|
||||
}
|
||||
|
||||
function yearRange(rel: RelationshipDTO): string {
|
||||
const from = rel.fromYear;
|
||||
const to = rel.toYear;
|
||||
if (from && to) return `${from}–${to}`;
|
||||
if (from) return m.relation_year_from({ year: from });
|
||||
if (to) return m.relation_year_to({ year: to });
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<!-- Header row: heading + family-member toggle -->
|
||||
<div class="mb-5 flex items-start justify-between gap-4">
|
||||
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.stammbaum_relationships_heading()}
|
||||
</h2>
|
||||
{#if canWrite}
|
||||
<form method="POST" action="?/toggleFamilyMember" use:enhance>
|
||||
<input type="hidden" name="familyMember" value={familyMember ? 'false' : 'true'} />
|
||||
<button
|
||||
type="submit"
|
||||
role="switch"
|
||||
aria-checked={familyMember}
|
||||
aria-label={familyMember
|
||||
? m.relation_toggle_remove_from_tree()
|
||||
: m.relation_toggle_add_to_tree()}
|
||||
class="inline-flex items-center gap-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<span
|
||||
class="relative inline-block h-4 w-7 rounded-full transition-colors {familyMember
|
||||
? 'bg-primary'
|
||||
: 'bg-line'}"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 inline-block h-3 w-3 rounded-full bg-white transition-transform {familyMember
|
||||
? 'translate-x-3'
|
||||
: ''}"
|
||||
></span>
|
||||
</span>
|
||||
{m.relation_label_family_member()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if relationshipError}
|
||||
<p class="mb-3 text-sm text-red-700" role="alert">{relationshipError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- In-tree banner -->
|
||||
{#if familyMember}
|
||||
<div
|
||||
class="mb-4 flex items-center justify-between rounded-sm border border-accent/30 bg-accent/10 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-accent"></span>
|
||||
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_in_tree()}</span>
|
||||
</div>
|
||||
<a
|
||||
href="/stammbaum?focus={personId}"
|
||||
class="font-sans text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{m.relation_label_view_in_tree()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Direkte Beziehungen -->
|
||||
<h3 class="mb-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.relation_label_direct()}
|
||||
</h3>
|
||||
{#if sortedDirect.length === 0}
|
||||
<p class="mb-2 text-sm text-ink-2 italic">{m.person_relationships_empty()}</p>
|
||||
{:else}
|
||||
<ul class="mb-2 divide-y divide-line">
|
||||
{#each sortedDirect as rel (rel.id)}
|
||||
<RelationshipChip
|
||||
chipLabel={chipLabel(rel, personId)}
|
||||
otherName={otherName(rel, personId)}
|
||||
yearRange={yearRange(rel)}
|
||||
canWrite={canWrite}
|
||||
relId={rel.id}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if canWrite}
|
||||
<AddRelationshipForm personId={personId} />
|
||||
{/if}
|
||||
|
||||
<!-- Abgeleitete Beziehungen -->
|
||||
{#if topDerived.length > 0}
|
||||
<details class="mt-6">
|
||||
<summary
|
||||
class="cursor-pointer text-xs font-bold tracking-widest text-ink-3 uppercase select-none"
|
||||
>
|
||||
{m.relation_label_derived()}
|
||||
</summary>
|
||||
<ul class="mt-2 space-y-2">
|
||||
{#each topDerived as derived (derived.person.id)}
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>
|
||||
{inferredRelationshipLabel(derived.label)}
|
||||
</span>
|
||||
<a
|
||||
href="/persons/{derived.person.id}"
|
||||
class="min-w-0 flex-1 truncate font-serif text-sm text-ink-2 hover:underline"
|
||||
>
|
||||
{derived.person.displayName}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import StammbaumCard from './StammbaumCard.svelte';
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
vi.mock('$lib/components/RelationshipChip.svelte', () => ({ default: () => null }));
|
||||
vi.mock('$lib/components/AddRelationshipForm.svelte', () => ({ default: () => null }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseProps = {
|
||||
personId: 'person-1',
|
||||
familyMember: false,
|
||||
relationships: [],
|
||||
inferredRelationships: [],
|
||||
canWrite: false
|
||||
};
|
||||
|
||||
describe('StammbaumCard', () => {
|
||||
it('renders the section heading', async () => {
|
||||
render(StammbaumCard, baseProps);
|
||||
await expect.element(page.getByText('Stammbaum & Beziehungen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty-relationships message when relationships list is empty', async () => {
|
||||
render(StammbaumCard, baseProps);
|
||||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the family-member toggle when canWrite is true', async () => {
|
||||
render(StammbaumCard, { ...baseProps, canWrite: true });
|
||||
await expect.element(page.getByText('Als Familienmitglied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays relationshipError text when provided', async () => {
|
||||
render(StammbaumCard, { ...baseProps, relationshipError: 'Test Fehler' });
|
||||
await expect.element(page.getByText('Test Fehler')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggle aria-label says "Zum Stammbaum hinzufügen" when not yet a family member', async () => {
|
||||
render(StammbaumCard, { ...baseProps, canWrite: true, familyMember: false });
|
||||
await expect
|
||||
.element(page.getByRole('switch', { name: 'Zum Stammbaum hinzufügen' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggle aria-label says "Aus Stammbaum entfernen" when already a family member', async () => {
|
||||
render(StammbaumCard, { ...baseProps, canWrite: true, familyMember: true });
|
||||
await expect
|
||||
.element(page.getByRole('switch', { name: 'Aus Stammbaum entfernen' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/relationshipLabels';
|
||||
import AddRelationshipForm from '$lib/components/AddRelationshipForm.svelte';
|
||||
import type { RelFormData } from '$lib/components/AddRelationshipForm.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
|
||||
|
||||
interface Props {
|
||||
node: PersonNodeDTO;
|
||||
onClose: () => void;
|
||||
canWrite?: boolean;
|
||||
}
|
||||
|
||||
let { node, onClose, canWrite = false }: Props = $props();
|
||||
|
||||
let directRels = $state<RelationshipDTO[]>([]);
|
||||
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const id = node.id;
|
||||
loadFor(id);
|
||||
});
|
||||
|
||||
async function loadFor(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [directRes, derivedRes] = await Promise.all([
|
||||
fetch(`/api/persons/${id}/relationships`),
|
||||
fetch(`/api/persons/${id}/inferred-relationships`)
|
||||
]);
|
||||
if (!directRes.ok || !derivedRes.ok) {
|
||||
error = m.error_internal_error();
|
||||
return;
|
||||
}
|
||||
directRels = await directRes.json();
|
||||
derivedRels = await derivedRes.json();
|
||||
} catch {
|
||||
error = m.error_internal_error();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddRelationship(data: RelFormData) {
|
||||
const body: Record<string, string | number> = {
|
||||
relatedPersonId: data.relatedPersonId,
|
||||
relationType: data.relationType
|
||||
};
|
||||
if (data.fromYear !== undefined) body.fromYear = data.fromYear;
|
||||
if (data.toYear !== undefined) body.toYear = data.toYear;
|
||||
const res = await fetch(`/api/persons/${node.id}/relationships`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to add relationship');
|
||||
await loadFor(node.id);
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const handler = (e: KeyboardEvent) => handleEscape(e);
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
});
|
||||
|
||||
const directOtherIds = $derived(
|
||||
new Set(directRels.map((r) => (r.personId === node.id ? r.relatedPersonId : r.personId)))
|
||||
);
|
||||
const topDerived = $derived(
|
||||
derivedRels.filter((d) => !directOtherIds.has(d.person.id)).slice(0, 5)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col p-5">
|
||||
<div class="mb-4 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<h2 class="font-serif text-lg text-ink">{node.displayName}</h2>
|
||||
{#if node.birthYear || node.deathYear}
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
aria-label={m.comp_dismiss()}
|
||||
class="shrink-0 rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-700" role="alert">{error}</p>
|
||||
{:else if loading}
|
||||
<p class="font-sans text-xs text-ink-3 italic">…</p>
|
||||
{:else}
|
||||
<section class="mb-5">
|
||||
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.stammbaum_panel_direct_rels()}
|
||||
</h3>
|
||||
{#if directRels.length === 0}
|
||||
<p class="text-xs text-ink-2 italic">{m.person_relationships_empty()}</p>
|
||||
{:else}
|
||||
<ul class="space-y-1.5">
|
||||
{#each directRels as rel (rel.id)}
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{chipLabel(rel, node.id)}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink">
|
||||
{otherName(rel, node.id)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if canWrite}
|
||||
{#key node.id}
|
||||
<AddRelationshipForm personId={node.id} onSubmit={handleAddRelationship} />
|
||||
{/key}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if topDerived.length > 0}
|
||||
<section class="mb-5">
|
||||
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.stammbaum_panel_derived_rels()}
|
||||
</h3>
|
||||
<ul class="space-y-1.5">
|
||||
{#each topDerived as derived (derived.person.id)}
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>
|
||||
{inferredRelationshipLabel(derived.label)}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink-2">
|
||||
{derived.person.displayName}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="mt-auto">
|
||||
<a
|
||||
href="/persons/{node.id}"
|
||||
class="block w-full rounded-sm border border-line bg-surface px-3 py-2 text-center font-sans text-xs font-medium text-primary transition hover:bg-muted"
|
||||
>
|
||||
{m.stammbaum_panel_to_person()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import StammbaumSidePanel from './StammbaumSidePanel.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
|
||||
|
||||
const makeNode = () => ({
|
||||
id: 'person-1',
|
||||
displayName: 'Alice Müller',
|
||||
birthYear: 1900,
|
||||
deathYear: null,
|
||||
familyMember: true
|
||||
});
|
||||
|
||||
function stubFetch(directRels: unknown[] = [], inferredRels: unknown[] = []) {
|
||||
let callCount = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
const body = callCount % 2 === 1 ? directRels : inferredRels;
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => stubFetch());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('StammbaumSidePanel', () => {
|
||||
it('calls onClose when dismiss button is clicked', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(StammbaumSidePanel, { node: makeNode(), onClose, canWrite: false });
|
||||
await expect.element(page.getByRole('button', { name: 'Schließen' })).toBeInTheDocument();
|
||||
const btn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(b) => b.getAttribute('aria-label') === 'Schließen'
|
||||
);
|
||||
btn!.click();
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders the person displayName as heading', async () => {
|
||||
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
|
||||
await expect.element(page.getByText('Alice Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty-relationships message when no direct relationships are loaded', async () => {
|
||||
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
|
||||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('year inputs inside the add form have label elements (canWrite=true)', async () => {
|
||||
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
|
||||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
|
||||
/Beziehung hinzufügen/i.test(b.textContent ?? '')
|
||||
);
|
||||
addBtn!.click();
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
const yearInputs = [...document.querySelectorAll('input')].filter(
|
||||
(i) => i.inputMode === 'numeric'
|
||||
);
|
||||
expect(yearInputs.length).toBeGreaterThan(0);
|
||||
for (const input of yearInputs) {
|
||||
expect(input.closest('label')).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows loading indicator while fetching', async () => {
|
||||
let resolveFirst: (v: unknown) => void;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
})
|
||||
)
|
||||
);
|
||||
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
|
||||
await expect.element(page.getByText('…')).toBeInTheDocument();
|
||||
resolveFirst!({ ok: true, json: () => Promise.resolve([]) });
|
||||
});
|
||||
});
|
||||
@@ -1,548 +0,0 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/prefer-svelte-reactivity -- maps are scope-local
|
||||
to a single $derived.by computation; never mutated after layout. */
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
interface Props {
|
||||
nodes: PersonNodeDTO[];
|
||||
edges: RelationshipDTO[];
|
||||
selectedId: string | null;
|
||||
zoom: number;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
|
||||
|
||||
const NODE_W = 160;
|
||||
const NODE_H = 56;
|
||||
const COL_GAP = 40;
|
||||
const ROW_GAP = 80;
|
||||
const VIEWBOX_PAD = 80;
|
||||
// Minimum viewBox dimensions — keeps a single node from being scaled up
|
||||
// to fill the entire canvas. Roughly matches a typical desktop content area.
|
||||
const MIN_VIEWBOX_W = 1200;
|
||||
const MIN_VIEWBOX_H = 800;
|
||||
|
||||
type Layout = {
|
||||
positions: Map<string, { x: number; y: number }>;
|
||||
generations: Map<number, string[]>;
|
||||
viewX: number;
|
||||
viewY: number;
|
||||
viewW: number;
|
||||
viewH: number;
|
||||
};
|
||||
|
||||
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
||||
const viewBox = $derived.by(() => {
|
||||
const w = layout.viewW / zoom;
|
||||
const h = layout.viewH / zoom;
|
||||
const cx = layout.viewX + layout.viewW / 2;
|
||||
const cy = layout.viewY + layout.viewH / 2;
|
||||
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
||||
});
|
||||
|
||||
function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
|
||||
const parentToChildren = new Map<string, string[]>();
|
||||
const childToParents = new Map<string, string[]>();
|
||||
const spousePairs = new Map<string, string>();
|
||||
|
||||
for (const e of allEdges) {
|
||||
switch (e.relationType) {
|
||||
case 'PARENT_OF':
|
||||
mapPush(parentToChildren, e.personId, e.relatedPersonId);
|
||||
mapPush(childToParents, e.relatedPersonId, e.personId);
|
||||
break;
|
||||
case 'SPOUSE_OF':
|
||||
spousePairs.set(e.personId, e.relatedPersonId);
|
||||
spousePairs.set(e.relatedPersonId, e.personId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Iterative longest-path generation assignment.
|
||||
//
|
||||
// Each node's generation = max(parent generations) + 1 (roots stay at 0).
|
||||
// Then spouses are pulled to share the deeper generation. Pulling a spouse
|
||||
// down can shift their own descendants, so we iterate until stable rather
|
||||
// than running BFS once like the previous implementation (which left
|
||||
// e.g. a child of a "later-pulled" spouse stranded one row too high).
|
||||
const generation = new Map<string, number>();
|
||||
for (const n of allNodes) generation.set(n.id, 0);
|
||||
const maxIters = allNodes.length + 4;
|
||||
for (let it = 0; it < maxIters; it++) {
|
||||
let changed = false;
|
||||
for (const n of allNodes) {
|
||||
const parents = childToParents.get(n.id) ?? [];
|
||||
if (parents.length === 0) continue;
|
||||
let maxParentGen = -1;
|
||||
for (const pid of parents) {
|
||||
maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
|
||||
}
|
||||
const newGen = maxParentGen + 1;
|
||||
if ((generation.get(n.id) ?? 0) < newGen) {
|
||||
generation.set(n.id, newGen);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const [a, b] of spousePairs) {
|
||||
const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
|
||||
if ((generation.get(a) ?? 0) < m) {
|
||||
generation.set(a, m);
|
||||
changed = true;
|
||||
}
|
||||
if ((generation.get(b) ?? 0) < m) {
|
||||
generation.set(b, m);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
// Group by generation, then sort within generation by display name.
|
||||
const generations = new Map<number, string[]>();
|
||||
for (const n of allNodes) {
|
||||
const g = generation.get(n.id) ?? 0;
|
||||
if (!generations.has(g)) generations.set(g, []);
|
||||
generations.get(g)!.push(n.id);
|
||||
}
|
||||
const byId = new Map(allNodes.map((n) => [n.id, n]));
|
||||
for (const ids of generations.values()) {
|
||||
ids.sort((a, b) => {
|
||||
const an = byId.get(a)?.displayName ?? '';
|
||||
const bn = byId.get(b)?.displayName ?? '';
|
||||
return an.localeCompare(bn);
|
||||
});
|
||||
}
|
||||
|
||||
// Per-generation layout:
|
||||
//
|
||||
// 1. Build sibling-groups (children of the same parent set) — these become
|
||||
// the layout "blocks" that are centred under their parents' midpoint.
|
||||
// 2. Attach loose spouses (people with no parents in the graph but a
|
||||
// spouse who *is* in a sibling group) on the outside of their partner,
|
||||
// so the spouse line stays short and adjacent.
|
||||
// 3. Merge dual-loose spouse pairs into a single 2-person block.
|
||||
// 4. Centre each block such that its *parented* members average sits
|
||||
// exactly under the parent midpoint (keeping all connectors at 90°),
|
||||
// then pack blocks left-to-right.
|
||||
type Block = {
|
||||
members: { id: string; parented: boolean }[];
|
||||
center: number;
|
||||
};
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
|
||||
|
||||
for (let gi = 0; gi < sortedGens.length; gi++) {
|
||||
const g = sortedGens[gi];
|
||||
const ids = generations.get(g)!;
|
||||
const y = g * (NODE_H + ROW_GAP);
|
||||
|
||||
const blocksByKey = new Map<string, Block>();
|
||||
const memberLookup = new Map<string, { key: string; parented: boolean }>();
|
||||
|
||||
// Step 1: place every node with parents-in-graph into a sibling block.
|
||||
for (const id of ids) {
|
||||
const parents = childToParents.get(id) ?? [];
|
||||
if (parents.length === 0) continue;
|
||||
const blockKey = [...parents].sort().join('|');
|
||||
let block = blocksByKey.get(blockKey);
|
||||
if (!block) {
|
||||
const parentCenters: number[] = [];
|
||||
for (const pid of parents) {
|
||||
const p = positions.get(pid);
|
||||
if (p) parentCenters.push(p.x + NODE_W / 2);
|
||||
}
|
||||
const center =
|
||||
parentCenters.length > 0
|
||||
? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
|
||||
: 0;
|
||||
block = { members: [], center };
|
||||
blocksByKey.set(blockKey, block);
|
||||
}
|
||||
block.members.push({ id, parented: true });
|
||||
memberLookup.set(id, { key: blockKey, parented: true });
|
||||
}
|
||||
|
||||
// Sort members within each sibling block alphabetically.
|
||||
for (const block of blocksByKey.values()) {
|
||||
block.members.sort((a, b) =>
|
||||
(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2 + 3: handle loose nodes.
|
||||
for (const id of ids) {
|
||||
if (memberLookup.has(id)) continue;
|
||||
const spouse = spousePairs.get(id);
|
||||
const spouseLookup = spouse ? memberLookup.get(spouse) : undefined;
|
||||
|
||||
if (spouseLookup && spouseLookup.parented) {
|
||||
// Spouse is parented — attach this loose node next to them on
|
||||
// the outer edge of their sibling block so the marriage line
|
||||
// is short and the sibling order is preserved.
|
||||
const block = blocksByKey.get(spouseLookup.key)!;
|
||||
const spouseIdx = block.members.findIndex((m) => m.id === spouse);
|
||||
const insertOnRight = spouseIdx >= block.members.length / 2;
|
||||
const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx;
|
||||
block.members.splice(insertAt, 0, { id, parented: false });
|
||||
memberLookup.set(id, { key: spouseLookup.key, parented: false });
|
||||
} else {
|
||||
// No usable parented spouse: put in its own loose block. We
|
||||
// merge dual-loose spouse pairs in the next pass.
|
||||
const blockKey = `__loose__${id}`;
|
||||
blocksByKey.set(blockKey, {
|
||||
members: [{ id, parented: false }],
|
||||
center: 0
|
||||
});
|
||||
memberLookup.set(id, { key: blockKey, parented: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Merge dual-loose spouse blocks into a single 2-person block.
|
||||
const removed = new Set<string>();
|
||||
for (const [key, block] of blocksByKey) {
|
||||
if (!key.startsWith('__loose__')) continue;
|
||||
if (removed.has(key)) continue;
|
||||
const member = block.members[0];
|
||||
const spouse = spousePairs.get(member.id);
|
||||
if (!spouse) continue;
|
||||
const spouseLookup = memberLookup.get(spouse);
|
||||
if (!spouseLookup || removed.has(spouseLookup.key)) continue;
|
||||
if (spouseLookup.key === key) continue;
|
||||
if (!spouseLookup.key.startsWith('__loose__')) continue;
|
||||
const otherBlock = blocksByKey.get(spouseLookup.key)!;
|
||||
block.members.push(...otherBlock.members);
|
||||
removed.add(spouseLookup.key);
|
||||
}
|
||||
for (const key of removed) blocksByKey.delete(key);
|
||||
|
||||
// Step 4: centre each block on its anchor (parented members) and pack.
|
||||
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
|
||||
let cursorRight = -Infinity;
|
||||
for (const block of ordered) {
|
||||
const n = block.members.length;
|
||||
const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
|
||||
const anchorIndices: number[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (block.members[i].parented) anchorIndices.push(i);
|
||||
}
|
||||
const avgAnchorIdx =
|
||||
anchorIndices.length > 0
|
||||
? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
|
||||
: (n - 1) / 2;
|
||||
let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
|
||||
if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
|
||||
for (let i = 0; i < n; i++) {
|
||||
positions.set(block.members[i].id, {
|
||||
x: groupLeft + i * (NODE_W + COL_GAP),
|
||||
y
|
||||
});
|
||||
}
|
||||
cursorRight = groupLeft + groupWidth;
|
||||
}
|
||||
}
|
||||
|
||||
// Bounding box around the actual content, then expanded to MIN dimensions
|
||||
// (so a single node doesn't get scaled up to fill the canvas). The viewBox
|
||||
// is centered on the content.
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const p of positions.values()) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x + NODE_W);
|
||||
maxY = Math.max(maxY, p.y + NODE_H);
|
||||
}
|
||||
if (positions.size === 0) {
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
maxX = 0;
|
||||
maxY = 0;
|
||||
}
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const viewW = Math.max(contentW + 2 * VIEWBOX_PAD, MIN_VIEWBOX_W);
|
||||
const viewH = Math.max(contentH + 2 * VIEWBOX_PAD, MIN_VIEWBOX_H);
|
||||
const viewX = minX + contentW / 2 - viewW / 2;
|
||||
const viewY = minY + contentH / 2 - viewH / 2;
|
||||
return { positions, generations, viewX, viewY, viewW, viewH };
|
||||
}
|
||||
|
||||
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(value);
|
||||
else map.set(key, [value]);
|
||||
}
|
||||
|
||||
function nodeCenter(id: string): { x: number; y: number } | null {
|
||||
const p = layout.positions.get(id);
|
||||
if (!p) return null;
|
||||
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
|
||||
}
|
||||
|
||||
let focusedId = $state<string | null>(null);
|
||||
|
||||
function handleNodeKey(event: KeyboardEvent, id: string) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect(id);
|
||||
}
|
||||
}
|
||||
|
||||
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
|
||||
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
|
||||
|
||||
function pairKey(a: string, b: string): string {
|
||||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||
}
|
||||
|
||||
type ParentLinks = {
|
||||
// One entry per spouse-pair-with-children: drives the drop + sibling-bar
|
||||
// + per-child vertical pattern in the SVG.
|
||||
shared: { key: string; parentA: string; parentB: string; childIds: string[] }[];
|
||||
// One entry per remaining parent → child edge (single parents, or the
|
||||
// "second" parent edge when only one parent is in the spouse pair).
|
||||
single: { key: string; parentId: string; childId: string }[];
|
||||
};
|
||||
|
||||
const parentLinks = $derived.by<ParentLinks>(() => {
|
||||
const spousePairs = new Set<string>();
|
||||
for (const e of spouseEdges) {
|
||||
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
|
||||
}
|
||||
|
||||
const childToParents = new Map<string, string[]>();
|
||||
for (const e of parentEdges) {
|
||||
const list = childToParents.get(e.relatedPersonId) ?? [];
|
||||
list.push(e.personId);
|
||||
childToParents.set(e.relatedPersonId, list);
|
||||
}
|
||||
|
||||
const sharedMap = new Map<string, { parentA: string; parentB: string; childIds: string[] }>();
|
||||
const single: ParentLinks['single'] = [];
|
||||
for (const [childId, parents] of childToParents) {
|
||||
const consumed = new Set<string>();
|
||||
for (let i = 0; i < parents.length; i++) {
|
||||
if (consumed.has(parents[i])) continue;
|
||||
for (let j = i + 1; j < parents.length; j++) {
|
||||
if (consumed.has(parents[j])) continue;
|
||||
if (spousePairs.has(pairKey(parents[i], parents[j]))) {
|
||||
const groupKey = pairKey(parents[i], parents[j]);
|
||||
const existing = sharedMap.get(groupKey);
|
||||
if (existing) {
|
||||
existing.childIds.push(childId);
|
||||
} else {
|
||||
sharedMap.set(groupKey, {
|
||||
parentA: parents[i],
|
||||
parentB: parents[j],
|
||||
childIds: [childId]
|
||||
});
|
||||
}
|
||||
consumed.add(parents[i]);
|
||||
consumed.add(parents[j]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const parentId of parents) {
|
||||
if (consumed.has(parentId)) continue;
|
||||
single.push({ key: `${parentId}->${childId}`, parentId, childId });
|
||||
}
|
||||
}
|
||||
|
||||
const shared: ParentLinks['shared'] = [];
|
||||
for (const [key, group] of sharedMap) shared.push({ key, ...group });
|
||||
return { shared, single };
|
||||
});
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox={viewBox}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="img"
|
||||
aria-label="Stammbaum"
|
||||
class="block h-full w-full"
|
||||
>
|
||||
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
||||
bar, then short verticals from the bar to each child top. -->
|
||||
{#each parentLinks.shared as group (group.key)}
|
||||
{@const aCenter = nodeCenter(group.parentA)}
|
||||
{@const bCenter = nodeCenter(group.parentB)}
|
||||
{@const childCenters = group.childIds
|
||||
.map((id) => nodeCenter(id))
|
||||
.filter((c): c is { x: number; y: number } => c !== null)}
|
||||
{#if aCenter && bCenter && childCenters.length > 0}
|
||||
{@const midX = (aCenter.x + bCenter.x) / 2}
|
||||
{@const parentBottomY = aCenter.y + NODE_H / 2}
|
||||
{@const childTopY = childCenters[0].y - NODE_H / 2}
|
||||
{@const barY = (parentBottomY + childTopY) / 2}
|
||||
{@const xs = childCenters.map((c) => c.x)}
|
||||
{@const minX = Math.min(midX, ...xs)}
|
||||
{@const maxX = Math.max(midX, ...xs)}
|
||||
<line
|
||||
x1={midX}
|
||||
y1={parentBottomY}
|
||||
x2={midX}
|
||||
y2={barY}
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{#if minX !== maxX}
|
||||
<line
|
||||
x1={minX}
|
||||
y1={barY}
|
||||
x2={maxX}
|
||||
y2={barY}
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{/if}
|
||||
{#each childCenters as cc, i (group.childIds[i])}
|
||||
<line
|
||||
x1={cc.x}
|
||||
y1={barY}
|
||||
x2={cc.x}
|
||||
y2={childTopY}
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
|
||||
{#each parentLinks.single as link (link.key)}
|
||||
{@const parentCenter = nodeCenter(link.parentId)}
|
||||
{@const childCenter = nodeCenter(link.childId)}
|
||||
{#if parentCenter && childCenter}
|
||||
{@const parentBottomY = parentCenter.y + NODE_H / 2}
|
||||
{@const childTopY = childCenter.y - NODE_H / 2}
|
||||
{@const barY = (parentBottomY + childTopY) / 2}
|
||||
<line
|
||||
x1={parentCenter.x}
|
||||
y1={parentBottomY}
|
||||
x2={parentCenter.x}
|
||||
y2={barY}
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{#if parentCenter.x !== childCenter.x}
|
||||
<line
|
||||
x1={parentCenter.x}
|
||||
y1={barY}
|
||||
x2={childCenter.x}
|
||||
y2={barY}
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{/if}
|
||||
<line
|
||||
x1={childCenter.x}
|
||||
y1={barY}
|
||||
x2={childCenter.x}
|
||||
y2={childTopY}
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Spouse connectors -->
|
||||
{#each spouseEdges as e (e.id)}
|
||||
{@const aCenter = nodeCenter(e.personId)}
|
||||
{@const bCenter = nodeCenter(e.relatedPersonId)}
|
||||
{#if aCenter && bCenter}
|
||||
<line
|
||||
x1={aCenter.x}
|
||||
y1={aCenter.y}
|
||||
x2={bCenter.x}
|
||||
y2={bCenter.y}
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
||||
/>
|
||||
<circle
|
||||
cx={(aCenter.x + bCenter.x) / 2}
|
||||
cy={(aCenter.y + bCenter.y) / 2}
|
||||
r="4.5"
|
||||
fill="var(--c-primary)"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Nodes -->
|
||||
{#each nodes as node (node.id)}
|
||||
{@const pos = layout.positions.get(node.id)}
|
||||
{#if pos}
|
||||
{@const isSelected = selectedId === node.id}
|
||||
{@const isFocused = focusedId === node.id}
|
||||
<g
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="{node.displayName}{node.birthYear || node.deathYear
|
||||
? `, ${node.birthYear ?? '?'}–${node.deathYear ?? ''}`
|
||||
: ''}"
|
||||
aria-expanded={isSelected}
|
||||
transform="translate({pos.x}, {pos.y})"
|
||||
onclick={() => onSelect(node.id)}
|
||||
onkeydown={(e) => handleNodeKey(e, node.id)}
|
||||
onfocus={() => (focusedId = node.id)}
|
||||
onblur={() => (focusedId = null)}
|
||||
class="cursor-pointer focus:outline-none"
|
||||
>
|
||||
{#if isFocused}
|
||||
<rect
|
||||
x="-3"
|
||||
y="-3"
|
||||
width={NODE_W + 6}
|
||||
height={NODE_H + 6}
|
||||
rx="6"
|
||||
fill="none"
|
||||
stroke="var(--c-focus-ring)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
{/if}
|
||||
<rect
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx="4"
|
||||
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{#if isSelected}
|
||||
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
|
||||
{/if}
|
||||
<text
|
||||
x={NODE_W / 2}
|
||||
y={NODE_H / 2 - 6}
|
||||
text-anchor="middle"
|
||||
font-family="serif"
|
||||
font-size="16"
|
||||
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
|
||||
>
|
||||
{node.displayName}
|
||||
</text>
|
||||
{#if node.birthYear || node.deathYear}
|
||||
<text
|
||||
x={NODE_W / 2}
|
||||
y={NODE_H / 2 + 12}
|
||||
text-anchor="middle"
|
||||
font-family="sans-serif"
|
||||
font-size="12"
|
||||
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
|
||||
opacity={isSelected ? 0.75 : 1}
|
||||
>
|
||||
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
@@ -1,349 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import StammbaumTree from './StammbaumTree.svelte';
|
||||
|
||||
const ID_A = '00000000-0000-0000-0000-000000000001';
|
||||
const ID_B = '00000000-0000-0000-0000-000000000002';
|
||||
|
||||
function parseViewBox(svg: SVGElement): [number, number, number, number] {
|
||||
const parts = svg.getAttribute('viewBox')!.split(/\s+/).map(Number);
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function rectsCentroid(svg: SVGElement): { x: number; y: number } {
|
||||
const rects = Array.from(svg.querySelectorAll('rect'));
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let n = 0;
|
||||
for (const r of rects) {
|
||||
const x = parseFloat(r.getAttribute('x') ?? '0');
|
||||
const y = parseFloat(r.getAttribute('y') ?? '0');
|
||||
const w = parseFloat(r.getAttribute('width') ?? '0');
|
||||
const h = parseFloat(r.getAttribute('height') ?? '0');
|
||||
// Skip the narrow accent stripe.
|
||||
if (w < 10) continue;
|
||||
// Each node rect lives inside <g transform="translate(...)">.
|
||||
const g = r.closest('g[transform]');
|
||||
const transform = g?.getAttribute('transform') ?? '';
|
||||
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
|
||||
const tx = match ? parseFloat(match[1]) : 0;
|
||||
const ty = match ? parseFloat(match[2]) : 0;
|
||||
sx += tx + x + w / 2;
|
||||
sy += ty + y + h / 2;
|
||||
n++;
|
||||
}
|
||||
return { x: sx / n, y: sy / n };
|
||||
}
|
||||
|
||||
describe('StammbaumTree viewBox', () => {
|
||||
it('uses the minimum size and centers a single node', async () => {
|
||||
render(StammbaumTree, {
|
||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||
edges: [],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const svg = document.querySelector('svg')!;
|
||||
const [x, y, w, h] = parseViewBox(svg);
|
||||
|
||||
// Single 160x56 node fits inside the 1200x800 minimum viewBox.
|
||||
expect(w).toBe(1200);
|
||||
expect(h).toBe(800);
|
||||
|
||||
// Whatever absolute coordinates the layout uses, the viewBox must
|
||||
// centre on the rendered content.
|
||||
const c = rectsCentroid(svg);
|
||||
expect(x + w / 2).toBeCloseTo(c.x, 1);
|
||||
expect(y + h / 2).toBeCloseTo(c.y, 1);
|
||||
});
|
||||
|
||||
it('renders only orthogonal segments when two parents share two children', async () => {
|
||||
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
|
||||
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
|
||||
const CHILD_1 = '00000000-0000-0000-0000-00000000000c';
|
||||
const CHILD_2 = '00000000-0000-0000-0000-00000000000d';
|
||||
render(StammbaumTree, {
|
||||
nodes: [
|
||||
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
|
||||
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
|
||||
{ id: CHILD_1, displayName: 'Clara', familyMember: true },
|
||||
{ id: CHILD_2, displayName: 'Hans', familyMember: true }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'sp',
|
||||
personId: PARENT_A,
|
||||
relatedPersonId: PARENT_B,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Eugenie',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'p1a',
|
||||
personId: PARENT_A,
|
||||
relatedPersonId: CHILD_1,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Clara',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p1b',
|
||||
personId: PARENT_B,
|
||||
relatedPersonId: CHILD_1,
|
||||
personDisplayName: 'Eugenie',
|
||||
relatedPersonDisplayName: 'Clara',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p2a',
|
||||
personId: PARENT_A,
|
||||
relatedPersonId: CHILD_2,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p2b',
|
||||
personId: PARENT_B,
|
||||
relatedPersonId: CHILD_2,
|
||||
personDisplayName: 'Eugenie',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const lines = Array.from(document.querySelectorAll('svg line'));
|
||||
// Every parent-child segment must be either vertical (x1==x2) or
|
||||
// horizontal (y1==y2) — no slanted segments allowed.
|
||||
const slanted = lines.filter(
|
||||
(l) =>
|
||||
l.getAttribute('x1') !== l.getAttribute('x2') &&
|
||||
l.getAttribute('y1') !== l.getAttribute('y2')
|
||||
);
|
||||
expect(slanted).toHaveLength(0);
|
||||
// Sibling bar must exist and span the children: at least one
|
||||
// horizontal line whose x1 != x2 and y1 == y2.
|
||||
const horizontalBars = lines.filter(
|
||||
(l) =>
|
||||
l.getAttribute('y1') === l.getAttribute('y2') &&
|
||||
l.getAttribute('x1') !== l.getAttribute('x2')
|
||||
);
|
||||
expect(horizontalBars.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('positions a single child at the midpoint of its two parents (vertical drop)', async () => {
|
||||
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
|
||||
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
|
||||
const CHILD = '00000000-0000-0000-0000-00000000000c';
|
||||
render(StammbaumTree, {
|
||||
nodes: [
|
||||
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
|
||||
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
|
||||
{ id: CHILD, displayName: 'Hans', familyMember: true }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'sp',
|
||||
personId: PARENT_A,
|
||||
relatedPersonId: PARENT_B,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Eugenie',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'p1',
|
||||
personId: PARENT_A,
|
||||
relatedPersonId: CHILD,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
personId: PARENT_B,
|
||||
relatedPersonId: CHILD,
|
||||
personDisplayName: 'Eugenie',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const lines = Array.from(document.querySelectorAll('svg line'));
|
||||
// No slanted segments. With one child, no horizontal sibling bar
|
||||
// is needed because midX == child.center.x.
|
||||
const slanted = lines.filter(
|
||||
(l) =>
|
||||
l.getAttribute('x1') !== l.getAttribute('x2') &&
|
||||
l.getAttribute('y1') !== l.getAttribute('y2')
|
||||
);
|
||||
expect(slanted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('places a loose spouse adjacent to their partner and demotes their child a generation', async () => {
|
||||
// Walter ↔ Eugenie (gen 0); their children Hans + Clara (gen 1).
|
||||
// Hans ↔ Hilde (Hilde has no parents in graph). Hans + Hilde have
|
||||
// child Lili. Hilde must sit next to Hans, and Lili must be on a
|
||||
// row below Hans/Hilde — not on the same row.
|
||||
const WALTER = '00000000-0000-0000-0000-000000000001';
|
||||
const EUGENIE = '00000000-0000-0000-0000-000000000002';
|
||||
const HANS = '00000000-0000-0000-0000-000000000003';
|
||||
const CLARA = '00000000-0000-0000-0000-000000000004';
|
||||
const HILDE = '00000000-0000-0000-0000-000000000005';
|
||||
const LILI = '00000000-0000-0000-0000-000000000006';
|
||||
|
||||
render(StammbaumTree, {
|
||||
nodes: [
|
||||
{ id: WALTER, displayName: 'Walter', familyMember: true },
|
||||
{ id: EUGENIE, displayName: 'Eugenie', familyMember: true },
|
||||
{ id: HANS, displayName: 'Hans', familyMember: true },
|
||||
{ id: CLARA, displayName: 'Clara', familyMember: true },
|
||||
{ id: HILDE, displayName: 'Hilde', familyMember: true },
|
||||
{ id: LILI, displayName: 'Lili', familyMember: true }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 's1',
|
||||
personId: WALTER,
|
||||
relatedPersonId: EUGENIE,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Eugenie',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'p1',
|
||||
personId: WALTER,
|
||||
relatedPersonId: HANS,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
personId: EUGENIE,
|
||||
relatedPersonId: HANS,
|
||||
personDisplayName: 'Eugenie',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
personId: WALTER,
|
||||
relatedPersonId: CLARA,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Clara',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
personId: EUGENIE,
|
||||
relatedPersonId: CLARA,
|
||||
personDisplayName: 'Eugenie',
|
||||
relatedPersonDisplayName: 'Clara',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
personId: HANS,
|
||||
relatedPersonId: HILDE,
|
||||
personDisplayName: 'Hans',
|
||||
relatedPersonDisplayName: 'Hilde',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
personId: HANS,
|
||||
relatedPersonId: LILI,
|
||||
personDisplayName: 'Hans',
|
||||
relatedPersonDisplayName: 'Lili',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
personId: HILDE,
|
||||
relatedPersonId: LILI,
|
||||
personDisplayName: 'Hilde',
|
||||
relatedPersonDisplayName: 'Lili',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const ys = new Map<string, number>();
|
||||
for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
|
||||
const aria = g.getAttribute('aria-label') ?? '';
|
||||
const transform = g.getAttribute('transform') ?? '';
|
||||
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
|
||||
if (!match) continue;
|
||||
ys.set(aria.split(',')[0], parseFloat(match[2]));
|
||||
}
|
||||
|
||||
// Lili must be on a deeper row than Hans / Hilde.
|
||||
expect(ys.get('Lili')).toBeGreaterThan(ys.get('Hans')!);
|
||||
expect(ys.get('Hans')).toEqual(ys.get('Hilde'));
|
||||
|
||||
// Hans and Hilde must be horizontally adjacent (|Δx| == NODE_W + COL_GAP).
|
||||
const xs = new Map<string, number>();
|
||||
for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
|
||||
const aria = g.getAttribute('aria-label') ?? '';
|
||||
const transform = g.getAttribute('transform') ?? '';
|
||||
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
|
||||
if (!match) continue;
|
||||
xs.set(aria.split(',')[0], parseFloat(match[1]));
|
||||
}
|
||||
expect(Math.abs(xs.get('Hans')! - xs.get('Hilde')!)).toBe(160 + 40);
|
||||
|
||||
// All parent-child segments must be orthogonal.
|
||||
const lines = Array.from(document.querySelectorAll('svg line'));
|
||||
const slanted = lines.filter(
|
||||
(l) =>
|
||||
l.getAttribute('x1') !== l.getAttribute('x2') &&
|
||||
l.getAttribute('y1') !== l.getAttribute('y2')
|
||||
);
|
||||
expect(slanted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('centers two spouse nodes within the minimum viewBox', async () => {
|
||||
render(StammbaumTree, {
|
||||
nodes: [
|
||||
{ id: ID_A, displayName: 'Anna', familyMember: true },
|
||||
{ id: ID_B, displayName: 'Bertha', familyMember: true }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
personId: ID_A,
|
||||
relatedPersonId: ID_B,
|
||||
personDisplayName: 'Anna',
|
||||
relatedPersonDisplayName: 'Bertha',
|
||||
relationType: 'SPOUSE_OF'
|
||||
}
|
||||
],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const svg = document.querySelector('svg')!;
|
||||
const [x, y, w, h] = parseViewBox(svg);
|
||||
|
||||
expect(w).toBe(1200);
|
||||
expect(h).toBe(800);
|
||||
|
||||
const c = rectsCentroid(svg);
|
||||
expect(x + w / 2).toBeCloseTo(c.x, 1);
|
||||
expect(y + h / 2).toBeCloseTo(c.y, 1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user