Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
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/soft-pianos-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

AnchoredOverlay: Add Popover API to AnchoredOverlay (behind `primer_react_css_anchor_positioning` feature flag)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 34 additions & 11 deletions e2e/components/AnchoredOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const stories: Array<{
viewport?: keyof typeof viewports
waitForText?: string
buttonName?: string
buttonNames?: string[]
openDialog?: boolean
openNestedDialog?: boolean
}> = [
Expand Down Expand Up @@ -97,6 +98,11 @@ const stories: Array<{
// buttonName: 'Open Overlay',
// openDialog: true,
// },
{
title: 'Multiple Overlays',
id: 'components-anchoredoverlay-features--multiple-overlays',
buttonNames: ['renderAnchor 1', 'External anchor 1', 'renderAnchor 2', 'External anchor 2'],
},
{
title: 'Within Sticky Element',
id: 'components-anchoredoverlay-features--within-sticky-element',
Expand Down Expand Up @@ -153,19 +159,36 @@ test.describe('AnchoredOverlay', () => {
await page.getByRole('button', {name: 'Open Inner Dialog'}).click()
}

// Open the overlay
const buttonName = story.buttonName ?? 'Button'
await page.locator('button', {hasText: buttonName}).first().waitFor()
const overlayButton = page.getByRole('button', {name: buttonName}).first()
await overlayButton.click()
// If the story has multiple overlays, screenshot each one individually
if (story.buttonNames) {
for (const name of story.buttonNames) {
await page.locator('button', {hasText: name}).first().waitFor()
const btn = page.getByRole('button', {name}).first()
await btn.click()
await waitForImages(page)

expect(await page.screenshot({animations: 'disabled'})).toMatchSnapshot(
`AnchoredOverlay.${story.title}.${name}.${theme}${namePostfix}.png`,
)

// 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)
// Close the overlay before opening the next one
await btn.click()
}
} else {
// Open the overlay
const buttonName = story.buttonName ?? 'Button'
await page.locator('button', {hasText: buttonName}).first().waitFor()
const overlayButton = page.getByRole('button', {name: buttonName}).first()
await overlayButton.click()

expect(await page.screenshot({animations: 'disabled'})).toMatchSnapshot(
`AnchoredOverlay.${story.title}.${theme}${namePostfix}.png`,
)
// 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)

expect(await page.screenshot({animations: 'disabled'})).toMatchSnapshot(
`AnchoredOverlay.${story.title}.${theme}${namePostfix}.png`,
)
}
})
}
})
Expand Down
5 changes: 0 additions & 5 deletions packages/react/src/ActionMenu/ActionMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ 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'

