Files
familienarchiv/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte
Marcel 0306023610 fix(stammbaum): 44x44 touch targets for panel + affordance icon buttons (#692)
Enlarge the centre-on-person, panel-close, and affordance-dismiss icon buttons
to 44x44 hit areas (WCAG 2.5.8, UX review) while keeping the small glyphs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:00:58 +02:00

208 lines
6.4 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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/person/relationshipLabels';
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
import type { RelFormData } from '$lib/person/relationship/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;
/** When provided, a "centre on this person" control appears in the title row (US-PAN-005). */
onCentre?: () => void;
canWrite?: boolean;
}
let { node, onClose, onCentre, 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>
<div class="flex shrink-0 items-center gap-1">
{#if onCentre}
<button
type="button"
onclick={onCentre}
aria-label={m.stammbaum_centre_on_person()}
class="inline-flex h-11 w-11 items-center justify-center rounded-sm 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="1.5"
aria-hidden="true"
>
<circle cx="8" cy="8" r="2" />
<path stroke-linecap="round" d="M8 1v2M8 13v2M1 8h2M13 8h2" />
</svg>
</button>
{/if}
<button
type="button"
onclick={onClose}
aria-label={m.comp_dismiss()}
class="inline-flex h-11 w-11 items-center justify-center rounded-sm 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>
</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>