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