import React from 'react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeAll, describe, expect, it, vi } from 'vitest' import Popover from './Popover' import styles from './_popover.module.scss' import Button from '../Button/Button' import { type PopoverProps } from './Popover' describe('Popover', () => { beforeAll(() => { // Reason: JSDOM doesn't provide `matchMedia` by default. // `useMediaQuery` relies on it, so we polyfill for Popover tests. if ( typeof window !== 'undefined' && typeof window.matchMedia !== 'function' ) { Object.defineProperty(window, 'matchMedia', { writable: true, value: (query: string) => ({ matches: true, media: query, onchange: null, addEventListener: () => undefined, removeEventListener: () => undefined, // Legacy API (some libs still call these) addListener: () => undefined, removeListener: () => undefined, dispatchEvent: () => false, }), }) } }) const mockOnClickOutside = vi.fn<() => void>() const defaultProps = { children: ({ setVisible, visible, }: { setVisible: (visible: boolean) => void visible: boolean }) => , popoverContent:
Popover Content
, position: 'bottom' as const, onClickOutside: mockOnClickOutside, } it('renders the trigger button', () => { render() expect(screen.getByTestId('popover-trigger')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Click Me' })).toBeInTheDocument() }) it('shows popover content when trigger is clicked', async () => { render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) expect(screen.getByTestId('popover-container')).toBeInTheDocument() }) it('hides popover content when clicking outside', async () => { render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) expect(screen.getByTestId('popover-container')).toBeInTheDocument() fireEvent.click(document.body) await waitFor(() => { expect(screen.queryByTestId('popover-container')).not.toBeInTheDocument() expect(mockOnClickOutside).toHaveBeenCalled() }) }) it('renders with header when header prop is provided', async () => { const props = { ...defaultProps, header: { headerText: 'Popover Header' }, } render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) expect(screen.getByTestId('popover-container')).toBeInTheDocument() expect(screen.getByText('Popover Header')).toBeInTheDocument() expect(screen.getByText('Popover Content')).toBeInTheDocument() }) it('applies noPadding class when noPadding prop is true', async () => { const props = { ...defaultProps, noPadding: true, } render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) expect(screen.getByTestId('popover-container')).toBeInTheDocument() const container = screen.getByTestId('popover-container') expect(container).toHaveClass(styles.noPadding) }) it('does not open when disabled prop is true', async () => { const props = { ...defaultProps, disabled: true, } render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) expect(screen.queryByTestId('popover-container')).not.toBeInTheDocument() expect(screen.queryByText('Popover Content')).not.toBeInTheDocument() }) it('closes popover when scrolling if disableCloseOnScroll is false', async () => { const props = { ...defaultProps, disableCloseOnScroll: false, } render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) expect(screen.getByTestId('popover-container')).toBeInTheDocument() act(() => { window.dispatchEvent(new Event('scroll')) }) await waitFor(() => { expect(screen.queryByTestId('popover-container')).not.toBeInTheDocument() }) }) // TODO: This test only validates the onOpenChange scroll handler, not the useDismiss ancestorScroll config. Consider adding a test that verifies ancestorScroll is properly set based on disableCloseOnScroll. it('keeps popover open when scrolling if disableCloseOnScroll is true', async () => { const props = { ...defaultProps, disableCloseOnScroll: true, } render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) expect(screen.getByTestId('popover-container')).toBeInTheDocument() act(() => { window.dispatchEvent(new Event('scroll')) }) await waitFor(() => { expect(screen.getByTestId('popover-container')).toBeInTheDocument() }) }) it('renders with custom className', async () => { const props = { ...defaultProps, className: 'custom-class', } render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) expect(screen.getByTestId('popover-container')).toBeInTheDocument() const container = screen.getByTestId('popover-container') expect(container).toHaveClass('custom-class') }) it('renders with function as popoverContent', async () => { const props = { ...defaultProps, popoverContent: ({ setVisible, }: { setVisible: (visible: boolean) => void }) => (

Dynamic Content

), } render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) expect(screen.getByTestId('popover-container')).toBeInTheDocument() expect(screen.getByText('Dynamic Content')).toBeInTheDocument() const closeButton = screen.getByRole('button', { name: 'Close' }) fireEvent.click(closeButton) await waitFor(() => { expect(screen.queryByTestId('popover-container')).not.toBeInTheDocument() expect(screen.queryByText('Dynamic Content')).not.toBeInTheDocument() }) }) const positions: PopoverProps['position'][] = [ 'top', 'right', 'bottom', 'left', 'top-start', 'top-end', 'right-start', 'right-end', 'bottom-start', 'bottom-end', 'left-start', 'left-end', ] positions.forEach((position) => { it(`renders with position ${position}`, async () => { const props = { ...defaultProps, position, } render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) expect(screen.getByTestId('popover-floating')).toBeInTheDocument() expect(screen.getByTestId('popover-floating')).toHaveAttribute( 'data-placement', position, ) }) }) it('renders with maxWidth prop', async () => { const props = { ...defaultProps, maxWidth: '500px', } render() const button = screen.getByRole('button', { name: 'Click Me' }) fireEvent.click(button) const floatingElement = screen.getByTestId('popover-floating') expect(floatingElement).toHaveStyle({ maxWidth: '500px' }) }) }) /** * Portal-root resolution tests for the `resolveAppendTo` refactor. * We isolate these via dynamic imports and module mocks to avoid depending on * Floating UI internals / DOM layout APIs. */ describe('Popover portal root resolution', () => { function mockUseMediaQuery() { vi.doMock('../../hooks/responsiveHooks', () => ({ useMediaQuery: () => true, })) } function mockFloatingUi() { vi.doMock('@floating-ui/react', () => { const reference = { current: null as HTMLElement | null } const floating = { current: null as HTMLElement | null } return { FloatingPortal: ({ children, root, }: { children: React.ReactNode root: unknown }) => { const rootId = root instanceof HTMLElement ? (root.getAttribute('data-testid') ?? root.tagName) : root === document.documentElement ? 'HTML' : root === document.body ? 'BODY' : root ? 'other' : 'null' return (
{children}
) }, useFloating: () => ({ refs: { setReference: (node: HTMLElement | null) => { reference.current = node }, setFloating: (node: HTMLElement | null) => { floating.current = node }, reference, floating, }, floatingStyles: {}, context: { placement: 'bottom' }, }), useInteractions: () => ({ getReferenceProps: () => ({}), getFloatingProps: () => ({}), }), useTransitionStyles: () => ({ // Always mounted so we can observe the resolved portal root. isMounted: true, styles: {}, }), // Interaction hooks are irrelevant for these tests. useClick: () => ({}), useDismiss: () => ({}), useRole: () => ({}), autoUpdate: () => undefined, offset: () => ({}), shift: () => ({}), flip: () => ({}), autoPlacement: () => ({}), } }) } const baseProps = { children: ({ setVisible, visible, }: { setVisible: (visible: boolean) => void visible: boolean }) => ( ), popoverContent:
Content
, position: 'bottom' as const, } it("uses the trigger's parent when appendTo is 'parent' (after ref is set)", async () => { vi.resetModules() mockUseMediaQuery() mockFloatingUi() const DynamicPopover = (await import('./Popover')).default const { getByRole, getByTestId } = render(
, ) // First click triggers a re-render after refs are attached, so resolveAppendTo can see parentElement. fireEvent.click(getByRole('button', { name: 'Toggle' })) expect(getByTestId('floating-portal').getAttribute('data-root-id')).toBe( 'wrapper', ) }) it('falls back to document.documentElement when document.body is null', async () => { vi.resetModules() mockUseMediaQuery() mockFloatingUi() const DynamicPopover = (await import('./Popover')).default const container = document.createElement('div') document.documentElement.appendChild(container) const originalBodyDescriptor = Object.getOwnPropertyDescriptor( Document.prototype, 'body', ) // Simulate a transient teardown state. Object.defineProperty(Document.prototype, 'body', { get: () => null, configurable: true, }) try { const { getByRole, getByTestId } = render( , { container, baseElement: document.documentElement, }, ) fireEvent.click(getByRole('button', { name: 'Toggle' })) expect(getByTestId('floating-portal').getAttribute('data-root-id')).toBe( 'HTML', ) } finally { if (originalBodyDescriptor) { Object.defineProperty( Document.prototype, 'body', originalBodyDescriptor, ) } container.remove() } }) it('does not render the portal when resolveAppendTo returns null (not client)', async () => { vi.resetModules() mockUseMediaQuery() mockFloatingUi() vi.doMock('../../services/isClient', () => ({ default: false })) const DynamicPopover = (await import('./Popover')).default const { queryByTestId } = render() expect(queryByTestId('floating-portal')).toBeNull() }) })