diff --git a/packages/react/src/ActionMenu/ActionMenu.test.tsx b/packages/react/src/ActionMenu/ActionMenu.test.tsx
index 0be609c68e8..0c93dc0b7a9 100644
--- a/packages/react/src/ActionMenu/ActionMenu.test.tsx
+++ b/packages/react/src/ActionMenu/ActionMenu.test.tsx
@@ -1,4 +1,4 @@
-import {describe, expect, it, vi} from 'vitest'
+import {describe, expect, it, vi, beforeEach} from 'vitest'
import {render as HTMLRender, waitFor, act, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type React from 'react'
@@ -9,9 +9,34 @@ 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 {getAnchoredPosition} from '@primer/behaviors'
+import type {AnchorPosition} from '@primer/behaviors'
import type {JSX} from 'react'
import {implementsClassName} from '../utils/testing'
+import {FeatureFlags} from '../FeatureFlags'
+import Portal from '../Portal'
+
+// Mock getAnchoredPosition for feature flag tests
+vi.mock('@primer/behaviors', async () => {
+ const actual = await vi.importActual('@primer/behaviors')
+ return {
+ ...actual,
+ getAnchoredPosition: vi.fn(
+ (
+ _floatingElement: Element,
+ _anchorElement: Element | DOMRect,
+ _settings?: Partial<{displayInViewport?: boolean}>,
+ ) =>
+ ({
+ top: 100,
+ left: 100,
+ anchorSide: 'outside-bottom',
+ anchorAlign: 'start',
+ }) as AnchorPosition,
+ ),
+ }
+})
function Example(): JSX.Element {
return (
@@ -812,4 +837,223 @@ describe('ActionMenu', () => {
expect(mockOnKeyDown).toHaveBeenCalledTimes(1)
})
})
+
+ describe('feature flag: primer_react_action_menu_display_in_viewport_inside_portal', () => {
+ const mockGetAnchoredPosition = vi.mocked(getAnchoredPosition)
+
+ beforeEach(() => {
+ // Reset mock before each test
+ mockGetAnchoredPosition.mockClear()
+ })
+
+ it('should enable displayInViewport when flag is enabled and ActionMenu is inside a portal', async () => {
+ // When the ActionMenu is wrapped in a Portal, it's inside a portal context.
+ // With the flag enabled, displayInViewport should be automatically enabled.
+ const component = HTMLRender(
+
+
+
+ Toggle Menu
+
+
+ New file
+
+
+
+
+ ,
+ )
+
+ const user = userEvent.setup()
+ const button = component.getByRole('button')
+ await user.click(button)
+
+ await waitFor(() => {
+ expect(component.queryByRole('menu')).toBeInTheDocument()
+ })
+
+ // Verify getAnchoredPosition was called with displayInViewport: true
+ await waitFor(() => {
+ expect(mockGetAnchoredPosition).toHaveBeenCalled()
+ })
+
+ const calls = mockGetAnchoredPosition.mock.calls
+ const lastCall = calls[calls.length - 1]
+ expect(lastCall[2]?.displayInViewport).toBe(true)
+ })
+
+ it('should not enable displayInViewport when flag is enabled but ActionMenu is NOT inside a portal', async () => {
+ // Without being wrapped in a Portal, the ActionMenu is not in a portal context.
+ // Even with the flag enabled, displayInViewport should remain at its default (false/undefined).
+ const component = HTMLRender(
+
+
+ Toggle Menu
+
+
+ New file
+
+
+
+ ,
+ )
+
+ const user = userEvent.setup()
+ const button = component.getByRole('button')
+ await user.click(button)
+
+ await waitFor(() => {
+ expect(component.queryByRole('menu')).toBeInTheDocument()
+ })
+
+ // Verify getAnchoredPosition was called without displayInViewport enabled
+ await waitFor(() => {
+ expect(mockGetAnchoredPosition).toHaveBeenCalled()
+ })
+
+ const calls = mockGetAnchoredPosition.mock.calls
+ const lastCall = calls[calls.length - 1]
+ expect(lastCall[2]?.displayInViewport).not.toBe(true)
+ })
+
+ it('should not enable displayInViewport when flag is disabled, even inside a portal', async () => {
+ // Even when inside a Portal, with the flag disabled, displayInViewport
+ // should remain at its default (false/undefined).
+ const component = HTMLRender(
+
+
+
+ Toggle Menu
+
+
+ New file
+
+
+
+
+ ,
+ )
+
+ const user = userEvent.setup()
+ const button = component.getByRole('button')
+ await user.click(button)
+
+ await waitFor(() => {
+ expect(component.queryByRole('menu')).toBeInTheDocument()
+ })
+
+ // Verify getAnchoredPosition was called without displayInViewport enabled
+ await waitFor(() => {
+ expect(mockGetAnchoredPosition).toHaveBeenCalled()
+ })
+
+ const calls = mockGetAnchoredPosition.mock.calls
+ const lastCall = calls[calls.length - 1]
+ expect(lastCall[2]?.displayInViewport).not.toBe(true)
+ })
+
+ it('should not enable displayInViewport when flag is disabled and outside portal', async () => {
+ // Default scenario: flag disabled and not in a portal context.
+ // displayInViewport should remain at its default (false/undefined).
+ const component = HTMLRender(
+
+
+ Toggle Menu
+
+
+ New file
+
+
+
+ ,
+ )
+
+ const user = userEvent.setup()
+ const button = component.getByRole('button')
+ await user.click(button)
+
+ await waitFor(() => {
+ expect(component.queryByRole('menu')).toBeInTheDocument()
+ })
+
+ // Verify getAnchoredPosition was called without displayInViewport enabled
+ await waitFor(() => {
+ expect(mockGetAnchoredPosition).toHaveBeenCalled()
+ })
+
+ const calls = mockGetAnchoredPosition.mock.calls
+ const lastCall = calls[calls.length - 1]
+ expect(lastCall[2]?.displayInViewport).not.toBe(true)
+ })
+
+ it('should respect explicit displayInViewport prop over feature flag logic', async () => {
+ // Test that an explicit displayInViewport=false prop overrides the automatic
+ // detection, even when the flag is enabled and the ActionMenu is inside a portal.
+ const component = HTMLRender(
+
+
+
+ Toggle Menu
+
+
+ New file
+
+
+
+
+ ,
+ )
+
+ const user = userEvent.setup()
+ const button = component.getByRole('button')
+ await user.click(button)
+
+ await waitFor(() => {
+ expect(component.queryByRole('menu')).toBeInTheDocument()
+ })
+
+ // Verify getAnchoredPosition was called with displayInViewport: false (explicit override)
+ await waitFor(() => {
+ expect(mockGetAnchoredPosition).toHaveBeenCalled()
+ })
+
+ const calls = mockGetAnchoredPosition.mock.calls
+ const lastCall = calls[calls.length - 1]
+ expect(lastCall[2]?.displayInViewport).toBe(false)
+ })
+
+ it('should respect explicit displayInViewport=true prop even when flag is disabled', async () => {
+ // Test that an explicit displayInViewport=true prop works regardless of
+ // the flag state or portal context.
+ const component = HTMLRender(
+
+
+ Toggle Menu
+
+
+ New file
+
+
+
+ ,
+ )
+
+ const user = userEvent.setup()
+ const button = component.getByRole('button')
+ await user.click(button)
+
+ await waitFor(() => {
+ expect(component.queryByRole('menu')).toBeInTheDocument()
+ })
+
+ // Verify getAnchoredPosition was called with displayInViewport: true (explicit override)
+ await waitFor(() => {
+ expect(mockGetAnchoredPosition).toHaveBeenCalled()
+ })
+
+ const calls = mockGetAnchoredPosition.mock.calls
+ const lastCall = calls[calls.length - 1]
+ expect(lastCall[2]?.displayInViewport).toBe(true)
+ })
+ })
})