Cards compose aria-hidden * / † glyphs in markup so screen readers only announce the dates; PersonSummaryDTO list card stays year-shaped by design (ADR-039). MentionDropdown subtitle wraps instead of truncating so DAY-precision ranges fit at 320px. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
318 lines
7.6 KiB
Svelte
318 lines
7.6 KiB
Svelte
<script lang="ts">
|
||
import { m } from '$lib/paraglide/messages.js';
|
||
import { formatLifeDate } from '$lib/person/personLifeDates';
|
||
import { chipLabel, otherName } from '$lib/person/relationshipLabels';
|
||
import type { components } from '$lib/generated/api';
|
||
import type { LoadState } from '$lib/person/personHoverCard';
|
||
|
||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||
|
||
type Props = {
|
||
personId: string;
|
||
cardId: string;
|
||
position: { top: number; left: number };
|
||
state: LoadState;
|
||
onmouseenter?: () => void;
|
||
onmouseleave?: () => void;
|
||
};
|
||
|
||
let { personId, cardId, position, state, onmouseenter, onmouseleave }: 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 birthText = $derived(
|
||
state.status === 'loaded'
|
||
? formatLifeDate(state.person.birthDate, state.person.birthDatePrecision)
|
||
: ''
|
||
);
|
||
const deathText = $derived(
|
||
state.status === 'loaded'
|
||
? formatLifeDate(state.person.deathDate, state.person.deathDatePrecision)
|
||
: ''
|
||
);
|
||
|
||
/**
|
||
* Cut the notes excerpt at the last word boundary inside the NOTES_MAX
|
||
* window. Mid-word truncation is especially ugly in German compound nouns
|
||
* ("…Familienzu…"), so prefer the previous space if there is one within
|
||
* a reasonable distance. Fall back to a hard cut for strings with no
|
||
* spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5.
|
||
*/
|
||
function truncateAtWordBoundary(text: string, max: number): string {
|
||
if (text.length <= max) return text;
|
||
const window = text.slice(0, max);
|
||
const lastSpace = window.lastIndexOf(' ');
|
||
// If the last space is too close to the start (< 70% of the window) we'd
|
||
// produce a near-empty excerpt — fall back to the hard cut instead.
|
||
const minBoundary = Math.floor(max * 0.7);
|
||
const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window;
|
||
return cut + '…';
|
||
}
|
||
|
||
const notesExcerpt = $derived.by(() => {
|
||
if (state.status !== 'loaded') return null;
|
||
const notes = state.person.notes;
|
||
if (!notes) return null;
|
||
return truncateAtWordBoundary(notes, NOTES_MAX);
|
||
});
|
||
|
||
// Accessible name for the region landmark — required by WCAG 1.3.1.
|
||
// Falls back to a localised loading label so axe-core never sees an unnamed
|
||
// region (Leonie FINDING-02 / Elicit NFR concern).
|
||
const ariaLabel = $derived(
|
||
state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()
|
||
);
|
||
|
||
// aria-busy="true" while loading so SR clients know the region's contents
|
||
// will change. Cleared on loaded/error so the new content is announced.
|
||
const ariaBusy = $derived(state.status === 'loading');
|
||
|
||
const showMaidenName = $derived(
|
||
state.status === 'loaded' &&
|
||
!!state.person.alias &&
|
||
state.person.alias !== state.person.lastName &&
|
||
state.person.alias !== state.person.displayName
|
||
);
|
||
</script>
|
||
|
||
<div
|
||
class="person-hover-card"
|
||
data-testid="person-hover-card"
|
||
id={cardId}
|
||
role="region"
|
||
aria-live="polite"
|
||
aria-label={ariaLabel}
|
||
aria-busy={ariaBusy ? 'true' : undefined}
|
||
style:position="fixed"
|
||
style:top={`${position.top}px`}
|
||
style:left={`${position.left}px`}
|
||
onmouseenter={onmouseenter}
|
||
onmouseleave={onmouseleave}
|
||
onfocusin={onmouseenter}
|
||
onfocusout={(e) => {
|
||
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) {
|
||
onmouseleave?.();
|
||
}
|
||
}}
|
||
>
|
||
{#if state.status === 'loading'}
|
||
<div
|
||
data-testid="person-hover-card-skeleton"
|
||
class="skeleton"
|
||
role="status"
|
||
aria-label={m.person_mention_loading()}
|
||
>
|
||
<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 birthText || deathText}
|
||
<!-- Glyphs aria-hidden so screen readers only announce the dates -->
|
||
<div class="dates" data-testid="person-hover-card-dates">
|
||
{#if birthText}
|
||
<span aria-hidden="true">*</span> {birthText}
|
||
{/if}
|
||
{#if birthText && deathText}–{/if}
|
||
{#if deathText}
|
||
<span aria-hidden="true">†</span> {deathText}
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{#if showMaidenName}
|
||
<div class="maiden" data-testid="person-hover-card-maiden">
|
||
{m.person_born_name_prefix()}
|
||
{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">
|
||
<span class="chip-type">{chipLabel(chip, personId)}:</span>
|
||
{otherName(chip, personId)}
|
||
</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 {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
background-color: var(--c-accent-bg);
|
||
color: var(--c-ink);
|
||
border-radius: 999px;
|
||
padding: 2px 10px;
|
||
}
|
||
|
||
.chip-type {
|
||
font-weight: 600;
|
||
/* opacity 0.7 on --c-ink: ~5.6:1 light, ~7.1:1 dark — WCAG AA ✓ */
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.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>
|