Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/safari-scroll-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

useScrollAnchor: Preserve an element's viewport position as its container content changes.
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
"useResponsiveValue",
"useRovingTabIndex",
"useSafeTimeout",
"useScrollAnchor",
"UseScrollAnchorSettings",
"useSlots",
"useSyncedState",
"useTheme",
Expand Down
107 changes: 107 additions & 0 deletions packages/react/src/hooks/__tests__/useScrollAnchor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {act, render, waitFor} from '@testing-library/react'
import {useRef} from 'react'
import {expect, it, vi} from 'vitest'
import {useScrollAnchor} from '../../hooks/useScrollAnchor'

function ScrollRegion({getAnchorTop, anchorVersion = 'initial'}: {getAnchorTop: () => number; anchorVersion?: string}) {
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement | null>(null)

useScrollAnchor({containerRef, anchorRef})

return (
<div ref={containerRef}>
<div
key={anchorVersion}
ref={node => {
anchorRef.current = node
if (node) {
vi.spyOn(node, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, getAnchorTop(), 100, 20))
}
}}
data-testid="anchor"
/>
</div>
)
}

it('preserves the anchor position when content changes above it', async () => {
const anchor = {top: 300}
const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation((_x, y) => {
anchor.top -= y
})

const {container} = render(<ScrollRegion getAnchorTop={() => anchor.top} />)

await act(async () => {
container.firstElementChild?.prepend(document.createElement('div'))
anchor.top += 48
})

await waitFor(() => {
expect(scrollBy).toHaveBeenCalledWith(0, 48)
})
expect(anchor.top).toBe(300)

scrollBy.mockRestore()
})

it('uses the latest anchor position after the user scrolls', async () => {
const anchor = {top: 300}
const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation((_x, y) => {
anchor.top -= y
})

const {container} = render(<ScrollRegion getAnchorTop={() => anchor.top} />)

anchor.top = 240
act(() => {
window.dispatchEvent(new Event('scroll'))
})

await act(async () => {
container.firstElementChild?.prepend(document.createElement('div'))
anchor.top += 20
})

await waitFor(() => {
expect(scrollBy).toHaveBeenCalledWith(0, 20)
})
expect(anchor.top).toBe(240)

scrollBy.mockRestore()
})

it('does not adjust scroll when the anchor position is unchanged', async () => {
const scrollBy = vi.spyOn(window, 'scrollBy')
const {container} = render(<ScrollRegion getAnchorTop={() => 0} />)

await act(async () => {
container.firstElementChild?.prepend(document.createElement('div'))
})

await waitFor(() => {
expect(container.firstElementChild?.children).toHaveLength(2)
})
expect(scrollBy).not.toHaveBeenCalled()

scrollBy.mockRestore()
})

it('preserves the position when the anchor element is replaced', async () => {
const anchor = {top: 300}
const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation((_x, y) => {
anchor.top -= y
})

const {rerender} = render(<ScrollRegion getAnchorTop={() => anchor.top} />)
anchor.top += 32
rerender(<ScrollRegion getAnchorTop={() => anchor.top} anchorVersion="updated" />)

await waitFor(() => {
expect(scrollBy).toHaveBeenCalledWith(0, 32)
})
expect(anchor.top).toBe(300)

scrollBy.mockRestore()
})
2 changes: 2 additions & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef'
export {useId} from './useId'
export {useIsMacOS} from './useIsMacOS'
export {useMergedRefs} from './useMergedRefs'
export {useScrollAnchor} from './useScrollAnchor'
export type {UseScrollAnchorSettings} from './useScrollAnchor'
export {useSlots} from './useSlots'
22 changes: 22 additions & 0 deletions packages/react/src/hooks/useScrollAnchor.hookDocs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "useScrollAnchor",
"importPath": "@primer/react",
"stories": [{"id": "hooks-usescrollanchor--scroll-anchor"}],
"parameters": [
{
"name": "containerRef",
"type": "RefObject<HTMLElement | null>",
"required": true,
"description": "A ref to the container whose DOM changes should be observed."
},
{
"name": "anchorRef",
"type": "RefObject<HTMLElement | null>",
"required": true,
"description": "A ref to the visible element whose viewport position should be preserved. Only window scrolling is adjusted."
}
],
"returns": {
"type": "void"
}
}
63 changes: 63 additions & 0 deletions packages/react/src/hooks/useScrollAnchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type {RefObject} from 'react'
import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'

export interface UseScrollAnchorSettings {
containerRef: RefObject<HTMLElement | null>
anchorRef: RefObject<HTMLElement | null>
}

