feat(frontend): add scrollToCommentFromQuery helper for notification deep-link

Pure function that reads commentId + annotationId from the page URL,
enters transcribe mode if needed, activates the block's annotation,
scrolls the target comment into view, focuses it for screen readers,
fires the existing annotation flash, and strips the params via the
injected callback.

All side effects go through callbacks so the helper is unit-testable
without mounting the page or a DOM (only scrollIntoView/focus are
called on the injected element). Eight tests cover both absent params,
happy path, transcribe-mode activation, missing DOM target, reduced
motion, flash trigger, and URL strip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-21 13:29:52 +02:00
parent bc69e8ff1e
commit 251eb9c3fc
2 changed files with 169 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest';
import { scrollToCommentFromQuery, type DeepLinkScrollOptions } from './deepLinkScroll';
const COMMENT_ID = 'cccc1111-1111-1111-1111-111111111111';
const ANNOTATION_ID = 'aaaa2222-2222-2222-2222-222222222222';
function fakeElement() {
return {
scrollIntoView: vi.fn(),
focus: vi.fn()
} as unknown as HTMLElement;
}
type Overrides = Partial<DeepLinkScrollOptions>;
function buildOpts(overrides: Overrides = {}): DeepLinkScrollOptions {
const el = overrides.getElement ? null : fakeElement();
return {
transcribeMode: true,
setTranscribeMode: vi.fn(),
loadBlocks: vi.fn().mockResolvedValue(undefined),
setActiveAnnotationId: vi.fn(),
flashAnnotation: vi.fn(),
prefersReducedMotion: false,
afterTick: vi.fn().mockResolvedValue(undefined),
getElement: vi.fn().mockReturnValue(el),
onStripUrl: vi.fn(),
...overrides
};
}
describe('scrollToCommentFromQuery', () => {
it('is a no-op when commentId query param is absent', async () => {
const url = new URL('https://app/documents/doc-1');
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
expect(opts.getElement).not.toHaveBeenCalled();
expect(opts.onStripUrl).not.toHaveBeenCalled();
});
it('is a no-op when annotationId query param is absent even if commentId is present', async () => {
const url = new URL(`https://app/documents/doc-1?commentId=${COMMENT_ID}`);
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
expect(opts.getElement).not.toHaveBeenCalled();
});
it('scrolls to the comment element and focuses it when both params are present', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const el = fakeElement();
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
await scrollToCommentFromQuery(url, opts);
expect(opts.getElement).toHaveBeenCalledWith(`comment-${COMMENT_ID}`);
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
expect(el.focus).toHaveBeenCalledWith({ preventScroll: true });
});
it('triggers the annotation flash after scrolling', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const el = fakeElement();
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
await scrollToCommentFromQuery(url, opts);
expect(opts.flashAnnotation).toHaveBeenCalledWith(ANNOTATION_ID);
});
it('enters transcribe mode and awaits loadBlocks when transcribe mode is off', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts({ transcribeMode: false });
await scrollToCommentFromQuery(url, opts);
expect(opts.setTranscribeMode).toHaveBeenCalledWith(true);
expect(opts.loadBlocks).toHaveBeenCalled();
});
it('is a graceful no-op when the target element is not in the DOM', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(null) });
// Must not throw. Flash should not fire — nothing to highlight.
await expect(scrollToCommentFromQuery(url, opts)).resolves.toBeUndefined();
expect(opts.flashAnnotation).not.toHaveBeenCalled();
});
it('uses behavior "instant" when prefers-reduced-motion is set', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const el = fakeElement();
const opts = buildOpts({
prefersReducedMotion: true,
getElement: vi.fn().mockReturnValue(el)
});
await scrollToCommentFromQuery(url, opts);
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant', block: 'center' });
});
it('strips both commentId and annotationId from the URL after handling', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.onStripUrl).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,40 @@
export type DeepLinkScrollOptions = {
transcribeMode: boolean;
setTranscribeMode: (value: boolean) => void;
loadBlocks: () => Promise<void>;
setActiveAnnotationId: (id: string) => void;
flashAnnotation: (annotationId: string) => void;
prefersReducedMotion: boolean;
afterTick: () => Promise<void>;
getElement: (id: string) => HTMLElement | null;
onStripUrl: () => void;
};
export async function scrollToCommentFromQuery(
url: URL,
opts: DeepLinkScrollOptions
): Promise<void> {
const commentId = url.searchParams.get('commentId');
if (!commentId) return;
const annotationId = url.searchParams.get('annotationId');
if (!annotationId) return;
if (!opts.transcribeMode) {
opts.setTranscribeMode(true);
await opts.loadBlocks();
}
opts.setActiveAnnotationId(annotationId);
await opts.afterTick();
const el = opts.getElement(`comment-${commentId}`);
if (el) {
const behavior: ScrollBehavior = opts.prefersReducedMotion ? 'instant' : 'smooth';
el.scrollIntoView({ behavior, block: 'center' });
el.focus({ preventScroll: true });
opts.flashAnnotation(annotationId);
}
opts.onStripUrl();
}