Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c0d75b6
Make popover in `AnchoredOverlay` opt-in
TylerJDev Apr 17, 2026
aa8b979
Merge branch 'main' into tylerjdev/make-popover-opt-in
TylerJDev Apr 17, 2026
f68e82b
Some clean up
TylerJDev Apr 17, 2026
6770095
Add changeset
TylerJDev Apr 17, 2026
d9716f0
Update packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
TylerJDev Apr 20, 2026
f7596e9
Change prop name; add test
TylerJDev Apr 20, 2026
c24827c
Ensure CSS anchor position persists on remount
TylerJDev Apr 23, 2026
dd2d1e8
Merge branch 'main' into tylerjdev/ensure-css-anchor-positioning-sticks
TylerJDev Apr 23, 2026
3ad5e6c
Add back `shouldRenderAsPopover`
TylerJDev Apr 23, 2026
4f8a012
Add back `currentOverlay`
TylerJDev Apr 23, 2026
f44bdc5
Fix lint issue
TylerJDev Apr 24, 2026
2199b7f
Add changeset
TylerJDev Apr 24, 2026
17da3bc
Fix more lint
TylerJDev Apr 24, 2026
c9c5562
Fix even more lint
TylerJDev Apr 24, 2026
58058e2
Some perf improvements
TylerJDev Apr 27, 2026
570e6bd
Disable `useResizeObserver` usage when flag is on
TylerJDev Apr 29, 2026
018006e
More cleanup
TylerJDev Apr 29, 2026
de5ae7f
Update requestAnimationFrame
TylerJDev Apr 29, 2026
7e0009b
Fix test
TylerJDev Apr 29, 2026
fab9385
Add fix for overflow
TylerJDev May 1, 2026
721ed09
Account for `outside-bottom`
TylerJDev May 1, 2026
8e4ac41
Fix bug where anchor and overlay could disconnect
TylerJDev May 1, 2026
f75ae3e
Add for height
TylerJDev May 4, 2026
5d58705
Ensure overlay is within viewport
TylerJDev May 4, 2026
24ce7d7
Add to dev stories
TylerJDev May 4, 2026
7065a9e
Merge branch 'main' into tylerjdev/fix-css-anchor-position-overflow
TylerJDev May 4, 2026
55e29e9
Fix merge
TylerJDev May 4, 2026
2f9bd65
Remove height logic
TylerJDev May 4, 2026
ffbc3f2
Some cleanup
TylerJDev May 4, 2026
ef40b1d
Add changeset
TylerJDev May 4, 2026
00afccf
Add test
TylerJDev May 5, 2026
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/social-deer-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

AnchoredOverlay: Ensure overlay fits within viewport by calculating viewport height + width (behind `primer_react_css_anchor_positioning` feature flag)
58 changes: 58 additions & 0 deletions packages/react/src/ActionMenu/ActionMenu.dev.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,61 @@ export const AnchorElementReplacement = () => {
</div>
)
}

export const RightAlignedWithLargeSubmenus = () => {
const submenuItems = Array.from({length: 30}, (_, i) => `Item ${i + 1}`)

return (
<div style={{position: 'fixed', top: '15px', right: '15px'}}>
<ActionMenu>
<ActionMenu.Button>Open menu</ActionMenu.Button>
<ActionMenu.Overlay align="end">
<ActionList>
<ActionMenu>
<ActionMenu.Anchor>
<ActionList.Item>First submenu (outside bottom)</ActionList.Item>
</ActionMenu.Anchor>
<ActionMenu.Overlay side="outside-bottom">
<ActionList>
{submenuItems.map(item => (
<ActionList.Item key={item} onSelect={() => alert(`${item} clicked`)}>
{item}
</ActionList.Item>
))}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
<ActionMenu>
<ActionMenu.Anchor>
<ActionList.Item>Second submenu (outside left)</ActionList.Item>
</ActionMenu.Anchor>
<ActionMenu.Overlay side="outside-left">
<ActionList>
{submenuItems.map(item => (
<ActionList.Item key={item} onSelect={() => alert(`${item} clicked`)}>
{item}
</ActionList.Item>
))}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
<ActionMenu>
<ActionMenu.Anchor>
<ActionList.Item>Third submenu</ActionList.Item>
</ActionMenu.Anchor>
<ActionMenu.Overlay>
<ActionList>
{submenuItems.map(item => (
<ActionList.Item key={item} onSelect={() => alert(`${item} clicked`)}>
{item}
</ActionList.Item>
))}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</div>
)
}
Comment thread
TylerJDev marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,18 @@