/**
* Preserves the viewport position of an anchor when content in its container changes.
* The anchor should be a visible element, and this hook only adjusts the window scroll position.
*
* @param containerRef A ref to the container whose DOM changes should be observed.
* @param anchorRef A ref to the visible element whose viewport position should be preserved.
*/
export function useScrollAnchor({containerRef, anchorRef}: UseScrollAnchorSettings): void {
useIsomorphicLayoutEffect(() => {
const container = containerRef.current
const initialAnchor = anchorRef.current

if (!container || !initialAnchor || !container.contains(initialAnchor)) {
return
}

let anchorTop = initialAnchor.getBoundingClientRect().top

const updateAnchorTop = () => {
const anchor = anchorRef.current
if (anchor && container.contains(anchor)) {
anchorTop = anchor.getBoundingClientRect().top
}
}

const observer = new MutationObserver(() => {
const anchor = anchorRef.current
if (!anchor || !container.contains(anchor)) {
return
}

const currentTop = anchor.getBoundingClientRect().top
const offset = currentTop - anchorTop

if (offset !== 0) {
window.scrollBy(0, offset)
anchorTop = currentTop - offset
}
})

observer.observe(container, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
})
// eslint-disable-next-line github/prefer-observers -- Track every scroll position to preserve the anchor's current viewport position.
window.addEventListener('scroll', updateAnchorTop)

return () => {
observer.disconnect()
window.removeEventListener('scroll', updateAnchorTop)
}
}, [anchorRef, containerRef])
}
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type {FocusZoneHookSettings} from './hooks/useFocusZone'
export {useRefObjectAsForwardedRef} from './hooks/useRefObjectAsForwardedRef'
export {useMergedRefs} from './hooks/useMergedRefs'
export {useResizeObserver} from './hooks/useResizeObserver'
export {useScrollAnchor, type UseScrollAnchorSettings} from './hooks/useScrollAnchor'
export {useResponsiveValue, type ResponsiveValue} from './hooks/useResponsiveValue'
export {default as useIsomorphicLayoutEffect} from './utils/useIsomorphicLayoutEffect'
export {useProvidedRefOrCreate} from './hooks/useProvidedRefOrCreate'
Expand Down
45 changes: 45 additions & 0 deletions packages/react/src/stories/ScrollAnchor.stories.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
.Root {
padding: var(--base-size-16);
color: var(--fgColor-default);
}

.Instructions {
margin: 0 0 var(--base-size-16);
}

.Controls {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: var(--base-size-8);
padding: var(--base-size-8);
border: var(--borderWidth-thin) solid var(--borderColor-default);
border-radius: var(--borderRadius-medium);
background: var(--bgColor-default);
}

.Status {
margin-left: auto;
}

.Timeline {
margin-top: var(--base-size-16);
border: var(--borderWidth-thin) solid var(--borderColor-default);
border-radius: var(--borderRadius-medium);
}

.Item {
min-height: 5rem;
padding: var(--base-size-16);
border-bottom: var(--borderWidth-thin) solid var(--borderColor-muted);
}

.Item:last-child {
border-bottom: 0;
}

.Anchor {
background: var(--bgColor-attention-muted);
}
64 changes: 64 additions & 0 deletions packages/react/src/stories/useScrollAnchor.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, {useRef, useState} from 'react'
import type {Meta} from '@storybook/react-vite'
import {Button} from '../Button'
import {useScrollAnchor} from '../hooks/useScrollAnchor'
import classes from './ScrollAnchor.stories.module.css'

export default {
title: 'Hooks/useScrollAnchor',
} as Meta

export const ScrollAnchor = () => {
const [enabled, setEnabled] = useState(true)
const [loadedPages, setLoadedPages] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const disabledAnchorRef = useRef<HTMLDivElement>(null)

useScrollAnchor({containerRef, anchorRef: enabled ? anchorRef : disabledAnchorRef})

const olderComments = Array.from({length: loadedPages * 3}, (_, index) => `Earlier comment ${index + 1}`)

return (
<>
<style>{`html { overflow-anchor: none !important; }`}</style>
<div className={classes.Root}>
<p className={classes.Instructions}>
Scroll until the highlighted comment is in view, then load earlier comments. Toggle the hook to compare how
the comment moves. Native browser scroll anchoring is disabled in this story so the hook&apos;s effect is
visible in every browser.
</p>
<p className={classes.Instructions}>
<code>{'useScrollAnchor({containerRef, anchorRef})'}</code> watches the timeline container and preserves the
highlighted comment&apos;s viewport position. It adjusts window scrolling only.
</p>
<div className={classes.Controls}>
<Button onClick={() => setLoadedPages(page => page + 1)}>Load earlier comments</Button>
<Button onClick={() => setEnabled(value => !value)}>{enabled ? 'Disable' : 'Enable'} scroll anchor</Button>
<span className={classes.Status}>Hook is {enabled ? 'on' : 'off'}</span>
</div>
<div className={classes.Timeline} ref={containerRef}>
{olderComments.map(comment => (
<div className={classes.Item} key={comment}>
{comment}
</div>
))}
{['Comment 1', 'Comment 2', 'Comment 3'].map(comment => (
<div className={classes.Item} key={comment}>
{comment}
</div>
))}
<div className={`${classes.Item} ${classes.Anchor}`} ref={anchorRef}>
<strong>You&apos;re reading this comment</strong>
<p>Keep this comment at the same viewport position while content is added above it.</p>
</div>
{Array.from({length: 12}, (_, index) => (
<div className={classes.Item} key={`later-comment-${index + 1}`}>
Comment {index + 4}
</div>
))}
</div>
</div>
</>
)
}
Loading