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) + }) + }) })