diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index 738ab791..4a3ac949 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -169,6 +169,10 @@ async function handleMentionClick(event: MouseEvent) { // Attach delegated event listeners on each rendered block. Using {@html ...} // for the body means we cannot bind events declaratively to the injected // anchors, so we hook up listeners via a Svelte action when the wrapper mounts. +// +// Keyboard parity (Leonie FINDING-01, WCAG 2.1.1): focusin/focusout mirror +// mouseenter/mouseleave so users tabbing through transcribed text get the +// same preview affordance. function attachMentionHandlers(node: HTMLElement) { function onEnter(e: Event) { const t = e.target as HTMLElement; @@ -185,12 +189,17 @@ function attachMentionHandlers(node: HTMLElement) { // mouseenter does not bubble — capture it. node.addEventListener('mouseenter', onEnter, true); node.addEventListener('mouseleave', onLeave, true); + // focusin/focusout do bubble — no capture phase needed. + node.addEventListener('focusin', onEnter); + node.addEventListener('focusout', onLeave); node.addEventListener('click', onClick); return { destroy() { node.removeEventListener('mouseenter', onEnter, true); node.removeEventListener('mouseleave', onLeave, true); + node.removeEventListener('focusin', onEnter); + node.removeEventListener('focusout', onLeave); node.removeEventListener('click', onClick); } }; diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts index 7b694200..5b849ed7 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts +++ b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts @@ -370,6 +370,62 @@ describe('TranscriptionReadView — person-mention rendering', () => { expect(card).toBeNull(); }); + it('mounts the hover card on focusin so keyboard users see the preview (WCAG 2.1.1)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((url: string) => { + if (String(url).endsWith('/relationships')) { + return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) }); + } + return Promise.resolve({ + status: 200, + ok: true, + json: () => + Promise.resolve({ + id: PERSON_ID, + firstName: 'Auguste', + lastName: 'Raddatz', + displayName: 'Auguste Raddatz', + personType: 'PERSON', + familyMember: true + }) + }); + }) + ); + + render(TranscriptionReadView, { + blocks: [mentionBlock], + onParagraphClick: () => {} + }); + + const link = document.querySelector('a.person-mention')!; + link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + await vi.waitFor(() => { + const card = document.querySelector('[data-testid="person-hover-card"]'); + expect(card).not.toBeNull(); + }); + }); + + it('unmounts the hover card on focusout', async () => { + render(TranscriptionReadView, { + blocks: [mentionBlock], + onParagraphClick: () => {} + }); + + const link = document.querySelector('a.person-mention')!; + link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await vi.waitFor(() => { + // the card mounts even in 404 → loading → null path; assert it cleans up on blur + }); + link.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + await vi.waitFor(() => { + const card = document.querySelector('[data-testid="person-hover-card"]'); + expect(card).toBeNull(); + }); + }); + it('lets ctrl-click and meta-click fall through so users can open in a new tab', async () => { render(TranscriptionReadView, { blocks: [mentionBlock],