Merge pull request #34936 from overleaf/jel-scroll-to-notification

[web] Scroll to new notification added above domains table

GitOrigin-RevId: f62851398de3a54ef07c0e125c65a29dd84af6f8
This commit is contained in:
Jessica Lawshe
2026-09-17 11:33:41 +00:00
committed by Copybot
parent f950d8ffde
commit 66671f9bf2
4 changed files with 113 additions and 20 deletions
@@ -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(() => {
@@ -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
@@ -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
}
@@ -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
})
})