Compare commits
3 Commits
96d9ff5db1
...
b3fe9b1171
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3fe9b1171 | ||
|
|
3c7c7a9aa4 | ||
|
|
9908f7afdc |
@@ -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'}
|
||||
<div
|
||||
|
||||
@@ -225,6 +225,7 @@ onMount(() => {
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
'aria-label': m.transcription_editor_aria_label(),
|
||||
'data-editor-inner': '',
|
||||
class: [
|
||||
'min-h-[120px] px-1 py-2.5',
|
||||
'font-serif text-base leading-relaxed text-ink',
|
||||
@@ -259,8 +260,9 @@ onDestroy(() => {
|
||||
// placeholder CSS only fires when there is no content (not just on blur).
|
||||
$effect(() => {
|
||||
if (!editor || !placeholder) return;
|
||||
void value; // track value as dependency so this re-runs on content changes
|
||||
const inner = editorEl?.querySelector('.tiptap-editor-inner') as HTMLElement | null;
|
||||
void value; // Tiptap's onUpdate always fires on content change, but $effect needs a
|
||||
// reactive read to re-run — void value registers value as a dependency without using it.
|
||||
const inner = editorEl?.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||||
if (!inner) return;
|
||||
if (editor.isEmpty) {
|
||||
inner.setAttribute('data-placeholder', placeholder);
|
||||
|
||||
@@ -374,7 +374,7 @@ describe('PersonMentionEditor — placeholder behavior', () => {
|
||||
onChange: () => {}
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const inner = document.querySelector('.tiptap-editor-inner') as HTMLElement | null;
|
||||
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||||
expect(inner).not.toBeNull();
|
||||
expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...');
|
||||
});
|
||||
@@ -388,7 +388,7 @@ describe('PersonMentionEditor — placeholder behavior', () => {
|
||||
onChange: () => {}
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const inner = document.querySelector('.tiptap-editor-inner') as HTMLElement | null;
|
||||
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||||
expect(inner).not.toBeNull();
|
||||
expect(inner!.hasAttribute('data-placeholder')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -97,9 +97,11 @@ function currentViewport() {
|
||||
};
|
||||
}
|
||||
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let closeTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function scheduleCardClose() {
|
||||
// 150ms: long enough for pointer movement from mention to card, short enough
|
||||
// to feel responsive. Matches the Radix/shadcn hover card delay.
|
||||
closeTimer = setTimeout(() => {
|
||||
activeCard = null;
|
||||
closeTimer = null;
|
||||
@@ -152,7 +154,7 @@ async function handleMentionEnter(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleMentionLeave(event: Event) {
|
||||
function scheduleMentionLeave(event: Event) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
link.removeAttribute('aria-describedby');
|
||||
scheduleCardClose();
|
||||
@@ -195,7 +197,7 @@ function attachMentionHandlers(node: HTMLElement) {
|
||||
}
|
||||
function onLeave(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionLeave(e);
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) scheduleMentionLeave(e);
|
||||
}
|
||||
function onClick(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement;
|
||||
|
||||
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