diff --git a/.changeset/nine-buttons-lose.md b/.changeset/nine-buttons-lose.md
new file mode 100644
index 00000000000..69945bd669e
--- /dev/null
+++ b/.changeset/nine-buttons-lose.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+AnchoredOverlay: Add CSS Anchor Positioning to `AnchoredOverlay` (under a feature flag)
diff --git a/e2e/components/ActionMenu.test.ts b/e2e/components/ActionMenu.test.ts
index ba8b94f2f22..3027a835331 100644
--- a/e2e/components/ActionMenu.test.ts
+++ b/e2e/components/ActionMenu.test.ts
@@ -59,32 +59,42 @@ const stories: Array<{
},
] as const
+const featureFlagVariants = [
+ {flagEnabled: false, suffix: ''},
+ {flagEnabled: true, suffix: '.css-anchor-positioning'},
+] as const
+
test.describe('ActionMenu', () => {
for (const story of stories) {
test.describe(story.title, () => {
for (const theme of themes) {
test.describe(theme, () => {
- test('default @vrt', async ({page}) => {
- await visit(page, {
- id: story.id,
- globals: {
- colorScheme: theme,
- },
- })
+ for (const {flagEnabled, suffix} of featureFlagVariants) {
+ test(`default @vrt${suffix}`, async ({page}) => {
+ await visit(page, {
+ id: story.id,
+ globals: {
+ colorScheme: theme,
+ featureFlags: {
+ primer_react_css_anchor_positioning: flagEnabled,
+ },
+ },
+ })
- const buttonName = story.buttonName ?? 'Open menu'
+ const buttonName = story.buttonName ?? 'Open menu'
- // Default state
- // Open state
+ // Default state
+ // Open state
- if (!story.skipOpen) {
- await page.locator('button', {hasText: buttonName}).waitFor()
- await page.getByRole('button', {name: buttonName}).click()
- }
- expect(await page.screenshot({animations: 'disabled'})).toMatchSnapshot(
- `ActionMenu.${story.title}.${theme}.png`,
- )
- })
+ if (!story.skipOpen) {
+ await page.locator('button', {hasText: buttonName}).waitFor()
+ await page.getByRole('button', {name: buttonName}).click()
+ }
+ expect(await page.screenshot({animations: 'disabled'})).toMatchSnapshot(
+ `ActionMenu.${story.title}.${theme}${suffix}.png`,
+ )
+ })
+ }
})
}
})
diff --git a/package-lock.json b/package-lock.json
index 27914e3c5f0..7600cd11a66 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4816,23 +4816,21 @@
}
},
"node_modules/@floating-ui/core": {
- "version": "1.7.3",
- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
- "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
- "dev": true,
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
+ "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
- "version": "1.7.4",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
- "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
- "dev": true,
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
+ "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
"license": "MIT",
"dependencies": {
- "@floating-ui/core": "^1.7.3",
+ "@floating-ui/core": "^1.7.4",
"@floating-ui/utils": "^0.2.10"
}
},
@@ -4854,7 +4852,6 @@
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/@github-ui/storybook-addon-performance-panel": {
@@ -6477,6 +6474,55 @@
"node": "^16.13.0 || >=18.0.0"
}
},
+ "node_modules/@oddbird/css-anchor-positioning": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@oddbird/css-anchor-positioning/-/css-anchor-positioning-0.9.0.tgz",
+ "integrity": "sha512-G5nfb4sU0auxJH7VHafPwVJjr1GhH5uPSkmytGqhNftCpT3QEh8pFtMd4YHt1dRwb4o9qVZxlGSKUIc4TIrysQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.5",
+ "@types/css-tree": "^2.3.11",
+ "css-tree": "^3.1.0",
+ "nanoid": "^5.1.6"
+ }
+ },
+ "node_modules/@oddbird/css-anchor-positioning/node_modules/css-tree": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
+ "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.12.2",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/@oddbird/css-anchor-positioning/node_modules/mdn-data": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
+ "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+ "license": "CC0-1.0"
+ },
+ "node_modules/@oddbird/css-anchor-positioning/node_modules/nanoid": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
+ "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^18 || >=20"
+ }
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"dev": true,
@@ -9037,6 +9083,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/css-tree": {
+ "version": "2.3.11",
+ "resolved": "https://registry.npmjs.org/@types/css-tree/-/css-tree-2.3.11.tgz",
+ "integrity": "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==",
+ "license": "MIT"
+ },
"node_modules/@types/debug": {
"version": "4.1.12",
"dev": true,
@@ -27743,6 +27795,7 @@
"@github/relative-time-element": "^4.5.0",
"@github/tab-container-element": "^4.8.2",
"@lit-labs/react": "1.2.1",
+ "@oddbird/css-anchor-positioning": "^0.9.0",
"@oddbird/popover-polyfill": "^0.5.2",
"@primer/behaviors": "^1.10.2",
"@primer/live-region-element": "^0.7.1",
diff --git a/packages/react/package.json b/packages/react/package.json
index dc741d23341..f34684cbeee 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -78,6 +78,7 @@
"@github/relative-time-element": "^4.5.0",
"@github/tab-container-element": "^4.8.2",
"@lit-labs/react": "1.2.1",
+ "@oddbird/css-anchor-positioning": "^0.9.0",
"@oddbird/popover-polyfill": "^0.5.2",
"@primer/behaviors": "^1.10.2",
"@primer/live-region-element": "^0.7.1",
diff --git a/packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx b/packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx
index 1f2b3bf420e..d55efb4b151 100644
--- a/packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx
+++ b/packages/react/src/ActionMenu/ActionMenu.examples.stories.tsx
@@ -759,3 +759,95 @@ export const InsideDialog = () => {
)
}
+
+export const CenteredOnPage = () => {
+ const [open, setOpen] = React.useState(false)
+
+ return (
+
+
+ Open menu
+
+
+ alert('Copy link clicked')}>
+ Copy link
+ ⌘C
+
+ alert('Quote reply clicked')}>
+ Quote reply
+ ⌘Q
+
+ alert('Edit comment clicked')}>
+ Edit comment
+ ⌘E
+
+
+ alert('Delete file clicked')}>
+ Delete file
+ ⌘D
+
+
+
+
+
+ )
+}
+
+export const TwoActionMenus = () => {
+ return (
+
+
+ First menu
+
+
+ alert('Copy clicked')}>
+
+
+
+ Copy
+ ⌘C
+
+ alert('Archive clicked')}>
+
+
+
+ Archive
+
+
+ alert('Delete clicked')}>
+ Delete
+ ⌘D
+
+
+
+
+
+
+ Second menu
+
+
+ alert('Settings clicked')}>
+
+
+
+ Settings
+
+ alert('Workflows clicked')}>
+
+
+
+ Workflows
+
+
+
+
+
+
+ Documentation
+
+
+
+
+
+ )
+}
diff --git a/packages/react/src/ActionMenu/ActionMenu.test.tsx b/packages/react/src/ActionMenu/ActionMenu.test.tsx
index f7e74c3db77..8d00bb8ea28 100644
--- a/packages/react/src/ActionMenu/ActionMenu.test.tsx
+++ b/packages/react/src/ActionMenu/ActionMenu.test.tsx
@@ -9,6 +9,7 @@ import {Tooltip as TooltipV2} from '../TooltipV2/Tooltip'
import {SingleSelect} from '../ActionMenu/ActionMenu.features.stories'
import {MixedSelection} from '../ActionMenu/ActionMenu.examples.stories'
import {SearchIcon, KebabHorizontalIcon} from '@primer/octicons-react'
+import anchoredOverlayClasses from '../AnchoredOverlay/AnchoredOverlay.module.css'
import {getAnchoredPosition} from '@primer/behaviors'
import type {AnchorPosition} from '@primer/behaviors'
@@ -622,58 +623,124 @@ describe('ActionMenu', () => {
expect(baseAnchor).not.toHaveAttribute('aria-expanded', 'true')
})
+ it('supports className prop on ActionMenu.Anchor with css anchor positioning flag', async () => {
+ const component = HTMLRender(
+
+
+
+
+
+
+
+
+ New file
+
+ Copy link
+ Edit file
+ event.preventDefault()}>
+ Delete file
+
+
+ Github
+
+
+
+
+
+ ,
+ )
+ const anchor = component.getByRole('button', {name: 'Toggle Menu'})
+ expect(anchor).toHaveClass('test-class')
+ expect(anchor).toHaveClass(anchoredOverlayClasses.Anchor)
+ })
+
+ it('supports className prop on ActionMenu.Button with css anchor positioning flag', async () => {
+ const component = HTMLRender(
+
+
+
+ Toggle Menu
+
+
+ New file
+
+ Copy link
+ Edit file
+ event.preventDefault()}>
+ Delete file
+
+
+ Github
+
+
+
+
+
+ ,
+ )
+ const button = component.getByRole('button', {name: 'Toggle Menu'})
+ expect(button).toHaveClass('test-class')
+ expect(button).toHaveClass(anchoredOverlayClasses.Anchor)
+ })
+
it('supports className prop on ActionMenu.Anchor', async () => {
const component = HTMLRender(
-
-
-
-
-
-
-
- New file
-
- Copy link
- Edit file
- event.preventDefault()}>
- Delete file
-
-
- Github
-
-
-
-
- ,
+
+
+
+
+
+
+
+
+ New file
+
+ Copy link
+ Edit file
+ event.preventDefault()}>
+ Delete file
+
+
+ Github
+
+
+
+
+
+ ,
)
const anchor = component.getByRole('button', {name: 'Toggle Menu'})
expect(anchor).toHaveClass('test-class')
+ expect(anchor).not.toHaveClass(anchoredOverlayClasses.Anchor)
})
it('supports className prop on ActionMenu.Button', async () => {
const component = HTMLRender(
-
-
- Toggle Menu
-
-
- New file
-
- Copy link
- Edit file
- event.preventDefault()}>
- Delete file
-
-
- Github
-
-
-
-
- ,
+
+
+
+ Toggle Menu
+
+
+ New file
+
+ Copy link
+ Edit file
+ event.preventDefault()}>
+ Delete file
+
+
+ Github
+
+
+
+
+
+ ,
)
const button = component.getByRole('button', {name: 'Toggle Menu'})
expect(button).toHaveClass('test-class')
+ expect(button).not.toHaveClass(anchoredOverlayClasses.Anchor)
})
})
diff --git a/packages/react/src/ActionMenu/ActionMenu.tsx b/packages/react/src/ActionMenu/ActionMenu.tsx
index e501b117377..d36ae802ca7 100644
--- a/packages/react/src/ActionMenu/ActionMenu.tsx
+++ b/packages/react/src/ActionMenu/ActionMenu.tsx
@@ -1,4 +1,5 @@
import React, {useCallback, useContext, useMemo, useEffect, useState} from 'react'
+import {clsx} from 'clsx'
import {TriangleDownIcon, ChevronRightIcon} from '@primer/octicons-react'
import type {AnchoredOverlayProps} from '../AnchoredOverlay'
import {AnchoredOverlay} from '../AnchoredOverlay'
@@ -73,6 +74,10 @@ const mergeAnchorHandlers = (anchorProps: React.HTMLAttributes, but
mergedAnchorProps.onKeyDown = mergedOnAnchorKeyDown
}
+ if (buttonProps.className) {
+ mergedAnchorProps.className = clsx(anchorProps.className, buttonProps.className)
+ }
+
return mergedAnchorProps
}
@@ -153,7 +158,11 @@ const Menu: FCWithSlotMarker> = ({
}
}
} else {
- renderAnchor = anchorProps => React.cloneElement(child, anchorProps)
+ renderAnchor = anchorProps =>
+ React.cloneElement(child, {
+ ...anchorProps,
+ className: clsx(anchorProps.className, child.props.className),
+ })
}
return null
} else if (child.type === MenuButton || isSlot(child, MenuButton)) {
@@ -234,6 +243,7 @@ const Anchor: WithSlotMarker<
{React.cloneElement(child, {
...anchorProps,
ref: anchorRef,
+ className: clsx(anchorProps.className, child.props.className),
onClick: onButtonClick,
onKeyDown: onButtonKeyDown,
})}
diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.features.stories.module.css b/packages/react/src/AnchoredOverlay/AnchoredOverlay.features.stories.module.css
index 8f1eb2584c2..e7c9c1ebe64 100644
--- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.features.stories.module.css
+++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.features.stories.module.css
@@ -62,3 +62,95 @@
.Icon {
color: var(--fgColor-muted);
}
+
+.CenteredTrigger {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 300vh;
+ /* stylelint-disable-next-line primer/responsive-widths -- intentionally large for scroll testing */
+ min-width: 300vw;
+}
+
+.AnchorGridContainer {
+ overflow: auto;
+ height: 100vh;
+ width: 100vw;
+}
+
+.AnchorGrid {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 200vh;
+ /* stylelint-disable-next-line primer/responsive-widths -- intentionally large for grid layout testing */
+ min-width: 200vw;
+}
+
+.AnchorGridCell {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ /* stylelint-disable-next-line primer/colors -- fallback value for borderColor-default */
+ border: var(--borderWidth-thin) solid var(--borderColor-default, #d1d9e0);
+ padding: var(--base-size-16);
+ position: relative;
+ height: 200px;
+}
+
+.AnchorGridLabel {
+ font-size: var(--text-body-size-small);
+ line-height: var(--text-body-lineHeight-small);
+ color: var(--fgColor-muted);
+ position: absolute;
+ top: var(--base-size-8);
+ left: var(--base-size-8);
+}
+
+.AnchorGridInner {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-template-rows: 1fr 1fr 1fr;
+ width: 50%;
+ height: 50%;
+}
+
+.ScrollContainer {
+ height: 400px;
+ overflow: auto;
+ /* stylelint-disable-next-line primer/colors -- fallback value for borderColor-default */
+ border: var(--borderWidth-thin) solid var(--borderColor-default, #d1d9e0);
+ border-radius: var(--borderRadius-medium);
+ position: relative;
+}
+
+.ScrollContent {
+ height: 1200px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--base-size-16);
+}
+
+.StickyHeader {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--base-size-8) var(--base-size-16);
+ background-color: var(--bgColor-default);
+ /* stylelint-disable-next-line primer/colors -- fallback value for borderColor-default */
+ border-bottom: var(--borderWidth-thin) solid var(--borderColor-default, #d1d9e0);
+}
+
+.StickyScrollArea {
+ height: 1200px;
+ padding: var(--base-size-16);
+}
+
+.DialogBody {
+ padding: var(--base-size-16);
+ height: 2000px;
+}
diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.features.stories.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.features.stories.tsx
index 033b6b656b5..d7602f5c616 100644
--- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.features.stories.tsx
+++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.features.stories.tsx
@@ -1,7 +1,8 @@
+import type React from 'react'
import {useEffect, useRef, useState, type JSX} from 'react'
import type {Args, Meta} from '@storybook/react-vite'
import {FocusKeys} from '@primer/behaviors'
-import {Avatar, Link} from '..'
+import {Avatar, Dialog, Link, Text} from '..'
import {AnchoredOverlay} from '../AnchoredOverlay'
import Heading from '../Heading'
import Octicon from '../Octicon'
@@ -385,3 +386,205 @@ export const FullscreenVariant = () => {
)
}
+
+export const CenteredOnPage = () => {
+ const [open, setOpen] = useState(false)
+
+ return (
+
+
setOpen(true)}
+ onClose={() => setOpen(false)}
+ renderAnchor={props => }
+ overlayProps={{
+ role: 'dialog',
+ 'aria-modal': true,
+ 'aria-label': 'Centered Overlay Demo',
+ }}
+ focusZoneSettings={{disabled: true}}
+ preventOverflow={false}
+ >
+ {hoverCard}
+
+
+ )
+}
+
+const gridPositions = [
+ {row: 'start', col: 'start'},
+ {row: 'start', col: 'center'},
+ {row: 'start', col: 'end'},
+ {row: 'center', col: 'start'},
+ {row: 'center', col: 'center'},
+ {row: 'center', col: 'end'},
+ {row: 'end', col: 'start'},
+ {row: 'end', col: 'center'},
+ {row: 'end', col: 'end'},
+]
+
+export const AnchorPositionGrid = () => {
+ const [openCell, setOpenCell] = useState(null)
+
+ return (
+
+
+
+ {gridPositions.map(({row, col}) => {
+ const key = `${row}-${col}`
+ const isCenter = row === 'center' && col === 'center'
+
+ return (
+
+
+ {row} / {col}
+
+ {isCenter ? (
+
setOpenCell(key)}
+ onClose={() => setOpenCell(null)}
+ renderAnchor={props => }
+ overlayProps={{
+ role: 'dialog',
+ 'aria-modal': true,
+ 'aria-label': 'Anchor Position Grid Demo',
+ }}
+ focusZoneSettings={{disabled: true}}
+ preventOverflow={false}
+ >
+ {hoverCard}
+
+ ) : null}
+
+ )
+ })}
+
+
+
+ )
+}
+
+const AnchorPositionOverlay = ({
+ children,
+ label = 'CSS Anchor Position Demo',
+}: {
+ children?: React.ReactNode
+ label?: string
+}) => {
+ const [open, setOpen] = useState(false)
+
+ return (
+ setOpen(true)}
+ onClose={() => setOpen(false)}
+ renderAnchor={props => }
+ overlayProps={{
+ role: 'dialog',
+ 'aria-modal': true,
+ 'aria-label': label,
+ }}
+ focusZoneSettings={{disabled: true}}
+ preventOverflow={false}
+ >
+ {children ?? hoverCard}
+
+ )
+}
+
+export const ScrollWithAnchor = () => {
+ return (
+
+ )
+}
+
+export const WithinDialog = () => {
+ const [isDialogOpen, setIsDialogOpen] = useState(false)
+
+ return (
+
+
+ {isDialogOpen && (
+
+ )}
+
+ )
+}
+
+export const WithinNestedDialog = () => {
+ const [isOuterOpen, setIsOuterOpen] = useState(false)
+ const [isInnerOpen, setIsInnerOpen] = useState(false)
+
+ return (
+
+
+ {isOuterOpen && (
+
+ )}
+
+ )
+}
+
+export const WithinDialogOverflowing = () => {
+ const [isDialogOpen, setIsDialogOpen] = useState(false)
+
+ return (
+
+
+ {isDialogOpen && (
+
setIsDialogOpen(false)} width="small">
+
+
+ The anchor is positioned near the edge. The overlay should flip via position-try-fallbacks when it would
+ overflow the viewport.
+
+
+
+
+ )}
+
+ )
+}
+
+export const WithinStickyElement = () => {
+ return (
+
+
+
+ Scroll down to test that the overlay stays anchored to the button in the sticky header.
+
+
+ )
+}
diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css b/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css
index 512a890b5ff..72485a8d665 100644
--- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css
+++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css
@@ -12,3 +12,48 @@
display: inline-grid;
}
}
+
+.Wrapper {
+ anchor-scope: --anchored-overlay-anchor;
+}
+
+.Anchor {
+ /* Anchor name, this is currently tied to `renderAnchor` */
+ anchor-name: --anchored-overlay-anchor;
+}
+
+.AnchoredOverlay {
+ /* Anchor position, this is currently tied to `` */
+ position-anchor: --anchored-overlay-anchor;
+ position-try-fallbacks:
+ flip-block,
+ flip-inline,
+ flip-block flip-inline;
+ position-visibility: anchors-visible;
+ z-index: 100;
+ position: fixed;
+
+ &[data-side='outside-bottom'] {
+ /* stylelint-disable primer/spacing */
+ top: calc(anchor(bottom) + var(--base-size-4));
+ left: anchor(left);
+ }
+
+ &[data-side='outside-top'] {
+ margin-bottom: var(--base-size-4);
+ bottom: anchor(top);
+ left: anchor(left);
+ }
+
+ &[data-side='outside-left'] {
+ right: anchor(left);
+ top: anchor(top);
+ margin-right: var(--base-size-4);
+ }
+
+ &[data-side='outside-right'] {
+ left: anchor(right);
+ top: anchor(top);
+ margin-left: var(--base-size-4);
+ }
+}
diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx
index 279bcc112d1..8bcb8d46284 100644
--- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx
+++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx
@@ -7,8 +7,10 @@ import {Button} from '../Button'
import BaseStyles from '../BaseStyles'
import type {AnchorPosition} from '@primer/behaviors'
import {implementsClassName} from '../utils/testing'
+import {FeatureFlags} from '../FeatureFlags'
import overlayClasses from '../Overlay/Overlay.module.css'
+import anchoredOverlayClasses from './AnchoredOverlay.module.css'
type TestComponentSettings = {
initiallyOpen?: boolean
@@ -16,6 +18,7 @@ type TestComponentSettings = {
onCloseCallback?: (gesture: string) => void
onPositionChange?: ({position}: {position: AnchorPosition}) => void
className?: string
+ withCSSAnchorPositioningFeatureFlag?: boolean
}
const AnchoredOverlayTestComponent = ({
@@ -24,6 +27,7 @@ const AnchoredOverlayTestComponent = ({
onCloseCallback,
onPositionChange,
className,
+ withCSSAnchorPositioningFeatureFlag,
}: TestComponentSettings = {}) => {
const [open, setOpen] = useState(initiallyOpen)
const onOpen = useCallback(
@@ -40,7 +44,8 @@ const AnchoredOverlayTestComponent = ({
},
[onCloseCallback],
)
- return (
+
+ const content = (
)
+
+ if (withCSSAnchorPositioningFeatureFlag !== undefined) {
+ return (
+
+ {content}
+
+ )
+ }
+
+ return content
}
-describe('AnchoredOverlay', () => {
- implementsClassName(props => , overlayClasses.Overlay)
- it('should call onOpen when the anchor is clicked', async () => {
- const mockOpenCallback = vi.fn()
- const mockCloseCallback = vi.fn()
- const anchoredOverlay = render(
- ,
+describe.each([true, false])(
+ 'AnchoredOverlay (primer_react_css_anchor_positioning=%s)',
+ (withCSSAnchorPositioningFeatureFlag: boolean) => {
+ implementsClassName(
+ props => (
+
+ ),
+ overlayClasses.Overlay,
)
- const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="true"]')!
- await act(async () => {
- await userEvent.click(anchor)
+
+ it('should call onOpen when the anchor is clicked', async () => {
+ const mockOpenCallback = vi.fn()
+ const mockCloseCallback = vi.fn()
+ const anchoredOverlay = render(
+ ,
+ )
+ const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="true"]')!
+ await act(async () => {
+ await userEvent.click(anchor)
+ })
+
+ expect(mockOpenCallback).toHaveBeenCalledTimes(1)
+ expect(mockOpenCallback).toHaveBeenCalledWith('anchor-click')
+ expect(mockCloseCallback).toHaveBeenCalledTimes(0)
})
- expect(mockOpenCallback).toHaveBeenCalledTimes(1)
- expect(mockOpenCallback).toHaveBeenCalledWith('anchor-click')
- expect(mockCloseCallback).toHaveBeenCalledTimes(0)
- })
+ it('should call onOpen when the anchor activated by a key press', async () => {
+ const mockOpenCallback = vi.fn()
+ const mockCloseCallback = vi.fn()
+ const anchoredOverlay = render(
+ ,
+ )
+ const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="true"]')!
+ await act(async () => {
+ await userEvent.type(anchor, '{Space}')
+ })
- it('should call onOpen when the anchor activated by a key press', async () => {
- const mockOpenCallback = vi.fn()
- const mockCloseCallback = vi.fn()
- const anchoredOverlay = render(
- ,
- )
- const anchor = anchoredOverlay.baseElement.querySelector('[aria-haspopup="true"]')!
- await act(async () => {
- await userEvent.type(anchor, '{Space}')
+ expect(mockOpenCallback).toHaveBeenCalledTimes(1)
+ expect(mockOpenCallback).toHaveBeenCalledWith('anchor-key-press')
+ expect(mockCloseCallback).toHaveBeenCalledTimes(0)
})
- expect(mockOpenCallback).toHaveBeenCalledTimes(1)
- expect(mockOpenCallback).toHaveBeenCalledWith('anchor-key-press')
- expect(mockCloseCallback).toHaveBeenCalledTimes(0)
- })
+ it('should call onClose when the user clicks off of the overlay', async () => {
+ const mockOpenCallback = vi.fn()
+ const mockCloseCallback = vi.fn()
+ const anchoredOverlay = render(
+ ,
+ )
+ await act(async () => {
+ await userEvent.click(anchoredOverlay.baseElement)
+ })
- it('should call onClose when the user clicks off of the overlay', async () => {
- const mockOpenCallback = vi.fn()
- const mockCloseCallback = vi.fn()
- const anchoredOverlay = render(
- ,
- )
- await act(async () => {
- await userEvent.click(anchoredOverlay.baseElement)
+ expect(mockOpenCallback).toHaveBeenCalledTimes(0)
+ expect(mockCloseCallback).toHaveBeenCalledTimes(1)
+ expect(mockCloseCallback).toHaveBeenCalledWith('click-outside')
})
- expect(mockOpenCallback).toHaveBeenCalledTimes(0)
- expect(mockCloseCallback).toHaveBeenCalledTimes(1)
- expect(mockCloseCallback).toHaveBeenCalledWith('click-outside')
- })
+ it('should call onClose when the escape key is pressed', async () => {
+ const mockOpenCallback = vi.fn()
+ const mockCloseCallback = vi.fn()
- it('should call onClose when the escape key is pressed', async () => {
- const mockOpenCallback = vi.fn()
- const mockCloseCallback = vi.fn()
+ render(
+ ,
+ )
- render(
- ,
- )
+ await act(async () => {
+ await userEvent.keyboard('{Escape}')
+ })
- await act(async () => {
- await userEvent.keyboard('{Escape}')
+ expect(mockOpenCallback).toHaveBeenCalledTimes(0)
+ expect(mockCloseCallback).toHaveBeenCalledTimes(1)
+ expect(mockCloseCallback).toHaveBeenCalledWith('escape')
})
- expect(mockOpenCallback).toHaveBeenCalledTimes(0)
- expect(mockCloseCallback).toHaveBeenCalledTimes(1)
- expect(mockCloseCallback).toHaveBeenCalledWith('escape')
- })
+ it('should call onPositionChange when provided', async () => {
+ const mockPositionChangeCallback = vi.fn(({position}: {position: AnchorPosition}) => position)
+ render(
+ ,
+ )
+
+ await act(async () => {
+ await userEvent.keyboard('{Escape}')
+ })
+
+ expect(mockPositionChangeCallback).toHaveBeenCalled()
+ expect(mockPositionChangeCallback).toHaveBeenCalledWith({
+ position: {
+ anchorAlign: 'start',
+ anchorSide: 'outside-bottom',
+ left: 0,
+ top: 36,
+ },
+ })
+ })
+
+ it('should support a `ref` through `overlayProps` on the overlay element', () => {
+ const ref = createRef()
+
+ function Test() {
+ const anchorRef = useRef(null)
+ return (
+
+ {
+ return (
+
+ )
+ }}
+ >
+ content
+
+
+ )
+ }
+
+ render()
+
+ expect(document.getElementById('overlay')).toBe(ref.current)
+ })
+ },
+)
+
+describe('AnchoredOverlay feature flag specific behavior', () => {
+ describe('with primer_react_css_anchor_positioning feature flag enabled', () => {
+ it('should render wrapper div when flag is enabled', () => {
+ const {container} = render(
+
+
+ ,
+ )
+
+ const wrapper = container.querySelector(`.${anchoredOverlayClasses.Wrapper}`)
+ expect(wrapper).toBeInTheDocument()
+ })
- it('should call onPositionChange when provided', async () => {
- const mockPositionChangeCallback = vi.fn(({position}: {position: AnchorPosition}) => position)
- render()
+ it('should apply Anchor class to anchor element when flag is enabled', () => {
+ const {container} = render(
+
+
+ ,
+ )
- await act(async () => {
- await userEvent.keyboard('{Escape}')
+ const anchor = container.querySelector('[aria-haspopup="true"]')
+ expect(anchor).toHaveClass(anchoredOverlayClasses.Anchor)
})
- expect(mockPositionChangeCallback).toHaveBeenCalled()
- expect(mockPositionChangeCallback).toHaveBeenCalledWith({
- position: {
- anchorAlign: 'start',
- anchorSide: 'outside-bottom',
- left: 0,
- top: 36,
- },
+ it('should render overlay as visible immediately when flag is enabled', () => {
+ const {baseElement} = render(
+
+
+ ,
+ )
+
+ const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]')
+ expect(overlay).toHaveAttribute('data-visibility-visible', '')
+ })
+
+ it('should not use portal when flag is enabled', () => {
+ const {baseElement, container} = render(
+
+
+ ,
+ )
+
+ // The overlay should be inside the component tree, not in the portal root
+ const portalRoot = baseElement.querySelector('#__primerPortalRoot__')
+ const overlayInPortal = portalRoot?.querySelector('[data-component="AnchoredOverlay"]')
+ expect(overlayInPortal).toBeNull()
+
+ // The overlay should be inside the wrapper
+ const wrapper = container.querySelector(`.${anchoredOverlayClasses.Wrapper}`)
+ const overlayInWrapper = wrapper?.querySelector('[data-component="AnchoredOverlay"]')
+ expect(overlayInWrapper).toBeInTheDocument()
+ })
+
+ it('should apply AnchoredOverlay class to overlay when flag is enabled', () => {
+ const {baseElement} = render(
+
+
+ ,
+ )
+
+ const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]')
+ expect(overlay).toHaveClass(anchoredOverlayClasses.AnchoredOverlay)
+ })
+
+ it('should set data-anchor-position attribute when flag is enabled', () => {
+ const {baseElement} = render(
+
+
+ ,
+ )
+
+ const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]')
+ expect(overlay).toHaveAttribute('data-anchor-position', 'true')
})
})
- it('should support a `ref` through `overlayProps` on the overlay element', () => {
- const ref = createRef()
-
- function Test() {
- const anchorRef = useRef(null)
- return (
- {
- return (
-
- )
- }}
- >
- content
-
+ describe('with primer_react_css_anchor_positioning feature flag disabled', () => {
+ it('should not render wrapper div when flag is disabled', () => {
+ const {container} = render(
+
+
+ ,
+ )
+
+ const wrapper = container.querySelector(`.${anchoredOverlayClasses.Wrapper}`)
+ expect(wrapper).not.toBeInTheDocument()
+ })
+
+ it('should not apply Anchor class to anchor element when flag is disabled', () => {
+ const {container} = render(
+
+
+ ,
+ )
+
+ const anchor = container.querySelector('[aria-haspopup="true"]')
+ expect(anchor).not.toHaveClass(anchoredOverlayClasses.Anchor)
+ })
+
+ it('should use portal when flag is disabled', () => {
+ const {baseElement} = render(
+
+
+ ,
)
- }
- render()
+ // The overlay should be inside the portal root
+ const portalRoot = baseElement.querySelector('#__primerPortalRoot__')
+ const overlayInPortal = portalRoot?.querySelector('[data-component="AnchoredOverlay"]')
+ expect(overlayInPortal).toBeInTheDocument()
+ })
- expect(document.getElementById('overlay')).toBe(ref.current)
+ it('should set data-anchor-position to false when flag is disabled', () => {
+ const {baseElement} = render(
+
+
+ ,
+ )
+
+ const overlay = baseElement.querySelector('[data-component="AnchoredOverlay"]')
+ expect(overlay).toHaveAttribute('data-anchor-position', 'false')
+ })
})
})
diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
index f60070447b3..3f9513f94f9 100644
--- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
+++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx
@@ -14,6 +14,7 @@ import {IconButton, type IconButtonProps} from '../Button'
import {XIcon} from '@primer/octicons-react'
import classes from './AnchoredOverlay.module.css'
import {clsx} from 'clsx'
+import {useFeatureFlag} from '../FeatureFlags'
interface AnchoredOverlayPropsWithAnchor {
/**
@@ -123,6 +124,13 @@ export type AnchoredOverlayProps = AnchoredOverlayBaseProps &
(AnchoredOverlayPropsWithAnchor | AnchoredOverlayPropsWithoutAnchor) &
Partial>
+const applyAnchorPositioningPolyfill = async () => {
+ if (typeof window !== 'undefined' && !('anchorName' in document.documentElement.style)) {
+ const {default: polyfill} = await import('@oddbird/css-anchor-positioning/fn')
+ polyfill()
+ }
+}
+
const defaultVariant = {
regular: 'anchored',
narrow: 'anchored',
@@ -160,6 +168,7 @@ export const AnchoredOverlay: React.FC {
+ const cssAnchorPositioning = useFeatureFlag('primer_react_css_anchor_positioning')
const anchorRef = useProvidedRefOrCreate(externalAnchorRef)
const [overlayRef, updateOverlayRef] = useRenderForcingRef()
const anchorId = useId(externalAnchorId)
@@ -218,7 +227,11 @@ export const AnchoredOverlay: React.FC
{renderAnchor &&
renderAnchor({
@@ -242,6 +257,7 @@ export const AnchoredOverlay: React.FC {
if (overlayProps?.ref) {
assignRef(overlayProps.ref, node)
}
updateOverlayRef(node)
}}
+ data-anchor-position={cssAnchorPositioning}
+ data-side={cssAnchorPositioning ? side : position?.anchorSide}
>
{showXIcon ? (
@@ -291,6 +309,12 @@ export const AnchoredOverlay: React.FC
)
+
+ if (cssAnchorPositioning) {
+ return
{innerContent}
+ }
+
+ return innerContent
}
function assignRef
(
diff --git a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
index 796448887e8..e3d5fd0d975 100644
--- a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
+++ b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
@@ -2,6 +2,7 @@ import {FeatureFlagScope} from './FeatureFlagScope'
export const DefaultFeatureFlags = FeatureFlagScope.create({
primer_react_breadcrumbs_overflow_menu: false,
+ primer_react_css_anchor_positioning: false,
primer_react_css_has_selector_perf: false,
primer_react_select_panel_fullscreen_on_narrow: false,
primer_react_select_panel_order_selected_at_top: false,
diff --git a/packages/react/src/Overlay/Overlay.module.css b/packages/react/src/Overlay/Overlay.module.css
index 65a4d17df97..c8238414717 100644
--- a/packages/react/src/Overlay/Overlay.module.css
+++ b/packages/react/src/Overlay/Overlay.module.css
@@ -19,14 +19,29 @@
background-color: var(--overlay-bgColor);
border-radius: var(--borderRadius-large);
box-shadow: var(--shadow-floating-small);
- /* stylelint-disable-next-line primer/spacing */
- top: var(--top, auto);
- /* stylelint-disable-next-line primer/spacing */
- left: var(--left, auto);
- /* stylelint-disable-next-line primer/spacing */
- right: var(--right, auto);
- /* stylelint-disable-next-line primer/spacing */
- bottom: var(--bottom, auto);
+
+ &[data-anchor-position='false'],
+ &:not([data-anchor-position]):not([data-variant='modal']) {
+ /* stylelint-disable-next-line primer/spacing */
+ right: var(--right, auto);
+ /* stylelint-disable-next-line primer/spacing */
+ bottom: var(--bottom, auto);
+
+ /* stylelint-disable-next-line selector-max-specificity */
+ &[data-responsive='fullscreen']:not([data-variant]) {
+ @media screen and (--viewportRange-narrow) {
+ top: 0;
+ left: 0;
+ }
+ }
+
+ &:not([data-variant]) {
+ /* stylelint-disable-next-line primer/spacing */
+ top: var(--top, auto);
+ /* stylelint-disable-next-line primer/spacing */
+ left: var(--left, auto);
+ }
+ }
&:focus {
outline: none;
diff --git a/packages/react/src/Overlay/Overlay.tsx b/packages/react/src/Overlay/Overlay.tsx
index 362083945e2..6db7e9debac 100644
--- a/packages/react/src/Overlay/Overlay.tsx
+++ b/packages/react/src/Overlay/Overlay.tsx
@@ -10,6 +10,7 @@ import type {AnchorSide} from '@primer/behaviors'
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
import classes from './Overlay.module.css'
import {clsx} from 'clsx'
+import {useFeatureFlag} from '../FeatureFlags'
type StyledOverlayProps = {
width?: keyof typeof widthMap
@@ -189,6 +190,7 @@ const Overlay = React.forwardRef(
forwardedRef,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ReactElement => {
+ const cssAnchorPositioning = useFeatureFlag('primer_react_css_anchor_positioning')
const overlayRef = useRef(null)
useRefObjectAsForwardedRef(forwardedRef, overlayRef)
const slideAnimationDistance = 8 // var(--base-size-8), hardcoded to do some math
@@ -229,22 +231,26 @@ const Overlay = React.forwardRef(
// 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
- return (
-
-
-
+ const overlayContent = (
+
)
+
+ if (cssAnchorPositioning) {
+ return overlayContent
+ }
+
+ return {overlayContent}
},
) as PolymorphicForwardRefComponent<'div', internalOverlayProps>