feat(person-mention): PersonHoverCard with skeleton/error/loaded states

The card has three render states:
  - loading  → 320×180 skeleton with three pulse-animated bars; respects
               prefers-reduced-motion (animation disabled, opacity dimmed)
  - error    → generic load-error message in the body; the footer link
               still navigates (click works regardless of fetch outcome)
  - loaded   → navy header with name, life-date range, and "geb. <alias>";
               family-only relationship chips (PARENT_OF / SPOUSE_OF /
               SIBLING_OF) — non-family types are filtered out;
               notes excerpt capped at 120 chars with ellipsis;
               footer with "Zur Person →" + hover hint

aria-live="polite" on the card root so screen readers announce loaded
content when the fetch resolves; the host's id is the cardId so the
parent anchor can use aria-describedby. The card is hidden via
@media (hover: none) on touch devices — tap navigates directly per
spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 08:16:51 +02:00
parent c247e1e971
commit c9c395eb59
2 changed files with 512 additions and 0 deletions

View File

@@ -0,0 +1,240 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
export type LoadState =
| { status: 'loading' }
| { status: 'error' }
| { status: 'loaded'; person: Person; relationships: RelationshipDTO[] };
type Props = {
personId: string;
cardId: string;
position: { top: number; left: number };
state: LoadState;
};
let { personId, cardId, position, state }: Props = $props();
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
'PARENT_OF',
'SPOUSE_OF',
'SIBLING_OF'
]);
const NOTES_MAX = 120;
const familyChips = $derived(
state.status === 'loaded'
? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
: []
);
const dateRange = $derived(
state.status === 'loaded'
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
: ''
);
const notesExcerpt = $derived.by(() => {
if (state.status !== 'loaded') return null;
const notes = state.person.notes;
if (!notes) return null;
if (notes.length <= NOTES_MAX) return notes;
return notes.slice(0, NOTES_MAX) + '…';
});
</script>
<div
class="person-hover-card"
data-testid="person-hover-card"
id={cardId}
role="region"
aria-live="polite"
style:position="absolute"
style:top={`${position.top}px`}
style:left={`${position.left}px`}
>
{#if state.status === 'loading'}
<div data-testid="person-hover-card-skeleton" class="skeleton">
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
</div>
{:else if state.status === 'error'}
<div data-testid="person-hover-card-error" class="error-message">
{m.person_mention_load_error()}
</div>
<div class="footer">
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()}</a>
</div>
{:else}
<div data-testid="person-hover-card-content" class="content">
<div class="header">
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
{#if dateRange}
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
{/if}
{#if state.person.alias}
<div class="maiden" data-testid="person-hover-card-maiden">geb. {state.person.alias}</div>
{/if}
</div>
{#if familyChips.length > 0}
<div class="chips" data-testid="person-hover-card-chips">
{#each familyChips as chip (chip.id)}
<span class="chip">{chip.relatedPersonDisplayName}</span>
{/each}
</div>
{/if}
{#if notesExcerpt}
<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
{/if}
<div class="footer">
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()}</a>
<span class="hint">{m.person_mention_hover_hint()}</span>
</div>
</div>
{/if}
</div>
<style>
.person-hover-card {
width: 320px;
min-height: 180px;
background-color: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
padding: 14px 16px;
font-family: var(--font-sans);
font-size: 14px;
color: var(--c-ink);
z-index: 50;
}
/* On touch devices the card is suppressed entirely — tap navigates directly. */
@media (hover: none) {
.person-hover-card {
display: none;
}
}
.skeleton {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 0;
}
.skeleton .bar {
height: 14px;
border-radius: 4px;
background-color: var(--c-line);
animation: pulse 1.4s ease-in-out infinite;
}
.skeleton .bar:nth-child(1) {
width: 60%;
}
.skeleton .bar:nth-child(2) {
width: 40%;
}
.skeleton .bar:nth-child(3) {
width: 90%;
}
@keyframes pulse {
0% {
opacity: 0.55;
}
50% {
opacity: 1;
}
100% {
opacity: 0.55;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton .bar {
animation: none;
opacity: 0.7;
}
}
.header {
display: flex;
flex-direction: column;
gap: 2px;
background-color: var(--c-ink);
color: var(--c-surface);
margin: -14px -16px 12px;
padding: 12px 16px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.name {
font-family: var(--font-serif);
font-weight: 600;
font-size: 16px;
}
.dates,
.maiden {
font-size: 12px;
color: color-mix(in srgb, var(--c-surface) 75%, transparent);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.chip {
font-size: 12px;
background-color: var(--c-accent-bg);
color: var(--c-ink);
border-radius: 999px;
padding: 2px 10px;
}
.notes {
font-size: 13px;
color: var(--c-ink-2);
line-height: 1.4;
margin: 0 0 10px;
}
.error-message {
font-size: 13px;
color: var(--c-ink-2);
padding: 8px 0;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--c-line);
padding-top: 8px;
margin-top: 4px;
}
.open-link {
color: var(--c-ink);
text-decoration: underline;
text-underline-offset: 3px;
font-weight: 500;
}
.hint {
font-size: 11px;
color: var(--c-ink-3);
}
</style>