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/pretty-masks-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Overlay: Adds popover API support
12 changes: 12 additions & 0 deletions e2e/components/AnchoredOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const stories: Array<{
buttonNames?: string[]
openDialog?: boolean
openNestedDialog?: boolean
nestedButtonName?: string
}> = [
// Default
{
Expand Down Expand Up @@ -119,6 +120,12 @@ const stories: Array<{
id: 'components-anchoredoverlay-dev--reposition-after-content-grows-within-dialog',
waitForText: 'content with 300px height',
},
{
title: 'Nested Overlay',
id: 'components-anchoredoverlay-dev--nested-overlay',
buttonName: 'Open AnchoredOverlay',
nestedButtonName: 'Open nested Overlay',
},
] as const

const theme = 'light'
Expand Down Expand Up @@ -181,6 +188,11 @@ test.describe('AnchoredOverlay', () => {
const overlayButton = page.getByRole('button', {name: buttonName}).first()
await overlayButton.click()

// Open nested overlay if needed
if (story.nestedButtonName) {
await page.getByRole('button', {name: story.nestedButtonName}).click()
}

// for the dev stories, we intentionally change the content after the overlay is open to test that it repositions correctly
if (story.waitForText) await page.getByText(story.waitForText).waitFor()
await waitForImages(page)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {Button} from '../Button'
import {AnchoredOverlay} from '.'
import {Stack} from '../Stack'
import {Dialog, Spinner, ActionList, ActionMenu} from '..'
import Overlay from '../Overlay'

const meta = {
title: 'Components/AnchoredOverlay/Dev',
Expand Down Expand Up @@ -309,3 +310,48 @@ export const WithActionMenu = {
},
},
}

export const NestedOverlay = () => {
const [anchoredOpen, setAnchoredOpen] = useState(false)
const [overlayOpen, setOverlayOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)

return (
<div>
<AnchoredOverlay
open={anchoredOpen}
onOpen={() => setAnchoredOpen(true)}
onClose={() => {
setAnchoredOpen(false)
setOverlayOpen(false)
}}
renderAnchor={props => <Button {...props}>Open AnchoredOverlay</Button>}
focusZoneSettings={{disabled: true}}
height="large"
width="large"
>
<div style={{padding: '16px', width: '300px'}}>
<p style={{marginBottom: '16px'}}>This is the AnchoredOverlay content.</p>
<Button ref={buttonRef} onClick={() => setOverlayOpen(!overlayOpen)}>
{overlayOpen ? 'Close' : 'Open'} nested Overlay
</Button>
{overlayOpen && (
<Overlay
returnFocusRef={buttonRef}
onClickOutside={() => setOverlayOpen(false)}
onEscape={() => setOverlayOpen(false)}
top={200}
left={100}
width="small"
popover="manual"
>
<div style={{padding: '16px'}}>
<p>This is a nested Overlay inside the AnchoredOverlay.</p>
</div>
</Overlay>
)}
</div>
</AnchoredOverlay>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
top: calc(anchor(bottom) + var(--base-size-4));
left: anchor(left);

/* Flips to the opposite side of the anchor if there's more space left of the anchor than right of it. */
&[data-align='left'] {
left: auto;
right: calc(anchor(right) - var(--anchored-overlay-anchor-offset-left));
Expand Down
8 changes: 8 additions & 0 deletions packages/react/src/Overlay/Overlay.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,14 @@
visibility: hidden;
}

&[popover]:not([data-anchor-position]) {
inset: auto;
margin: 0;
padding: 0;
border: 0;
max-width: none;
Comment on lines +204 to +208
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The [popover]:not([data-anchor-position]) rule sets inset: auto, which overrides the earlier top/left/right/bottom positioning rules for .Overlay. As a result, an Overlay using top/left props may no longer be positioned as intended when popover is present (it also removes the component’s default max-width constraint via max-width: none). Consider overriding the UA popover styles without clobbering the component’s positioning/sizing rules (e.g. explicitly re-apply top/left/right/bottom here, or avoid using the inset shorthand and keep the existing max-width).

Suggested change
inset: auto;
margin: 0;
padding: 0;
border: 0;
max-width: none;
margin: 0;
padding: 0;
border: 0;

Copilot uses AI. Check for mistakes.
}

&:where([data-responsive='fullscreen']),
&[data-responsive='fullscreen'][data-anchor-position='true'] {
@media screen and (--viewportRange-narrow) {
Expand Down
17 changes: 17 additions & 0 deletions packages/react/src/Overlay/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type BaseOverlayProps = {
children?: React.ReactNode
className?: string
responsiveVariant?: 'fullscreen' // we only support fullscreen today but we might add bottomsheet in the future
popover?: 'auto' | 'manual'
}

type OwnOverlayProps = Merge<StyledOverlayProps, BaseOverlayProps>
Expand Down Expand Up @@ -186,6 +187,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
visibility = 'visible',
width = 'auto',
responsiveVariant,
popover,
...props
},
forwardedRef,
Expand Down Expand Up @@ -229,6 +231,20 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
)
}, [anchorSide, slideAnimationDistance, slideAnimationEasing, visibility])

// Show popover when using the Popover API
// Skip if CSS anchor positioning is enabled (handled by AnchoredOverlay)
useLayoutEffect(() => {
if (!popover || !overlayRef.current || cssAnchorPositioning) return

try {
if (!overlayRef.current.matches(':popover-open')) {
overlayRef.current.showPopover()
Comment on lines +236 to +241
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The new popover auto-opening effect is gated on the primer_react_css_anchor_positioning feature flag (if (... || cssAnchorPositioning) return). This prevents Overlay from ever calling showPopover() when the flag is enabled, which breaks the primary nested-overlay use case (a nested Overlay needs to enter the top layer when an outer AnchoredOverlay is using popovers). Consider removing this flag check, or instead skipping only when this particular overlay is the CSS-anchor-positioning variant (e.g. data-anchor-position="true"), so standalone/nested Overlay instances can still call showPopover() under the flag.

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

Copilot uses AI. Check for mistakes.
}
} catch {
// Ignore if popover is already showing or not supported
}
}, [popover, cssAnchorPositioning])
Comment on lines +234 to +246
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

Popover support is now implemented in Overlay (new popover prop + showPopover() call), but there are no unit tests covering this behavior. Since this affects layering/visibility and will run under the CSS anchor positioning rollout, it would be good to add a test that stubs HTMLElement.prototype.showPopover/matches and asserts the method is invoked when popover is provided (and not invoked when it’s not).

Copilot uses AI. Check for mistakes.

// To be backwards compatible with the old Overlay, we need to set the left prop if x-position is not specified
const leftPosition = left === undefined && right === undefined ? 0 : left

Expand All @@ -243,6 +259,7 @@ const Overlay = React.forwardRef<HTMLDivElement, internalOverlayProps>(
height={height}
visibility={visibility}
data-responsive={responsiveVariant}
popover={popover}
{...props}
/>
)
Expand Down
Loading