feat(stammbaum): centre-on-person control in the panel title row (#692)

Add an onCentre control to StammbaumSidePanel (title row, both desktop aside
and mobile sheet). The page drives a one-shot centreOnId so StammbaumTree
recentres the canvas on the focal node (US-PAN-005). Also tighten the panel
spec's deathYear fixture to a valid type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 17:10:49 +02:00
parent 1e5a45a027
commit 1dffb430ac
4 changed files with 76 additions and 21 deletions

View File

@@ -10,9 +10,10 @@ interface Props {
node: PersonNodeDTO; node: PersonNodeDTO;
canWrite: boolean; canWrite: boolean;
onClose: () => void; onClose: () => void;
onCentre?: () => void;
} }
let { node, canWrite, onClose }: Props = $props(); let { node, canWrite, onClose, onCentre }: Props = $props();
// Swipe the sheet down past this threshold to dismiss it (Leonie). // Swipe the sheet down past this threshold to dismiss it (Leonie).
const SWIPE_DISMISS_PX = 80; const SWIPE_DISMISS_PX = 80;
@@ -70,5 +71,5 @@ function onKeydown(event: KeyboardEvent) {
<div class="h-1 w-10 rounded-full bg-line" aria-hidden="true"></div> <div class="h-1 w-10 rounded-full bg-line" aria-hidden="true"></div>
</div> </div>
<StammbaumSidePanel node={node} canWrite={canWrite} onClose={onClose} /> <StammbaumSidePanel node={node} canWrite={canWrite} onClose={onClose} onCentre={onCentre} />
</div> </div>

View File

@@ -14,10 +14,12 @@ type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelation
interface Props { interface Props {
node: PersonNodeDTO; node: PersonNodeDTO;
onClose: () => void; onClose: () => void;
/** When provided, a "centre on this person" control appears in the title row (US-PAN-005). */
onCentre?: () => void;
canWrite?: boolean; canWrite?: boolean;
} }
let { node, onClose, canWrite = false }: Props = $props(); let { node, onClose, onCentre, canWrite = false }: Props = $props();
let directRels = $state<RelationshipDTO[]>([]); let directRels = $state<RelationshipDTO[]>([]);
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]); let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
@@ -95,23 +97,45 @@ const topDerived = $derived(
</p> </p>
{/if} {/if}
</div> </div>
<button <div class="flex shrink-0 items-center gap-1">
type="button" {#if onCentre}
onclick={onClose} <button
aria-label={m.comp_dismiss()} type="button"
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" onclick={onCentre}
> aria-label={m.stammbaum_centre_on_person()}
<svg class="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"
class="h-4 w-4" >
viewBox="0 0 16 16" <svg
fill="none" class="h-4 w-4"
stroke="currentColor" viewBox="0 0 16 16"
stroke-width="2" fill="none"
aria-hidden="true" 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="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"
> >
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" /> <svg
</svg> class="h-4 w-4"
</button> 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> </div>
{#if error} {#if error}

View File

@@ -11,7 +11,7 @@ const makeNode = () => ({
id: 'person-1', id: 'person-1',
displayName: 'Alice Müller', displayName: 'Alice Müller',
birthYear: 1900, birthYear: 1900,
deathYear: null, deathYear: undefined,
familyMember: true familyMember: true
}); });
@@ -50,6 +50,23 @@ describe('StammbaumSidePanel', () => {
await expect.element(page.getByText('Alice Müller')).toBeInTheDocument(); await expect.element(page.getByText('Alice Müller')).toBeInTheDocument();
}); });
it('hides the centre control when onCentre is not provided', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
await expect
.element(page.getByRole('button', { name: 'Auf diese Person zentrieren' }))
.not.toBeInTheDocument();
});
it('calls onCentre when the centre control is clicked (US-PAN-005)', async () => {
const onCentre = vi.fn();
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), onCentre, canWrite: false });
const btn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(b) => b.getAttribute('aria-label') === 'Auf diese Person zentrieren'
);
btn!.click();
expect(onCentre).toHaveBeenCalledOnce();
});
it('shows empty-relationships message when no direct relationships are loaded', async () => { it('shows empty-relationships message when no direct relationships are loaded', async () => {
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false }); render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack, tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { page } from '$app/state'; import { page } from '$app/state';
import { replaceState } from '$app/navigation'; import { replaceState } from '$app/navigation';
@@ -42,6 +42,16 @@ function zoomIn() {
function zoomOut() { function zoomOut() {
view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) }; view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) };
} }
// One-shot recentre trigger: set the focal id, let StammbaumTree's effect read
// it and emit the recentred view, then clear so the same person can be
// re-centred on a later click (US-PAN-005).
let centreOnId = $state<string | null>(null);
async function centreOnSelected() {
centreOnId = selectedId;
await tick();
centreOnId = null;
}
let cancelAnimation = () => {}; let cancelAnimation = () => {};
function fitToScreen() { function fitToScreen() {
cancelAnimation(); cancelAnimation();
@@ -112,6 +122,7 @@ $effect(() => {
edges={data.edges} edges={data.edges}
selectedId={selectedId} selectedId={selectedId}
panZoom={view} panZoom={view}
centreOnId={centreOnId}
onPanZoom={(v) => (view = v)} onPanZoom={(v) => (view = v)}
onSelect={(id) => (selectedId = id)} onSelect={(id) => (selectedId = id)}
/> />
@@ -126,6 +137,7 @@ $effect(() => {
node={selectedNode} node={selectedNode}
canWrite={canWrite} canWrite={canWrite}
onClose={() => (selectedId = null)} onClose={() => (selectedId = null)}
onCentre={centreOnSelected}
/> />
</aside> </aside>
<!-- Mobile: dismissible bottom sheet (overlay, preserves pan/zoom) --> <!-- Mobile: dismissible bottom sheet (overlay, preserves pan/zoom) -->
@@ -133,6 +145,7 @@ $effect(() => {
node={selectedNode} node={selectedNode}
canWrite={canWrite} canWrite={canWrite}
onClose={() => (selectedId = null)} onClose={() => (selectedId = null)}
onCentre={centreOnSelected}
/> />
{/if} {/if}
</div> </div>