fix(person-mention): hover card mounts on focusin for keyboard users (WCAG 2.1.1)

Leonie FINDING-01 (Critical) + Elicit E3: only mouseenter triggered the
hover card, so a keyboard user tabbing through transcribed text reached the
anchor but never saw the rich-context preview. For the senior audience
constraint that's a hard regression.

Wire focusin/focusout alongside mouseenter/mouseleave on the delegated
listener. Same handleMentionEnter/Leave run — getBoundingClientRect works
identically on focused elements. focusin/focusout bubble naturally so no
capture phase needed.

Two new tests assert focusin mounts the card and focusout unmounts it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 08:57:48 +02:00
parent 3faac13533
commit 3365f5845e
2 changed files with 65 additions and 0 deletions

View File

@@ -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);
}
};

View File

@@ -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],