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:
240
frontend/src/lib/components/PersonHoverCard.svelte
Normal file
240
frontend/src/lib/components/PersonHoverCard.svelte
Normal 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>
|
||||||
272
frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Normal file
272
frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Normal file
@@ -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. <alias>" 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user