fix(person-mention): respect modified-click and middle-click for new-tab nav

Felix #7: handleMentionClick unconditionally preventDefault'd and goto'd,
breaking ctrl-click / cmd-click / shift-click / alt-click / middle-click —
"open in new tab" is a real workflow for researchers comparing two persons.

Add isPlainPrimaryClick() guard. Modified clicks fall through to the
browser's default anchor handling (the <a href="/persons/{id}"> opens in
the new tab as expected). Plain left-clicks still SPA-navigate via goto().

Three new tests assert ctrl-click, meta-click, and middle-click are not
preventDefault'd.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 08:56:23 +02:00
parent 5890bb3abd
commit 3faac13533
2 changed files with 43 additions and 0 deletions

View File

@@ -143,7 +143,18 @@ function handleMentionLeave(event: Event) {
activeCard = null;
}
/**
* Modified clicks (ctrl/meta/shift/alt) and middle-clicks must fall through to
* the browser's default anchor behaviour so users can open the person page in
* a new tab/window. Felix #7. Only the plain primary-button click navigates
* via SPA goto().
*/
function isPlainPrimaryClick(event: MouseEvent): boolean {
return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
}
async function handleMentionClick(event: MouseEvent) {
if (!isPlainPrimaryClick(event)) return;
const link = event.target as HTMLAnchorElement;
const personId = link.dataset.personId;
if (!personId) return;

View File

@@ -370,6 +370,38 @@ describe('TranscriptionReadView — person-mention rendering', () => {
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],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
// ctrl-click (Linux/Win "open in new tab")
const ctrlClick = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true });
const ctrlPrevented = !link.dispatchEvent(ctrlClick);
expect(ctrlPrevented).toBe(false);
// meta-click (macOS "open in new tab")
const metaClick = new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true });
const metaPrevented = !link.dispatchEvent(metaClick);
expect(metaPrevented).toBe(false);
});
it('lets middle-click fall through so users can open in a background tab', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
// button === 1 is middle mouse button
const middleClick = new MouseEvent('click', { bubbles: true, cancelable: true, button: 1 });
const prevented = !link.dispatchEvent(middleClick);
expect(prevented).toBe(false);
});
it('degrades to plain unlinked text when the person fetch returns 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));