&[data-side='outside-left'] {
right: anchor(left);
top: anchor(top);
/* Falls back to `anchor(top)` when JS hasn't overridden it. JS sets the
override when the overlay's bottom would overflow the viewport so we
can lift it up to keep the bottom edge on screen, mirroring the JS
anchored-positioning code path. */
top: var(--anchored-overlay-top-override, anchor(top));
Comment on lines +57 to +61
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is some new logic to handle overflows in the y-axis. Mainly to fix https://github.com/github/primer/issues/6648 gracefully.

margin-right: var(--base-size-4);
position-try-fallbacks: flip-inline, flip-block, flip-start, --outside-left-to-bottom;
}

&[data-side='outside-right'] {
left: anchor(right);
top: anchor(top);
top: var(--anchored-overlay-top-override, anchor(top));
margin-left: var(--base-size-4);
position-try-fallbacks: flip-inline, flip-block, flip-start, --outside-right-to-bottom;
}
Expand Down
111 changes: 110 additions & 1 deletion packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {act, createRef, useCallback, useRef, useState} from 'react'
import {describe, expect, it, vi} from 'vitest'
import {render} from '@testing-library/react'
import {render, waitFor} from '@testing-library/react'
import {userEvent} from 'vitest/browser'
import {AnchoredOverlay} from '../AnchoredOverlay'
import {Button} from '../Button'
Expand Down Expand Up @@ -394,6 +394,115 @@
})
})

describe('AnchoredOverlay CSS anchor positioning viewport handling', () => {
it('should set --anchored-overlay-top-override when the overlay would overflow the viewport bottom', async () => {
function TestComponent() {
const ref = useRef<HTMLButtonElement>(null)
return (
<FeatureFlags flags={{primer_react_css_anchor_positioning: true}}>
<BaseStyles>
<button type="button" ref={ref} style={{position: 'fixed', left: 0, top: 0}} data-testid="anchor">
Anchor
</button>
<AnchoredOverlay
open
onOpen={() => {}}
onClose={() => {}}
renderAnchor={null}
anchorRef={ref}
side="outside-bottom"
>
{/* Make the overlay taller than the viewport so it overflows */}
<div style={{height: `${window.innerHeight + 200}px`, width: '120px'}}>tall content</div>
</AnchoredOverlay>
</BaseStyles>
</FeatureFlags>
)
}

const {baseElement} = render(<TestComponent />)
const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') as HTMLElement

await waitFor(() => {
expect(overlay.style.getPropertyValue('--anchored-overlay-top-override')).not.toBe('')

Check failure on line 427 in packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx

View workflow job for this annotation

GitHub Actions / test (react-18)

[@primer/react (chromium)] src/AnchoredOverlay/AnchoredOverlay.test.tsx > AnchoredOverlay CSS anchor positioning viewport handling > should set --anchored-overlay-top-override when the overlay would overflow the viewport bottom

AssertionError: expected '' not to be '' // Object.is equality Ignored nodes: comments, script, style <html class="js-focus-visible" data-color-mode="auto" data-dark-theme="dark" data-js-focus-visible="" data-light-theme="light" lang="en" > <head> <meta charset="UTF-8" /> <link href="/__vitest__/favicon.svg" rel="icon" type="image/svg+xml" /> <meta content="width=device-width, initial-scale=1.0" name="viewport" /> <title> Vitest Browser Tester </title> <link crossorigin="" href="/__vitest_browser__/utils-DmkAiRYk.js" rel="modulepreload" /> </head> <body> <div id="__primerPortalRoot__" style="position: absolute; top: 0px; left: 0px; width: 100%;" > <div style="position: relative; z-index: 1;" > <div class="prc-AnchoredOverlay-AnchoredOverlay-VjqdQ prc-Overlay-Overlay-ViJgm" data-align="right" data-anchor-position="true" data-component="AnchoredOverlay" data-height-auto="" data-side="outside-bottom" data-visibility-visible="" data-width-auto="" role="none" style="--left: 0px; position-anchor: --anchored-overlay-anchor-_r2a_; --anchored-overlay-anchor-offset-right: 0px;" > <div style="height: 1096px; width: 120px;" > tall content </div> </div> </div> </div> <div> <div class="prc-src-BaseStyles-9LBd2" data-component="BaseStyles" data-portal-root="true" > <button data-testid="anchor" style="position: fixed; left: 0px; top: 0px; anchor-name: --anchored-overlay-anchor-_r2a_;" type="button" > Anchor </button> </div> </div> </body> </html>... ❯ toBe src/AnchoredOverlay/AnchoredOverlay.test.tsx:427:84 ❯ runWithExpensiveErrorDiagnosticsDisabled ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:349:11 ❯ checkCallback ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1097:23 ❯ checkRealTimersCallback ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1091:15
})

// The override should clamp top to keep the bottom edge on screen.
const value = overlay.style.getPropertyValue('--anchored-overlay-top-override')
expect(value.endsWith('px')).toBe(true)
expect(parseFloat(value)).toBeGreaterThanOrEqual(0)
})

it('should not set --anchored-overlay-top-override when the overlay fits in the viewport', async () => {
function TestComponent() {
const ref = useRef<HTMLButtonElement>(null)
return (
<FeatureFlags flags={{primer_react_css_anchor_positioning: true}}>
<BaseStyles>
<button type="button" ref={ref} style={{position: 'fixed', left: 0, top: 0}}>
Anchor
</button>
<AnchoredOverlay open onOpen={() => {}} onClose={() => {}} renderAnchor={null} anchorRef={ref}>
<div style={{height: '40px', width: '120px'}}>short content</div>
</AnchoredOverlay>
</BaseStyles>
</FeatureFlags>
)
}

const {baseElement} = render(<TestComponent />)
const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') as HTMLElement

// Wait two frames so the rAF positioning callback has run.
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))))

