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;
|
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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user