Skip to content
Open
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/anchored-position-scroll-recalculation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

useAnchoredPosition: recalculate overlay position when any scrollable ancestor (or the window) is scrolled.
124 changes: 123 additions & 1 deletion packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React, {useState} from 'react'
import {Button} from '../Button'
import {AnchoredOverlay} from '.'
import {Stack} from '../Stack'
import {Dialog, Spinner} from '..'
import {Dialog, Spinner, Text} from '..'

const meta = {
title: 'Components/AnchoredOverlay/Dev',
Expand Down Expand Up @@ -59,6 +59,128 @@ export const RepositionAfterContentGrows = () => {
)
}

export const ScrollRecalculation = () => {
const [open, setOpen] = useState(false)

return (
<div>
<Stack gap="normal" style={{padding: '16px', marginBottom: '16px'}}>
<Text as="p" style={{fontWeight: 'bold'}}>
How to test (scrollable container):
</Text>
<Text as="p">
1. Scroll down inside the bordered box until you see the &quot;Open overlay&quot; button
<br />
2. Click the button to open the overlay
<br />
3. Scroll inside the box again
<br />
4. <strong>Expected:</strong> the overlay stays visually attached to the button
<br />
5. <strong>Bug (without fix):</strong> the overlay stays at its initial absolute position and detaches from
the button
</Text>
</Stack>

<div
style={{
height: '400px',
overflow: 'auto',
border: '1px solid var(--borderColor-default, #d1d9e0)',
borderRadius: 'var(--borderRadius-medium)',
position: 'relative',
}}
>
{/* Spacer to push button below the fold */}
<div style={{height: '600px', padding: '16px'}}>
<Text as="p" style={{color: 'var(--fgColor-muted)'}}>
↓ Scroll down to find the trigger button
</Text>
</div>

<div style={{padding: '16px'}}>
<AnchoredOverlay
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
renderAnchor={props => <Button {...props}>Open overlay</Button>}
overlayProps={{
role: 'dialog',
'aria-modal': true,
'aria-label': 'Scroll recalculation demo',
}}
focusZoneSettings={{disabled: true}}
preventOverflow={false}
>
<div style={{padding: '16px', width: '240px'}}>
<Text as="p">This overlay should stay attached to the button as you scroll.</Text>
</div>
</AnchoredOverlay>
</div>

{/* Spacer below the button so there's room to scroll further */}
<div style={{height: '800px'}} />
</div>
</div>
)
}

export const WindowScrollRecalculation = () => {
const [open, setOpen] = useState(false)

return (
<div>
<Stack gap="normal" style={{padding: '16px'}}>
<Text as="p" style={{fontWeight: 'bold'}}>
How to test (window scroll):
</Text>
<Text as="p">
1. Scroll down the page until you see the &quot;Open overlay&quot; button
<br />
2. Click the button to open the overlay
<br />
3. Scroll the page again
<br />
4. <strong>Expected:</strong> the overlay stays visually attached to the button
<br />
5. <strong>Bug (without fix):</strong> the overlay stays at its initial absolute position and detaches from
the button
</Text>
</Stack>

{/* Spacer to push button below the fold */}
<div style={{height: '120vh', padding: '16px'}}>
<Text as="p" style={{color: 'var(--fgColor-muted)'}}>
↓ Scroll down to find the trigger button
</Text>
</div>

<div style={{padding: '16px'}}>
<AnchoredOverlay
open={open}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
renderAnchor={props => <Button {...props}>Open overlay</Button>}
overlayProps={{
role: 'dialog',
'aria-modal': true,
'aria-label': 'Window scroll recalculation demo',
}}
focusZoneSettings={{disabled: true}}
preventOverflow={false}
>
<div style={{padding: '16px', width: '240px'}}>
<Text as="p">This overlay should stay attached to the button as you scroll the page.</Text>
</div>
</AnchoredOverlay>
</div>

{/* Spacer below the button so there's room to scroll further */}
<div style={{height: '120vh'}} />
</div>
)
}