Expand Down Expand Up @@ -651,7 +650,6 @@ describe('ActionMenu', () => {
)
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 () => {
Expand Down Expand Up @@ -680,7 +678,6 @@ describe('ActionMenu', () => {
)
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 () => {
Expand Down Expand Up @@ -711,7 +708,6 @@ describe('ActionMenu', () => {
)
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 () => {
Expand Down Expand Up @@ -740,7 +736,6 @@ describe('ActionMenu', () => {
)
const button = component.getByRole('button', {name: 'Toggle Menu'})
expect(button).toHaveClass('test-class')
expect(button).not.toHaveClass(anchoredOverlayClasses.Anchor)
})
})

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type {Meta} from '@storybook/react-vite'
import React, {useState} from 'react'
import React, {useState, useRef} from 'react'

import {Button} from '../Button'
import {AnchoredOverlay} from '.'
import {Stack} from '../Stack'
import {Dialog, Spinner} from '..'
import {Dialog, Spinner, ActionList, ActionMenu} from '..'

const meta = {
title: 'Components/AnchoredOverlay/Dev',
Expand Down Expand Up @@ -106,3 +106,92 @@ export const RepositionAfterContentGrowsWithinDialog = () => {
</Dialog>
)
}

function LazyAnchoredOverlay() {
const [wasTriggered, setWasTriggered] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const anchorRef = useRef<HTMLButtonElement>(null)

if (!wasTriggered) {
return (
<Button
ref={anchorRef}
onClick={() => {
setWasTriggered(true)
setIsOpen(true)
}}
>
Open Overlay (lazy)
</Button>
)
}

return (
<AnchoredOverlay
open={isOpen}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
renderAnchor={props => (
<Button {...props} ref={anchorRef}>
Open Overlay (loaded)
</Button>
)}
anchorRef={anchorRef}
>
<ActionList>
<ActionList.Item onSelect={() => setIsOpen(false)}>Item 1</ActionList.Item>
<ActionList.Item onSelect={() => setIsOpen(false)}>Item 2</ActionList.Item>
<ActionList.Item onSelect={() => setIsOpen(false)}>Item 3</ActionList.Item>
</ActionList>
</AnchoredOverlay>
)
}

function LazyActionMenu() {
const [items, setItems] = useState<string[] | null>(null)

const loadItems = () => {
// Simulate expensive data fetch
if (!items) {
setItems(['Assignee 1', 'Assignee 2', 'Assignee 3'])
}
}

return (
<ActionMenu>
<ActionMenu.Button onClick={loadItems}>{items ? 'Select assignee' : 'Click to load assignees'}</ActionMenu.Button>
<ActionMenu.Overlay>
<ActionList>
{items ? (
items.map(item => <ActionList.Item key={item}>{item}</ActionList.Item>)
) : (
<ActionList.Item disabled>Loading...</ActionList.Item>
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
)
}

export const WithAnchoredOverlay = {
render: () => <LazyAnchoredOverlay />,
parameters: {
docs: {
description: {
story:
'Defers mounting AnchoredOverlay until first click. The overlay component (with focus trap, positioning, etc.) is not created until needed.',
},
},
},
}

export const WithActionMenu = {
render: () => <LazyActionMenu />,
parameters: {
docs: {
description: {
story: 'Uses ActionMenu but lazily loads the menu items on first open.',
},
},
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,95 @@ export const WithinDialogOverflowing = () => {
)
}

export const MultipleOverlays = () => {
const [openOverlay, setOpenOverlay] = useState<string | null>(null)
const externalAnchorRefA = useRef<HTMLButtonElement>(null)
const externalAnchorRefB = useRef<HTMLButtonElement>(null)

const open = (key: string) => () => setOpenOverlay(key)
const close = () => setOpenOverlay(null)

return (
<Stack direction="horizontal" gap="normal" align="start" style={{padding: '16px'}}>
<AnchoredOverlay
open={openOverlay === 'render-1'}
onOpen={open('render-1')}
onClose={close}
renderAnchor={props => <Button {...props}>renderAnchor 1</Button>}
overlayProps={{
role: 'dialog',
'aria-modal': true,
'aria-label': 'Overlay 1',
}}
focusZoneSettings={{disabled: true}}
preventOverflow={false}
>
<div className={classes.FlexColFill}>{hoverCard}</div>
</AnchoredOverlay>

<Button
ref={externalAnchorRefA}
onClick={() => setOpenOverlay(openOverlay === 'external-1' ? null : 'external-1')}
>
External anchor 1
</Button>
<AnchoredOverlay
open={openOverlay === 'external-1'}
onClose={close}
renderAnchor={null}
anchorRef={externalAnchorRefA}
overlayProps={{
role: 'dialog',
'aria-modal': true,
'aria-label': 'Overlay 2',
}}
focusZoneSettings={{disabled: true}}
preventOverflow={false}
>
<div className={classes.FlexColFill}>{hoverCard}</div>
</AnchoredOverlay>

<AnchoredOverlay
open={openOverlay === 'render-2'}
onOpen={open('render-2')}
onClose={close}
renderAnchor={props => <Button {...props}>renderAnchor 2</Button>}
overlayProps={{
role: 'dialog',
'aria-modal': true,
'aria-label': 'Overlay 3',
}}
focusZoneSettings={{disabled: true}}
preventOverflow={false}
>
<div className={classes.FlexColFill}>{hoverCard}</div>
</AnchoredOverlay>

<Button
ref={externalAnchorRefB}
onClick={() => setOpenOverlay(openOverlay === 'external-2' ? null : 'external-2')}
>
External anchor 2
</Button>
<AnchoredOverlay
open={openOverlay === 'external-2'}
onClose={close}
renderAnchor={null}
anchorRef={externalAnchorRefB}
overlayProps={{
role: 'dialog',
'aria-modal': true,
'aria-label': 'Overlay 4',
}}
focusZoneSettings={{disabled: true}}
preventOverflow={false}
>
<div className={classes.FlexColFill}>{hoverCard}</div>
</AnchoredOverlay>
</Stack>
)
}

export const WithinStickyElement = () => {
return (
<div className={classes.ScrollContainer}>
Expand Down
22 changes: 10 additions & 12 deletions packages/react/src/AnchoredOverlay/AnchoredOverlay.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,23 @@
}
}

.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 `<Overlay>` */
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;
position: fixed !important;

&[popover] {
inset: auto;
margin: 0;
padding: 0;
border: 0;
max-height: none;
max-width: none;
}

&[data-side='outside-bottom'] {
/* stylelint-disable primer/spacing */
Expand Down
Loading
Loading