feat(stammbaum): mobile read path — pan, zoom, fit-to-view (#692) #694
90
frontend/src/lib/person/genealogy/StammbaumAffordance.svelte
Normal file
90
frontend/src/lib/person/genealogy/StammbaumAffordance.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Set true once the canvas receives its first pointer interaction. */
|
||||||
|
dismissed?: boolean;
|
||||||
|
/**
|
||||||
|
* Force touch mode on/off. When undefined, falls back to a
|
||||||
|
* `matchMedia('(pointer: coarse)')` check so the hint only appears on touch
|
||||||
|
* devices (OQ-008). Tests pass an explicit boolean.
|
||||||
|
*/
|
||||||
|
touch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { dismissed = false, touch }: Props = $props();
|
||||||
|
|
||||||
|
// Boolean gate only — the stored timestamp is compared, never rendered to the
|
||||||
|
// DOM (Nora #692). 30-day re-show window (NFR-USE-001).
|
||||||
|
const STORAGE_KEY = 'stammbaumAffordanceDismissedAt';
|
||||||
|
const RESHOW_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function recentlyDismissed(): boolean {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return false;
|
||||||
|
return Date.now() - Number(raw) < RESHOW_AFTER_MS;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTouch(): boolean {
|
||||||
|
if (touch !== undefined) return touch;
|
||||||
|
return (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.matchMedia === 'function' &&
|
||||||
|
window.matchMedia('(pointer: coarse)').matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible = $state(false);
|
||||||
|
onMount(() => {
|
||||||
|
visible = isTouch() && !recentlyDismissed();
|
||||||
|
});
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(Date.now()));
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — hide anyway for this session */
|
||||||
|
}
|
||||||
|
visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First canvas interaction auto-dismisses the hint (Leonie).
|
||||||
|
$effect(() => {
|
||||||
|
if (dismissed && visible) hide();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 bottom-4 z-20 flex justify-center px-4"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto flex items-center gap-2 rounded-full border border-line bg-surface/95 px-4 py-2 text-sm text-ink-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<span>{m.stammbaum_affordance_hint()}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={hide}
|
||||||
|
aria-label={m.stammbaum_affordance_dismiss()}
|
||||||
|
class="rounded-sm p-0.5 text-ink-3 transition hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
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}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import StammbaumAffordance from './StammbaumAffordance.svelte';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'stammbaumAffordanceDismissedAt';
|
||||||
|
|
||||||
|
describe('StammbaumAffordance (#692)', () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it('shows the hint on a touch device that has not dismissed it', async () => {
|
||||||
|
render(StammbaumAffordance, { touch: true });
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="status"]')).not.toBeNull());
|
||||||
|
expect(document.body.textContent).toContain('Ziehen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show on non-touch devices (OQ-008)', async () => {
|
||||||
|
render(StammbaumAffordance, { touch: false });
|
||||||
|
expect(document.querySelector('[role="status"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides and records dismissal when the close button is clicked', async () => {
|
||||||
|
render(StammbaumAffordance, { touch: true });
|
||||||
|
const dismiss = [...document.querySelectorAll<HTMLButtonElement>('button')][0];
|
||||||
|
dismiss.click();
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('[role="status"]')).toBeNull());
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reappear within the 30-day window (NFR-USE-001)', async () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(Date.now()));
|
||||||
|
render(StammbaumAffordance, { touch: true });
|
||||||
|
expect(document.querySelector('[role="status"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,8 @@ interface Props {
|
|||||||
onPanZoom?: (state: PanZoomState) => void;
|
onPanZoom?: (state: PanZoomState) => void;
|
||||||
/** When set to a node id, the canvas recentres on that node (US-PAN-005). */
|
/** When set to a node id, the canvas recentres on that node (US-PAN-005). */
|
||||||
centreOnId?: string | null;
|
centreOnId?: string | null;
|
||||||
|
/** Fired on the first pointer interaction with the canvas (affordance dismiss). */
|
||||||
|
onActivity?: () => void;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
/**
|
/**
|
||||||
* Force-show or force-hide the generation gutter. When undefined, falls
|
* Force-show or force-hide the generation gutter. When undefined, falls
|
||||||
@@ -46,6 +48,7 @@ let {
|
|||||||
panZoom,
|
panZoom,
|
||||||
onPanZoom = () => {},
|
onPanZoom = () => {},
|
||||||
centreOnId = null,
|
centreOnId = null,
|
||||||
|
onActivity,
|
||||||
onSelect,
|
onSelect,
|
||||||
showGutter
|
showGutter
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -295,7 +298,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
baseCentreX: baseCentre.x,
|
baseCentreX: baseCentre.x,
|
||||||
baseCentreY: baseCentre.y,
|
baseCentreY: baseCentre.y,
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
onPanZoom
|
onPanZoom,
|
||||||
|
onGestureStart: onActivity
|
||||||
}}
|
}}
|
||||||
class="block h-full w-full"
|
class="block h-full w-full"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
|
|||||||
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
|
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
|
||||||
import StammbaumBottomSheet from '$lib/person/genealogy/StammbaumBottomSheet.svelte';
|
import StammbaumBottomSheet from '$lib/person/genealogy/StammbaumBottomSheet.svelte';
|
||||||
import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte';
|
import StammbaumControls from '$lib/person/genealogy/StammbaumControls.svelte';
|
||||||
|
import StammbaumAffordance from '$lib/person/genealogy/StammbaumAffordance.svelte';
|
||||||
import {
|
import {
|
||||||
type PanZoomState,
|
type PanZoomState,
|
||||||
DEFAULT_VIEW,
|
DEFAULT_VIEW,
|
||||||
@@ -36,6 +37,7 @@ let selectedId = $state<string | null>(
|
|||||||
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
|
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
|
||||||
|
|
||||||
let view = $state<PanZoomState>(data.initialView);
|
let view = $state<PanZoomState>(data.initialView);
|
||||||
|
let canvasActivity = $state(false);
|
||||||
function zoomIn() {
|
function zoomIn() {
|
||||||
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
|
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
|
||||||
}
|
}
|
||||||
@@ -124,8 +126,10 @@ $effect(() => {
|
|||||||
panZoom={view}
|
panZoom={view}
|
||||||
centreOnId={centreOnId}
|
centreOnId={centreOnId}
|
||||||
onPanZoom={(v) => (view = v)}
|
onPanZoom={(v) => (view = v)}
|
||||||
|
onActivity={() => (canvasActivity = true)}
|
||||||
onSelect={(id) => (selectedId = id)}
|
onSelect={(id) => (selectedId = id)}
|
||||||
/>
|
/>
|
||||||
|
<StammbaumAffordance dismissed={canvasActivity} />
|
||||||
<StammbaumControls onZoomIn={zoomIn} onZoomOut={zoomOut} onFit={fitToScreen} />
|
<StammbaumControls onZoomIn={zoomIn} onZoomOut={zoomOut} onFit={fitToScreen} />
|
||||||
</div>
|
</div>
|
||||||
{#if selectedNode}
|
{#if selectedNode}
|
||||||
|
|||||||
Reference in New Issue
Block a user