diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte index ac599120..4eecd578 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte +++ b/frontend/src/lib/components/PersonHoverCard.svelte @@ -94,6 +94,12 @@ const showMaidenName = $derived( 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'}
{ + if (url.includes('/relationships')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + id: PERSON_ID, + firstName: 'Auguste', + lastName: 'Raddatz', + displayName: 'Auguste Raddatz' + }) + }); + }) + ); +} + +function getMentionLink(): HTMLAnchorElement { + return document.querySelector( + `a.person-mention[data-person-id="${PERSON_ID}"]` + ) as HTMLAnchorElement; +} + +function getHoverCard(): HTMLElement | null { + return document.querySelector('[data-testid="person-hover-card"]'); +} + +/** Hover a mention and wait until the loaded card content is in the DOM. */ +async function showCard(): Promise { + getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false })); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); + }); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +// ─── Mouse timer behavior ────────────────────────────────────────────────────── + +describe('TranscriptionReadView — hover card mouse timer', () => { + it('keeps the card open when mouse moves from mention to card within 150ms', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + await showCard(); + + // Leave mention — starts 150ms close timer + getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })); + + // Enter card before 150ms — cancels timer + getHoverCard()!.dispatchEvent(new MouseEvent('mouseenter')); + + // Wait past the original 150ms window + await new Promise((r) => setTimeout(r, 200)); + + expect(getHoverCard()).not.toBeNull(); + }); + + it('closes the card immediately when mouse leaves the card (no timer)', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + await showCard(); + + // Leave card — activeCard = null immediately, no timer + getHoverCard()!.dispatchEvent(new MouseEvent('mouseleave')); + + await vi.waitFor(() => { + expect(getHoverCard()).toBeNull(); + }); + }); + + it('cancels a pending close when mouse re-enters a mention', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + await showCard(); + + // Leave mention — starts 150ms close timer + getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })); + + // Re-enter same mention before 150ms — cancels timer + getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false })); + + // Wait past the original 150ms window + await new Promise((r) => setTimeout(r, 200)); + + expect(getHoverCard()).not.toBeNull(); + }); +}); + +// ─── Keyboard focus behavior ─────────────────────────────────────────────────── + +describe('TranscriptionReadView — hover card keyboard focus', () => { + it('keeps the card open when keyboard focus moves from mention into card', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + // Show card via keyboard focusin on mention + getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); + }); + + // Focus leaves mention — starts 150ms close timer + getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + // Focus enters card — should cancel the close timer + getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + // Wait past the 150ms window + await new Promise((r) => setTimeout(r, 200)); + + expect(getHoverCard()).not.toBeNull(); + }); + + it('closes the card when keyboard focus leaves the card entirely', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + // Show card via keyboard focusin + getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); + }); + + // Focus leaves mention — 150ms timer starts + getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + // Focus enters card — cancels timer + getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + // Focus leaves card entirely (relatedTarget = null means focus left the page) + getHoverCard()!.dispatchEvent( + new FocusEvent('focusout', { bubbles: true, relatedTarget: null }) + ); + + await vi.waitFor(() => { + expect(getHoverCard()).toBeNull(); + }); + }); +});