diff --git a/services/web/frontend/js/shared/components/notification-scrolled-to.tsx b/services/web/frontend/js/shared/components/notification-scrolled-to.tsx index e2b909b917..b9d4f960d6 100644 --- a/services/web/frontend/js/shared/components/notification-scrolled-to.tsx +++ b/services/web/frontend/js/shared/components/notification-scrolled-to.tsx @@ -2,26 +2,7 @@ import Notification, { NotificationProps, } from '@/shared/components/notification' import { useEffect } from 'react' - -function elementIsInView(el: HTMLElement) { - const scroll = window.scrollY - const boundsTop = el.getBoundingClientRect().top + scroll - - const viewport = { - top: scroll, - bottom: scroll + window.innerHeight, - } - - const bounds = { - top: boundsTop, - bottom: boundsTop + el.clientHeight, - } - - return ( - (bounds.bottom >= viewport.top && bounds.bottom <= viewport.bottom) || - (bounds.top <= viewport.bottom && bounds.top >= viewport.top) - ) -} +import { elementIsInView } from '@/shared/utils/element-in-view' function NotificationScrolledTo({ ...props }: NotificationProps) { useEffect(() => { diff --git a/services/web/frontend/js/shared/hooks/use-scroll-into-view.ts b/services/web/frontend/js/shared/hooks/use-scroll-into-view.ts new file mode 100644 index 0000000000..a0c4aa87d9 --- /dev/null +++ b/services/web/frontend/js/shared/hooks/use-scroll-into-view.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from 'react' +import { elementIsInView } from '@/shared/utils/element-in-view' + +type UseScrollIntoViewProps = { + id: string | null +} + +function useScrollIntoView({ id }: UseScrollIntoViewProps) { + const hasMountedRef = useRef(false) + + useEffect(() => { + if (!hasMountedRef.current) { + hasMountedRef.current = true + return + } + + if (!id) { + return + } + + const targetElement = document.getElementById(id) + if (targetElement && !elementIsInView(targetElement)) { + targetElement.scrollIntoView({ behavior: 'smooth' }) + } + }, [id]) +} + +export default useScrollIntoView diff --git a/services/web/frontend/js/shared/utils/element-in-view.ts b/services/web/frontend/js/shared/utils/element-in-view.ts new file mode 100644 index 0000000000..e10e16fd0d --- /dev/null +++ b/services/web/frontend/js/shared/utils/element-in-view.ts @@ -0,0 +1,5 @@ +export function elementIsInView(el: HTMLElement): boolean { + const rect = el.getBoundingClientRect() + // Any vertical intersection with the viewport counts as "in view". + return rect.bottom > 0 && rect.top < window.innerHeight +} diff --git a/services/web/test/frontend/shared/hooks/use-scroll-into-view.test.tsx b/services/web/test/frontend/shared/hooks/use-scroll-into-view.test.tsx new file mode 100644 index 0000000000..ca67c89355 --- /dev/null +++ b/services/web/test/frontend/shared/hooks/use-scroll-into-view.test.tsx @@ -0,0 +1,79 @@ +import { renderHook } from '@testing-library/react' +import { expect } from 'chai' +import sinon from 'sinon' +import useScrollIntoView from '../../../../frontend/js/shared/hooks/use-scroll-into-view' + +describe('useScrollIntoView', function () { + function createTargetElement({ id, top }: { id: string; top: number }) { + const element = document.createElement('div') + element.id = id + Object.defineProperty(element, 'clientHeight', { + configurable: true, + value: 20, + }) + sinon.stub(element, 'getBoundingClientRect').returns({ + top, + bottom: top + 20, + } as DOMRect) + document.body.appendChild(element) + return element + } + + afterEach(function () { + sinon.restore() + document.body.innerHTML = '' + }) + + it('does not scroll on the initial render', function () { + createTargetElement({ id: 'target-1', top: 10000 }) + const scrollIntoViewStub = sinon.stub( + globalThis.HTMLElement.prototype, + 'scrollIntoView' + ) + + renderHook(() => useScrollIntoView({ id: 'target-1' })) + + expect(scrollIntoViewStub.called).to.be.false + }) + + it('scrolls when id changes and the target is out of view', function () { + createTargetElement({ id: 'target-1', top: 10000 }) + createTargetElement({ id: 'target-2', top: 10000 }) + const scrollIntoViewStub = sinon.stub( + globalThis.HTMLElement.prototype, + 'scrollIntoView' + ) + + const { rerender } = renderHook( + ({ id }: { id: string }) => useScrollIntoView({ id }), + { + initialProps: { id: 'target-1' }, + } + ) + + rerender({ id: 'target-2' }) + + expect(scrollIntoViewStub.calledOnceWithExactly({ behavior: 'smooth' })).to + .be.true + }) + + it('does not scroll when id changes and the target is already in view', function () { + createTargetElement({ id: 'target-1', top: 10000 }) + createTargetElement({ id: 'target-2', top: 10 }) + const scrollIntoViewStub = sinon.stub( + globalThis.HTMLElement.prototype, + 'scrollIntoView' + ) + + const { rerender } = renderHook( + ({ id }: { id: string }) => useScrollIntoView({ id }), + { + initialProps: { id: 'target-1' }, + } + ) + + rerender({ id: 'target-2' }) + + expect(scrollIntoViewStub.called).to.be.false + }) +})