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
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
102 changes: 101 additions & 1 deletion packages/react/src/Overlay/Overlay.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import {render, waitFor, fireEvent} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React, {useRef, useState} from 'react'
import {describe, expect, it, vi} from 'vitest'
import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
import {Button} from '../Button'
import Overlay from '../Overlay'
import Text from '../Text'
import BaseStyles from '../BaseStyles'
import {NestedOverlays, MemexNestedOverlays, MemexIssueOverlay, PositionedOverlays} from './Overlay.features.stories'
import {implementsClassName} from '../utils/testing'
import classes from './Overlay.module.css'
import {FeatureFlags} from '../FeatureFlags'

type TestComponentSettings = {
initialFocus?: 'button'
Expand Down Expand Up @@ -352,3 +353,102 @@ describe('Overlay', () => {
expect(container).not.toHaveAttribute('data-reflow-container')
})
})

describe('Overlay popover behavior', () => {
let showPopoverSpy: ReturnType<typeof vi.spyOn>
let matchesSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
showPopoverSpy = vi.spyOn(HTMLElement.prototype, 'showPopover').mockImplementation(() => {})
matchesSpy = vi.spyOn(HTMLElement.prototype, 'matches').mockReturnValue(false)
})

afterEach(() => {
showPopoverSpy.mockRestore()
matchesSpy.mockRestore()
})

const PopoverTestComponent = ({popover}: {popover?: 'auto' | 'manual'}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
return (
<BaseStyles>
<Button ref={buttonRef}>trigger</Button>
<Overlay
returnFocusRef={buttonRef}
onEscape={() => {}}
onClickOutside={() => {}}
popover={popover}
role="dialog"
>
<div>Overlay content</div>
</Overlay>
</BaseStyles>
)
}

it('should call showPopover when popover prop is provided and feature flag is enabled', () => {
render(
<FeatureFlags flags={{primer_react_css_anchor_positioning: true}}>
<PopoverTestComponent popover="manual" />
</FeatureFlags>,
)

expect(showPopoverSpy).toHaveBeenCalled()
})

it('should not call showPopover when feature flag is disabled', () => {
render(
<FeatureFlags flags={{primer_react_css_anchor_positioning: false}}>
<PopoverTestComponent popover="manual" />
</FeatureFlags>,
)

expect(showPopoverSpy).not.toHaveBeenCalled()
})

it('should not call showPopover when popover prop is not provided', () => {
render(
<FeatureFlags flags={{primer_react_css_anchor_positioning: true}}>
<PopoverTestComponent />
</FeatureFlags>,
)

expect(showPopoverSpy).not.toHaveBeenCalled()
})

it('should not call showPopover if already open', () => {
matchesSpy.mockReturnValue(true)

render(
<FeatureFlags flags={{primer_react_css_anchor_positioning: true}}>
<PopoverTestComponent popover="manual" />
</FeatureFlags>,
)

expect(showPopoverSpy).not.toHaveBeenCalled()
})

it('should apply popover attribute when feature flag is enabled', () => {
const {baseElement} = render(
<FeatureFlags flags={{primer_react_css_anchor_positioning: true}}>
<PopoverTestComponent popover="manual" />
</FeatureFlags>,
)

// Use querySelector since popover elements are hidden by default until showPopover() is called
const overlay = baseElement.querySelector('[role="dialog"]')
expect(overlay).toHaveAttribute('popover', 'manual')
})

it('should not apply popover attribute when feature flag is disabled', () => {
const {getByRole} = render(
<FeatureFlags flags={{primer_react_css_anchor_positioning: false}}>
<PopoverTestComponent popover="manual" />
</FeatureFlags>,
)

// When feature flag is disabled, popover attribute is not applied so element is visible
const overlay = getByRole('dialog')
expect(overlay).not.toHaveAttribute('popover')
})
})
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