expect(overlay.style.getPropertyValue('--anchored-overlay-top-override')).toBe('')
})

it('should set data-side to a suggested side when the overlay overflows both axes', async () => {
function TestComponent() {
const ref = useRef<HTMLButtonElement>(null)
// Anchor pinned to the bottom-right corner so an outside-bottom + start
// overlay overflows both the right and bottom edges of the viewport.
return (
<FeatureFlags flags={{primer_react_css_anchor_positioning: true}}>
<BaseStyles>
<button
type="button"
ref={ref}
style={{position: 'fixed', right: 0, bottom: 0, width: '40px', height: '20px'}}
data-testid="anchor"
>
A
</button>
<AnchoredOverlay
open
onOpen={() => {}}
onClose={() => {}}
renderAnchor={null}
anchorRef={ref}
side="outside-bottom"
width="medium"
>
<div style={{height: `${window.innerHeight + 200}px`}}>tall content</div>
</AnchoredOverlay>
</BaseStyles>
</FeatureFlags>
)
}

const {baseElement} = render(<TestComponent />)
const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]') as HTMLElement

await waitFor(() => {
// The JS should suggest a flip because the overlay overflows both axes.
// In this layout there is enough room to the left of the anchor, so the
// suggested side should flip to outside-left.
expect(overlay.getAttribute('data-side')).toBe('outside-left')

Check failure on line 501 in packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx

View workflow job for this annotation

GitHub Actions / test (react-18)

[@primer/react (chromium)] src/AnchoredOverlay/AnchoredOverlay.test.tsx > AnchoredOverlay CSS anchor positioning viewport handling > should set data-side to a suggested side when the overlay overflows both axes

AssertionError: expected 'outside-bottom' to be 'outside-left' // Object.is equality Ignored nodes: comments, script, style <html class="js-focus-visible" data-color-mode="auto" data-dark-theme="dark" data-js-focus-visible="" data-light-theme="light" lang="en" > <head> <meta charset="UTF-8" /> <link href="/__vitest__/favicon.svg" rel="icon" type="image/svg+xml" /> <meta content="width=device-width, initial-scale=1.0" name="viewport" /> <title> Vitest Browser Tester </title> <link crossorigin="" href="/__vitest_browser__/utils-DmkAiRYk.js" rel="modulepreload" /> </head> <body> <div id="__primerPortalRoot__" style="position: absolute; top: 0px; left: 0px; width: 100%;" > <div style="position: relative; z-index: 1;" > <div class="prc-AnchoredOverlay-AnchoredOverlay-VjqdQ prc-Overlay-Overlay-ViJgm" data-align="left" data-anchor-position="true" data-component="AnchoredOverlay" data-height-auto="" data-side="outside-bottom" data-visibility-visible="" data-width-medium="" role="none" style="--left: 0px; position-anchor: --anchored-overlay-anchor-_r2e_; --anchored-overlay-anchor-offset-left: 0px;" > <div style="height: 1096px;" > tall content </div> </div> </div> </div> <div> <div class="prc-src-BaseStyles-9LBd2" data-component="BaseStyles" data-portal-root="true" > <button data-testid="anchor" style="position: fixed; right: 0px; bottom: 0px; width: 40px; height: 20px; anchor-name: --anchored-overlay-anchor-_r2e_;" type="button" > A </button> </div> </div> </body> </html>... Expected: "outside-left" Received: "outside-bottom" ❯ toBe src/AnchoredOverlay/AnchoredOverlay.test.tsx:501:48 ❯ runWithExpensiveErrorDiagnosticsDisabled ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:349:11 ❯ checkCallback ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1097:23 ❯ checkRealTimersCallback ../../node_modules/@testing-library/dom/dist/@testing-library/dom.esm.js:1091:15
})
})
})

