feat(person-mention): PR-B2 — read-mode rendering + hover card (issue #362) #371
@@ -169,6 +169,10 @@ async function handleMentionClick(event: MouseEvent) {
|
|||||||
// Attach delegated event listeners on each rendered block. Using {@html ...}
|
// Attach delegated event listeners on each rendered block. Using {@html ...}
|
||||||
// for the body means we cannot bind events declaratively to the injected
|
// 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.
|
// 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 attachMentionHandlers(node: HTMLElement) {
|
||||||
function onEnter(e: Event) {
|
function onEnter(e: Event) {
|
||||||
const t = e.target as HTMLElement;
|
const t = e.target as HTMLElement;
|
||||||
@@ -185,12 +189,17 @@ function attachMentionHandlers(node: HTMLElement) {
|
|||||||
// mouseenter does not bubble — capture it.
|
// mouseenter does not bubble — capture it.
|
||||||
node.addEventListener('mouseenter', onEnter, true);
|
node.addEventListener('mouseenter', onEnter, true);
|
||||||
node.addEventListener('mouseleave', onLeave, 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);
|
node.addEventListener('click', onClick);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener('mouseenter', onEnter, true);
|
node.removeEventListener('mouseenter', onEnter, true);
|
||||||
node.removeEventListener('mouseleave', onLeave, true);
|
node.removeEventListener('mouseleave', onLeave, true);
|
||||||
|
node.removeEventListener('focusin', onEnter);
|
||||||
|
node.removeEventListener('focusout', onLeave);
|
||||||
node.removeEventListener('click', onClick);
|
node.removeEventListener('click', onClick);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -370,6 +370,62 @@ describe('TranscriptionReadView — person-mention rendering', () => {
|
|||||||
expect(card).toBeNull();
|
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 () => {
|
it('lets ctrl-click and meta-click fall through so users can open in a new tab', async () => {
|
||||||
render(TranscriptionReadView, {
|
render(TranscriptionReadView, {
|
||||||
blocks: [mentionBlock],
|
blocks: [mentionBlock],
|
||||||
|
|||||||
Reference in New Issue
Block a user