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:
129
frontend/src/lib/utils/deepLinkScroll.spec.ts
Normal file
129
frontend/src/lib/utils/deepLinkScroll.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/utils/deepLinkScroll.ts
Normal file
40
frontend/src/lib/utils/deepLinkScroll.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user