describe('AnchoredOverlay anchor element replacement', () => {
it('should re-apply anchor-name to a new anchor DOM element when the overlay reopens', () => {
function TestComponent() {
Expand Down
93 changes: 70 additions & 23 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,31 +267,56 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr

const popoverId = useId()
const id = popoverId.replaceAll(':', '_') // popoverId can contain colons which are invalid in CSS custom property names, so we replace them with underscores
const anchorName = `--anchored-overlay-anchor-${id}`

// Manage `anchor-name` on the anchor independently of `open`/`width` so a
// parent re-render that re-runs the positioning effect below doesn't
// briefly flicker the anchor link off and back on.
Comment on lines +272 to +274
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The comment explains it, but we add an additional useEffect mainly to handle when we add/remove the anchor-overlay CSS anchor positioning link.

It helps us ensure that unrelated dependency changes don't accidentally add or remove position-anchor or anchor-name.

useEffect(() => {
if (!cssAnchorPositioning || !anchorElement) return
if (anchorElement.style.getPropertyValue('anchor-name')) return
anchorElement.style.setProperty('anchor-name', anchorName)
return () => {
if (anchorElement.style.getPropertyValue('anchor-name') === anchorName) {
anchorElement.style.removeProperty('anchor-name')
}
}
}, [cssAnchorPositioning, anchorElement, anchorName])

const currentOverlay = overlayRef.current
useEffect(() => {
if (!cssAnchorPositioning || !anchorElement) return

// Link the anchor and the overlay (when present) via CSS anchor positioning.
anchorElement.style.setProperty('anchor-name', `--anchored-overlay-anchor-${id}`)
const currentOverlay = overlayRef.current
const resolvedAnchorName = anchorElement.style.getPropertyValue('anchor-name') || anchorName

let pendingPositionFrame: number | null = null
if (open && currentOverlay) {
currentOverlay.style.setProperty('position-anchor', `--anchored-overlay-anchor-${id}`)
currentOverlay.style.setProperty('position-anchor', resolvedAnchorName)

// Defer the getBoundingClientRect read into a `requestAnimationFrame` so the style write above
// does not force a synchronous layout.
pendingPositionFrame = requestAnimationFrame(() => {
pendingPositionFrame = null
const overlayWidth = width ? parseInt(widthMap[width]) : null
const result = getDefaultPosition(anchorElement, overlayWidth)
const fallbackWidth = width ? parseInt(widthMap[width]) : parseInt(widthMap.small)
const result = getDefaultPosition(anchorElement, currentOverlay, fallbackWidth)

currentOverlay.setAttribute('data-align', result.horizontal)
if (result.suggestedSide) {
currentOverlay.setAttribute('data-side', result.suggestedSide)
}

// Apply offset only when viewport is too narrow
const offset = result.horizontal === 'left' ? result.leftOffset : result.rightOffset
currentOverlay.style.setProperty(`--anchored-overlay-anchor-offset-${result.horizontal}`, `${offset || 0}px`)

// Set y-axis offset to prevent overflow if needed.
const settledRect = currentOverlay.getBoundingClientRect()
const overflowBottom = settledRect.bottom - window.innerHeight
if (overflowBottom > 0) {
const clampedTop = Math.max(0, settledRect.top - overflowBottom - 8)
currentOverlay.style.setProperty('--anchored-overlay-top-override', `${clampedTop}px`)
} else {
currentOverlay.style.removeProperty('--anchored-overlay-top-override')
Comment thread
TylerJDev marked this conversation as resolved.
Comment on lines +300 to +318
}
})

// Only call showPopover when shouldRenderAsPopover is enabled
Expand All @@ -308,7 +333,6 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr

return () => {
if (pendingPositionFrame !== null) cancelAnimationFrame(pendingPositionFrame)
anchorElement.style.removeProperty('anchor-name')
// The overlay may no longer be in the DOM at this point, so we need to check for its presence before trying to update it.
if (currentOverlay) {
currentOverlay.style.removeProperty('position-anchor')
Expand Down Expand Up @@ -396,27 +420,50 @@ export const AnchoredOverlay: React.FC<React.PropsWithChildren<AnchoredOverlayPr

function getDefaultPosition(
anchorElement: HTMLElement,
overlayWidth: number | null,
): {horizontal: 'left' | 'right'; leftOffset?: number; rightOffset?: number} {
const rect = anchorElement.getBoundingClientRect()
overlayElement: HTMLElement,
fallbackWidth: number,
): {
horizontal: 'left' | 'right'
leftOffset?: number
rightOffset?: number
suggestedSide?: 'outside-left' | 'outside-right' | 'outside-bottom'
} {
const anchorRect = anchorElement.getBoundingClientRect()
const overlayRect = overlayElement.getBoundingClientRect()
const vw = window.innerWidth
const viewportMargin = 8
const spaceLeft = rect.left
const spaceRight = vw - rect.right
const vh = window.innerHeight
const margin = 8
const overlayWidth = overlayRect.width || fallbackWidth
const spaceLeft = anchorRect.left
const spaceRight = vw - anchorRect.right
const horizontal: 'left' | 'right' = spaceLeft > spaceRight ? 'left' : 'right'

// If there's no explicit overlay width, or either side has enough space
// to contain the overlay, let CSS position-try-fallbacks handle positioning
if (!overlayWidth || spaceLeft >= overlayWidth + viewportMargin || spaceRight >= overlayWidth + viewportMargin) {
return {horizontal}
// Suggest a flip when the overlay is currently overflowing both axes:
// prefer the side of the anchor with enough room, otherwise fall back to
// outside-bottom and let the offsets keep it inside the viewport.
Comment on lines +441 to +443
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The summary of the changes here are:

  • We try to account for content overflow both horizontally and vertically. CSS anchor positioning will handle this automatically IF there's at least one fallback with enough space to fit the overlay, but if not, it'll default the the default position, which is where this implementation comes in.
  • This should only kick in only when CSS anchor positioning isn't able to correctly position the overlay (lack of space).
  • This logic only runs on opening of the overlay, so it won't affect performance.

const overflowsX = overlayRect.right > vw || overlayRect.left < 0
const overflowsY = overlayRect.bottom > vh || overlayRect.top < 0
let suggestedSide: 'outside-left' | 'outside-right' | 'outside-bottom' | undefined
if (overflowsX && overflowsY) {
if (spaceLeft >= overlayWidth + margin) {
suggestedSide = 'outside-left'
} else if (spaceRight >= overlayWidth + margin) {
suggestedSide = 'outside-right'
} else {
suggestedSide = 'outside-bottom'
}
}

// If the viewport is too narrow to fit the overlay on either side, calculate offsets to prevent overflow
// leftOffset is how much to shift the overlay to the right, rightOffset is how much to shift the overlay to the left
const leftOffset = Math.max(0, overlayWidth - rect.right + viewportMargin)
const rightOffset = Math.max(0, rect.left + overlayWidth - vw + viewportMargin)
// If the viewport is too narrow to fit the overlay on either side, calculate offsets to prevent overflow.
let leftOffset: number | undefined
let rightOffset: number | undefined

if (spaceLeft < overlayWidth + margin && spaceRight < overlayWidth + margin) {
leftOffset = Math.max(0, overlayWidth - anchorRect.right + margin)
rightOffset = Math.max(0, anchorRect.left + overlayWidth - vw + margin)
}

return {horizontal, leftOffset, rightOffset}
return {horizontal, leftOffset, rightOffset, suggestedSide}
}

function assignRef<T>(
Expand Down
Loading