export const RepositionAfterContentGrowsWithinDialog = () => {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(true)
Expand Down
75 changes: 73 additions & 2 deletions packages/react/src/hooks/__tests__/useAnchoredPosition.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {render, waitFor} from '@testing-library/react'
import {it, expect, vi} from 'vitest'
import {render, waitFor, act} from '@testing-library/react'
import {it, expect, vi, describe} from 'vitest'
import React from 'react'
import {useAnchoredPosition} from '../../hooks/useAnchoredPosition'

Expand Down Expand Up @@ -34,3 +34,74 @@ it('should should return a position', async () => {
`)
})
})

describe('scroll recalculation', () => {
it('should recalculate position when window scrolls', async () => {
const cb = vi.fn()
render(<Component callback={cb} />)

// Wait for initial position calculation to stabilise
await waitFor(() => {
expect(cb).toHaveBeenCalledTimes(2)
})

// Wait a tick to let any pending effects settle
await new Promise(resolve => setTimeout(resolve, 50))
const callCountBefore = cb.mock.calls.length

// Simulate a window scroll event
act(() => {
window.dispatchEvent(new Event('scroll'))
})

// Wait for rAF-throttled handler to fire and trigger re-render
await new Promise(resolve => setTimeout(resolve, 50))

expect(cb.mock.calls.length).toBeGreaterThan(callCountBefore)
Comment on lines +48 to +60
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests rely on real timers (setTimeout(50)) to wait for the rAF-throttled scroll handler, which makes the suite slower and can be flaky under load. Prefer using fake timers (e.g., vi.useFakeTimers() + advancing timers) or waiting on requestAnimationFrame deterministically (and/or waitFor) so the assertion doesn't depend on an arbitrary delay.

This issue also appears on line 92 of the same file.

Copilot uses AI. Check for mistakes.
})

it('should recalculate position when a scrollable ancestor scrolls', async () => {
const ScrollableComponent = ({
callback,
}: {
callback: (hookReturnValue: ReturnType<typeof useAnchoredPosition>) => void
}) => {
const floatingElementRef = React.useRef<HTMLDivElement>(null)
const anchorElementRef = React.useRef<HTMLDivElement>(null)
callback(useAnchoredPosition({floatingElementRef, anchorElementRef}))
return (
<div style={{overflow: 'auto', height: '200px'}}>
<div style={{height: '1000px'}}>
<div
style={{position: 'absolute', top: '20px', left: '20px', height: '50px', width: '50px'}}
ref={floatingElementRef}
/>
<div ref={anchorElementRef} />
</div>
</div>
)
}

const cb = vi.fn()
const {container} = render(<ScrollableComponent callback={cb} />)

await waitFor(() => {
expect(cb).toHaveBeenCalledTimes(2)
})

// Wait a tick to let any pending effects settle
await new Promise(resolve => setTimeout(resolve, 50))
const callCountBefore = cb.mock.calls.length
const scrollContainer = container.firstElementChild!

// Simulate scroll on the scrollable ancestor
act(() => {
scrollContainer.dispatchEvent(new Event('scroll'))
})

// Wait for rAF-throttled handler to fire and trigger re-render
await new Promise(resolve => setTimeout(resolve, 50))

expect(cb.mock.calls.length).toBeGreaterThan(callCountBefore)
})
})
52 changes: 52 additions & 0 deletions packages/react/src/hooks/useAnchoredPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ import {useProvidedRefOrCreate} from './useProvidedRefOrCreate'
import {useResizeObserver} from './useResizeObserver'
import useLayoutEffect from '../utils/useIsomorphicLayoutEffect'

/**
* Returns all scrollable ancestor elements of the given element, plus the window.
* An element is scrollable if its computed overflow/overflow-x/overflow-y is
* 'auto', 'scroll', or 'overlay'.
*/
function getScrollableAncestors(element: Element): Array<Element | Window> {
const scrollables: Array<Element | Window> = []
let current = element.parentElement
while (current) {
const style = getComputedStyle(current)
const overflowY = style.overflowY
const overflowX = style.overflowX
if (/auto|scroll|overlay/.test(overflowY) || /auto|scroll|overlay/.test(overflowX)) {
scrollables.push(current)
}
current = current.parentElement
}
scrollables.push(window)
return scrollables
}
Comment on lines +13 to +27
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to run when the AnchoredOverlay is closed? I'm wondering if there are any performance implications, but I doubt it, as nothing here stands out as being slow.


export interface AnchoredPositionHookSettings extends Partial<PositionSettings> {
floatingElementRef?: React.RefObject<Element | null>
anchorElementRef?: React.RefObject<Element | null>
Expand Down Expand Up @@ -100,6 +121,37 @@ export function useAnchoredPosition(
useResizeObserver(updatePosition) // watches for changes in window size
useResizeObserver(updatePosition, floatingElementRef as React.RefObject<HTMLElement | null>) // watches for changes in floating element size

// Recalculate position when any scrollable ancestor of the anchor scrolls.
// Uses requestAnimationFrame to avoid layout thrashing during scroll.
React.useEffect(() => {
const anchorEl = anchorElementRef.current
if (!anchorEl) return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Random question - could we only run this when the overlay is active? I'm assuming no, but wanted to ask 😁

Same goes for the scroll event; if we couldn't return early here if the overlay active, would we only want to add the scroll event if the overlay is open?


Comment on lines +126 to +129
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scroll-listener effect bails out when anchorElementRef.current is null, but the effect only depends on the ref object and updatePosition. If the anchor element is conditionally rendered later (so current becomes non-null without changing the ref object), this effect will not re-run and scroll listeners will never be attached. Consider keying the effect off the resolved anchorEl value (e.g., compute const anchorEl = anchorElementRef.current during render and include that in the deps) so listeners are attached/updated when the anchor element appears or changes.

Copilot uses AI. Check for mistakes.
let rafId: number | null = null
const handleScroll = () => {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
updatePosition()
})
}

const scrollables = getScrollableAncestors(anchorEl)
for (const scrollable of scrollables) {
// eslint-disable-next-line github/prefer-observers -- IntersectionObserver cannot detect continuous scroll position changes needed for repositioning
scrollable.addEventListener('scroll', handleScroll)
}
Comment on lines +139 to +143
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions scroll listeners are passive, but the implementation adds them without an options object. Either update the PR description or pass a consistent options object (and use the same options for removal) so the code matches the stated behavior.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to utilize passive here, or are we okay with our usage of requestAnimationFrame?


return () => {
for (const scrollable of scrollables) {
scrollable.removeEventListener('scroll', handleScroll)
}
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
}
}, [anchorElementRef, updatePosition])

return {
floatingElementRef,
anchorElementRef,
Expand Down
Loading