diff --git a/.changeset/pretty-masks-kick.md b/.changeset/pretty-masks-kick.md new file mode 100644 index 00000000000..4e0f86497c9 --- /dev/null +++ b/.changeset/pretty-masks-kick.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Overlay: Adds popover API support diff --git a/e2e/components/AnchoredOverlay.test.ts b/e2e/components/AnchoredOverlay.test.ts index 37bf305df1a..65f2176fdcd 100644 --- a/e2e/components/AnchoredOverlay.test.ts +++ b/e2e/components/AnchoredOverlay.test.ts @@ -12,6 +12,7 @@ const stories: Array<{ buttonNames?: string[] openDialog?: boolean openNestedDialog?: boolean + nestedButtonName?: string }> = [ // Default { @@ -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' @@ -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) diff --git a/e2e/components/Overlay.test.ts b/e2e/components/Overlay.test.ts index ed053c04b98..9a84d22e787 100644 --- a/e2e/components/Overlay.test.ts +++ b/e2e/components/Overlay.test.ts @@ -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 ', () => { diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx index 88f7a778309..f9f48ad7574 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx @@ -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', @@ -309,3 +310,48 @@ export const WithActionMenu = { }, }, } + +export const NestedOverlay = () => { + const [anchoredOpen, setAnchoredOpen] = useState(false) + const [overlayOpen, setOverlayOpen] = useState(false) + const buttonRef = useRef(null) + + return ( +
+ setAnchoredOpen(true)} + onClose={() => { + setAnchoredOpen(false) + setOverlayOpen(false) + }} + renderAnchor={props => } + focusZoneSettings={{disabled: true}} + height="large" + width="large" + > +
+

This is the AnchoredOverlay content.

+ + {overlayOpen && ( + setOverlayOpen(false)} + onEscape={() => setOverlayOpen(false)} + top={200} + left={100} + width="small" + popover="manual" + > +
+

This is a nested Overlay inside the AnchoredOverlay.

+
+
+ )} +
+
+
+ ) +} diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css b/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css index f40e73c6283..7e6f717eb30 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css @@ -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)); diff --git a/packages/react/src/Overlay/Overlay.features.stories.tsx b/packages/react/src/Overlay/Overlay.features.stories.tsx index 275ecac51a9..5cf5c471ee8 100644 --- a/packages/react/src/Overlay/Overlay.features.stories.tsx +++ b/packages/react/src/Overlay/Overlay.features.stories.tsx @@ -668,3 +668,33 @@ export const SettingMaxHeight = ({open}: Args) => { ) } + +export const OpenByDefault = () => { + const [isOpen, setIsOpen] = useState(true) + const buttonRef = useRef(null) + + return ( + <> + + {isOpen ? ( + setIsOpen(false)} + onClickOutside={() => setIsOpen(false)} + role="dialog" + aria-label="Open by default overlay" + popover="manual" + > +
+ This overlay is open by default when the story loads. +
+
+ ) : null} + + ) +} diff --git a/packages/react/src/Overlay/Overlay.module.css b/packages/react/src/Overlay/Overlay.module.css index 4be3a962042..5ba70c10ca6 100644 --- a/packages/react/src/Overlay/Overlay.module.css +++ b/packages/react/src/Overlay/Overlay.module.css @@ -200,6 +200,14 @@ visibility: hidden; } + &[popover]:not([data-anchor-position]) { + inset: auto; + margin: 0; + padding: 0; + border: 0; + max-width: none; + } + &:where([data-responsive='fullscreen']), &[data-responsive='fullscreen'][data-anchor-position='true'] { @media screen and (--viewportRange-narrow) { diff --git a/packages/react/src/Overlay/Overlay.test.tsx b/packages/react/src/Overlay/Overlay.test.tsx index b43ecc99e93..6ac980aadfd 100644 --- a/packages/react/src/Overlay/Overlay.test.tsx +++ b/packages/react/src/Overlay/Overlay.test.tsx @@ -1,7 +1,7 @@ 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' @@ -9,6 +9,7 @@ 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' @@ -352,3 +353,102 @@ describe('Overlay', () => { expect(container).not.toHaveAttribute('data-reflow-container') }) }) + +describe('Overlay popover behavior', () => { + let showPopoverSpy: ReturnType + let matchesSpy: ReturnType + + 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(null) + return ( + + + {}} + onClickOutside={() => {}} + popover={popover} + role="dialog" + > +
Overlay content
+
+
+ ) + } + + it('should call showPopover when popover prop is provided and feature flag is enabled', () => { + render( + + + , + ) + + expect(showPopoverSpy).toHaveBeenCalled() + }) + + it('should not call showPopover when feature flag is disabled', () => { + render( + + + , + ) + + expect(showPopoverSpy).not.toHaveBeenCalled() + }) + + it('should not call showPopover when popover prop is not provided', () => { + render( + + + , + ) + + expect(showPopoverSpy).not.toHaveBeenCalled() + }) + + it('should not call showPopover if already open', () => { + matchesSpy.mockReturnValue(true) + + render( + + + , + ) + + expect(showPopoverSpy).not.toHaveBeenCalled() + }) + + it('should apply popover attribute when feature flag is enabled', () => { + const {baseElement} = render( + + + , + ) + + // 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( + + + , + ) + + // When feature flag is disabled, popover attribute is not applied so element is visible + const overlay = getByRole('dialog') + expect(overlay).not.toHaveAttribute('popover') + }) +}) diff --git a/packages/react/src/Overlay/Overlay.tsx b/packages/react/src/Overlay/Overlay.tsx index 279690e1dff..d3918aeb03c 100644 --- a/packages/react/src/Overlay/Overlay.tsx +++ b/packages/react/src/Overlay/Overlay.tsx @@ -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 @@ -186,6 +187,7 @@ const Overlay = React.forwardRef( visibility = 'visible', width = 'auto', responsiveVariant, + popover, ...props }, forwardedRef, @@ -229,6 +231,20 @@ const Overlay = React.forwardRef( ) }, [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() + } + } catch { + // Ignore if popover is already showing or not supported + } + }, [popover, cssAnchorPositioning]) + // 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 @@ -243,6 +259,7 @@ const Overlay = React.forwardRef( height={height} visibility={visibility} data-responsive={responsiveVariant} + popover={cssAnchorPositioning ? popover : undefined} {...props} /> )