Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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
4 changes: 4 additions & 0 deletions e2e/components/Overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ const stories = [
title: 'Setting Max Height',
id: 'private-components-overlay-features--setting-max-height',
},
{
title: 'Open By Default',
id: 'private-components-overlay-features--open-by-default',
},
] as const

test.describe('Overlay ', () => {
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
30 changes: 30 additions & 0 deletions packages/react/src/Overlay/Overlay.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -668,3 +668,33 @@ export const SettingMaxHeight = ({open}: Args) => {
</div>
)
}

export const OpenByDefault = () => {
const [isOpen, setIsOpen] = useState(true)
const buttonRef = useRef<HTMLButtonElement>(null)

return (
<>
<Button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
{isOpen ? 'Close' : 'Open'} overlay
</Button>
{isOpen ? (
<Overlay
returnFocusRef={buttonRef}
height="auto"
width="small"
ignoreClickRefs={[buttonRef]}
onEscape={() => setIsOpen(false)}
onClickOutside={() => setIsOpen(false)}
role="dialog"
aria-label="Open by default overlay"
popover="manual"
>
<div style={{padding: '16px'}}>
<Text as="p">This overlay is open by default when the story loads.</Text>
</div>
</Overlay>
) : null}
</>
)
}
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'
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.

Decided not to make it popover by default as I feel like this might consist of more than a minor change.

With it on by default, it seems to work the same, so maybe it's worth enabling it by by default. 🤔 We will need to add popover to instances across Dotcom, which is the con of having it opt-in.

}

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 thread
TylerJDev marked this conversation as resolved.
}
} catch {
// Ignore if popover is already showing or not supported
}
}, [popover, cssAnchorPositioning])
Comment thread
TylerJDev marked this conversation as resolved.

// 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={cssAnchorPositioning ? popover : undefined}
{...props}
/>
)
Expand Down
Loading