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:
@@ -10,9 +10,10 @@ interface Props {
|
||||
node: PersonNodeDTO;
|
||||
canWrite: boolean;
|
||||
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).
|
||||
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>
|
||||
|
||||
<StammbaumSidePanel node={node} canWrite={canWrite} onClose={onClose} />
|
||||
<StammbaumSidePanel node={node} canWrite={canWrite} onClose={onClose} onCentre={onCentre} />
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,12 @@ type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelation
|
||||
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, canWrite = false }: Props = $props();
|
||||
let { node, onClose, onCentre, canWrite = false }: Props = $props();
|
||||
|
||||
let directRels = $state<RelationshipDTO[]>([]);
|
||||
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
|
||||
@@ -95,23 +97,45 @@ const topDerived = $derived(
|
||||
</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"
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if onCentre}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCentre}
|
||||
aria-label={m.stammbaum_centre_on_person()}
|
||||
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"
|
||||
>
|
||||
<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="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>
|
||||
</button>
|
||||
<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}
|
||||
|
||||
@@ -11,7 +11,7 @@ const makeNode = () => ({
|
||||
id: 'person-1',
|
||||
displayName: 'Alice Müller',
|
||||
birthYear: 1900,
|
||||
deathYear: null,
|
||||
deathYear: undefined,
|
||||
familyMember: true
|
||||
});
|
||||
|
||||
@@ -50,6 +50,23 @@ describe('StammbaumSidePanel', () => {
|
||||
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 () => {
|
||||
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
|
||||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { untrack, tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
@@ -42,6 +42,16 @@ function zoomIn() {
|
||||
function zoomOut() {
|
||||
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 = () => {};
|
||||
function fitToScreen() {
|
||||
cancelAnimation();
|
||||
@@ -112,6 +122,7 @@ $effect(() => {
|
||||
edges={data.edges}
|
||||
selectedId={selectedId}
|
||||
panZoom={view}
|
||||
centreOnId={centreOnId}
|
||||
onPanZoom={(v) => (view = v)}
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
/>
|
||||
@@ -126,6 +137,7 @@ $effect(() => {
|
||||
node={selectedNode}
|
||||
canWrite={canWrite}
|
||||
onClose={() => (selectedId = null)}
|
||||
onCentre={centreOnSelected}
|
||||
/>
|
||||
</aside>
|
||||
<!-- Mobile: dismissible bottom sheet (overlay, preserves pan/zoom) -->
|
||||
@@ -133,6 +145,7 @@ $effect(() => {
|
||||
node={selectedNode}
|
||||
canWrite={canWrite}
|
||||
onClose={() => (selectedId = null)}
|
||||
onCentre={centreOnSelected}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user