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 && ( + setIsDialogOpen(false)} width="large"> +
+ + The overlay below uses CSS anchor positioning within a dialog. + + +
+
+ )} +
+ ) +} + +export const WithinNestedDialog = () => { + const [isOuterOpen, setIsOuterOpen] = useState(false) + const [isInnerOpen, setIsInnerOpen] = useState(false) + + return ( +
+ + {isOuterOpen && ( + setIsOuterOpen(false)} width="large"> +
+ + This is the outer dialog. Open the inner dialog to test nested anchor positioning. + + + {isInnerOpen && ( + setIsInnerOpen(false)} width="medium"> +
+ + The overlay below uses CSS anchor positioning inside a nested dialog. + + +
+
+ )} +
+
+ )} +
+ ) +} + +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 ( +
+
+ Sticky Header + +
+
+ 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>