From c9c395eb594f06d28edeb4151ba35c2a2be375ce Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 08:16:51 +0200 Subject: [PATCH] feat(person-mention): PersonHoverCard with skeleton/error/loaded states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. "; 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 --- .../src/lib/components/PersonHoverCard.svelte | 240 ++++++++++++++++ .../components/PersonHoverCard.svelte.spec.ts | 272 ++++++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 frontend/src/lib/components/PersonHoverCard.svelte create mode 100644 frontend/src/lib/components/PersonHoverCard.svelte.spec.ts diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte new file mode 100644 index 00000000..68a56d0f --- /dev/null +++ b/frontend/src/lib/components/PersonHoverCard.svelte @@ -0,0 +1,240 @@ + + +
+ {#if state.status === 'loading'} +
+
+
+
+
+ {:else if state.status === 'error'} +
+ {m.person_mention_load_error()} +
+ + {:else} +
+
+
{state.person.displayName}
+ {#if dateRange} +
{dateRange}
+ {/if} + {#if state.person.alias} +
geb. {state.person.alias}
+ {/if} +
+ {#if familyChips.length > 0} +
+ {#each familyChips as chip (chip.id)} + {chip.relatedPersonDisplayName} + {/each} +
+ {/if} + {#if notesExcerpt} +

{notesExcerpt}

+ {/if} + +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts new file mode 100644 index 00000000..ac8bb400 --- /dev/null +++ b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonHoverCard from './PersonHoverCard.svelte'; +import type { components } from '$lib/generated/api'; + +type Person = components['schemas']['Person']; +type RelationshipDTO = components['schemas']['RelationshipDTO']; + +const AUGUSTE: Person = { + id: 'p-aug', + firstName: 'Auguste', + lastName: 'Raddatz', + displayName: 'Auguste Raddatz', + personType: 'PERSON', + familyMember: true, + birthYear: 1882, + deathYear: 1944 +} as unknown as Person; + +const POSITION = { top: 100, left: 200 }; + +afterEach(() => cleanup()); + +describe('PersonHoverCard — loading state', () => { + it('shows the skeleton when state.status is loading', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument(); + }); + + it('renders three skeleton bars', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar'); + expect(bars.length).toBe(3); + }); +}); + +describe('PersonHoverCard — error state', () => { + it('shows a generic error message when state.status is error', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'error' } + }); + await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument(); + }); + + it('still allows the link footer to navigate (link present in error state)', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'error' } + }); + // The card root must show the footer link even when the body errored — + // click navigation works regardless of fetch outcome. + const link = document.querySelector('a[href="/persons/p-aug"]'); + expect(link).not.toBeNull(); + }); +}); + +describe('PersonHoverCard — loaded state', () => { + it('renders the person displayName as the header name', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); + + it('renders the life-date range when birthYear and deathYear are present', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + }); + + it('omits the life-date line when both years are missing', async () => { + const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: noDates, relationships: [] } + }); + const dates = document.querySelector('[data-testid="person-hover-card-dates"]'); + expect(dates).toBeNull(); + }); + + it('renders "geb. " when alias is set', async () => { + const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withAlias, relationships: [] } + }); + await expect.element(page.getByText('geb. Müller')).toBeInTheDocument(); + }); + + it('omits the maiden name line when alias is null', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]'); + expect(maiden).toBeNull(); + }); + + it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => { + const relationships: RelationshipDTO[] = [ + { + id: 'r1', + personId: 'p-aug', + relatedPersonId: 'p-spouse', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Otto Raddatz', + relationType: 'SPOUSE_OF' + }, + { + id: 'r2', + personId: 'p-aug', + relatedPersonId: 'p-friend', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Karl Friend', + relationType: 'FRIEND' + }, + { + id: 'r3', + personId: 'p-aug', + relatedPersonId: 'p-sibling', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Marie Sister', + relationType: 'SIBLING_OF' + } + ]; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships } + }); + await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument(); + await expect.element(page.getByText('Marie Sister')).toBeInTheDocument(); + // Non-family relationship type must be filtered out + const friendChip = page.getByText('Karl Friend'); + await expect.element(friendChip).not.toBeInTheDocument(); + }); + + it('omits the chips section entirely when no family relationships', async () => { + const onlyFriend: RelationshipDTO[] = [ + { + id: 'r1', + personId: 'p-aug', + relatedPersonId: 'p-friend', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Karl Friend', + relationType: 'FRIEND' + } + ]; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend } + }); + const chips = document.querySelector('[data-testid="person-hover-card-chips"]'); + expect(chips).toBeNull(); + }); + + it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => { + const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withNotes, relationships: [] } + }); + await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument(); + }); + + it('truncates notes longer than 120 characters with an ellipsis', async () => { + const long = 'x'.repeat(150); + const withLongNotes = { ...AUGUSTE, notes: long } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withLongNotes, relationships: [] } + }); + const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!; + expect(notes.textContent!.length).toBeLessThanOrEqual(122); + expect(notes.textContent).toContain('…'); + }); + + it('omits notes section when notes is null', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const notes = document.querySelector('[data-testid="person-hover-card-notes"]'); + expect(notes).toBeNull(); + }); + + it('footer renders an anchor link to /persons/{personId}', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const link = document.querySelector('a[href="/persons/p-aug"]')!; + expect(link).not.toBeNull(); + }); +}); + +describe('PersonHoverCard — accessibility', () => { + it('uses aria-live="polite" so screen readers announce loaded content', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + expect(root.getAttribute('aria-live')).toBe('polite'); + }); + + it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-xyz', + position: POSITION, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + expect(root.id).toBe('card-xyz'); + }); + + it('positions itself absolutely at the given top/left', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: { top: 333, left: 444 }, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement; + expect(root.style.top).toBe('333px'); + expect(root.style.left).toBe('444px'); + expect(root.style.position).toBe('absolute'); + }); +});