test(TranscriptionReadView): cover hover card timer and keyboard focus behavior
Five new tests verify: - Card stays open when mouse moves mention → card (cancels 150ms timer) - Card closes immediately on card mouseleave (no timer) - Re-entering a mention cancels a pending close - Card stays open when keyboard focus moves mention → card (WCAG 2.1.1) - Card closes when keyboard focus leaves the card entirely The keyboard tests drove adding onfocusin/onfocusout to PersonHoverCard's root div, reusing the existing onmouseenter/onmouseleave callbacks so that screen-reader and keyboard users get the same stay-open affordance as mouse users. relatedTarget check prevents spurious closes on intra-card focus movement. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,12 @@ const showMaidenName = $derived(
|
|||||||
style:left={`${position.left}px`}
|
style:left={`${position.left}px`}
|
||||||
onmouseenter={onmouseenter}
|
onmouseenter={onmouseenter}
|
||||||
onmouseleave={onmouseleave}
|
onmouseleave={onmouseleave}
|
||||||
|
onfocusin={onmouseenter}
|
||||||
|
onfocusout={(e) => {
|
||||||
|
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) {
|
||||||
|
onmouseleave?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if state.status === 'loading'}
|
{#if state.status === 'loading'}
|
||||||
<div
|
<div
|
||||||
|
|||||||
170
frontend/src/lib/components/TranscriptionReadView.svelte.spec.ts
Normal file
170
frontend/src/lib/components/TranscriptionReadView.svelte.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
||||||
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
|
||||||
|
const PERSON_ID = '11111111-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
const block: TranscriptionBlockData = {
|
||||||
|
id: 'b1',
|
||||||
|
annotationId: 'a1',
|
||||||
|
documentId: 'd1',
|
||||||
|
text: '@Auguste',
|
||||||
|
label: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockPersonFetch() {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockImplementation((url: string) => {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user