test(web): add unit tests for base ui primitive wrappers

This commit is contained in:
yyh
2026-03-02 19:17:29 +08:00
parent afcd3b81ce
commit 03180ffc2c
5 changed files with 1141 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
import type { ComponentPropsWithoutRef } from 'react'
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '../index'
type PrimitiveProps = ComponentPropsWithoutRef<'div'>
vi.mock('@base-ui/react/dialog', () => {
const createPrimitive = (testId: string) => {
return vi.fn(({ children, ...props }: PrimitiveProps) => (
<div data-testid={testId} {...props}>
{children}
</div>
))
}
return {
Dialog: {
Root: createPrimitive('base-dialog-root'),
Trigger: createPrimitive('base-dialog-trigger'),
Title: createPrimitive('base-dialog-title'),
Description: createPrimitive('base-dialog-description'),
Close: createPrimitive('base-dialog-close'),
Portal: createPrimitive('base-dialog-portal'),
Backdrop: createPrimitive('base-dialog-backdrop'),
Popup: createPrimitive('base-dialog-popup'),
},
}
})
describe('Dialog wrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for wrapper-specific structure and content.
describe('Rendering', () => {
it('should render backdrop and popup when DialogContent is rendered', () => {
// Arrange
const contentText = 'dialog body'
// Act
render(
<DialogContent>
<span>{contentText}</span>
</DialogContent>,
)
// Assert
expect(screen.getByTestId('base-dialog-portal')).toBeInTheDocument()
expect(screen.getByTestId('base-dialog-backdrop')).toBeInTheDocument()
expect(screen.getByTestId('base-dialog-popup')).toBeInTheDocument()
expect(screen.getByText(contentText)).toBeInTheDocument()
})
it('should apply default wrapper class names when no override classes are provided', () => {
// Arrange
render(
<DialogContent>
<span>content</span>
</DialogContent>,
)
// Act
const backdrop = screen.getByTestId('base-dialog-backdrop')
const popup = screen.getByTestId('base-dialog-popup')
// Assert
expect(backdrop).toHaveClass('fixed', 'inset-0', 'z-50', 'bg-background-overlay')
expect(backdrop).toHaveClass('transition-opacity', 'duration-150')
expect(backdrop).toHaveClass('data-[ending-style]:opacity-0', 'data-[starting-style]:opacity-0')
expect(popup).toHaveClass('fixed', 'left-1/2', 'top-1/2', 'z-50')
expect(popup).toHaveClass('max-h-[80dvh]', 'w-[480px]', 'max-w-[calc(100vw-2rem)]')
expect(popup).toHaveClass('-translate-x-1/2', '-translate-y-1/2')
expect(popup).toHaveClass('rounded-2xl', 'border-[0.5px]', 'bg-components-panel-bg', 'p-6', 'shadow-xl')
expect(popup).toHaveClass('transition-all', 'duration-150')
expect(popup).toHaveClass(
'data-[ending-style]:scale-95',
'data-[starting-style]:scale-95',
'data-[ending-style]:opacity-0',
'data-[starting-style]:opacity-0',
)
})
})
// Props behavior for class merging and custom styling.
describe('Props', () => {
it('should merge overlayClassName and className with default classes when overrides are provided', () => {
// Arrange
const overlayClassName = 'custom-overlay opacity-90'
const className = 'custom-popup max-w-[640px]'
// Act
render(
<DialogContent overlayClassName={overlayClassName} className={className}>
<span>content</span>
</DialogContent>,
)
const backdrop = screen.getByTestId('base-dialog-backdrop')
const popup = screen.getByTestId('base-dialog-popup')
// Assert
expect(backdrop).toHaveClass('fixed', 'inset-0', 'custom-overlay', 'opacity-90')
expect(popup).toHaveClass('fixed', 'left-1/2', 'custom-popup', 'max-w-[640px]')
expect(popup).not.toHaveClass('max-w-[calc(100vw-2rem)]')
})
it('should render children inside popup when children are provided', () => {
// Arrange
const childText = 'child content'
// Act
render(
<DialogContent>
<div>{childText}</div>
</DialogContent>,
)
const popup = screen.getByTestId('base-dialog-popup')
// Assert
expect(popup).toContainElement(screen.getByText(childText))
})
})
// Export mapping ensures wrapper aliases point to base primitives.
describe('Exports', () => {
it('should map dialog aliases to the matching base dialog primitives', () => {
// Arrange
const basePrimitives = BaseDialog
// Act & Assert
expect(Dialog).toBe(basePrimitives.Root)
expect(DialogTrigger).toBe(basePrimitives.Trigger)
expect(DialogTitle).toBe(basePrimitives.Title)
expect(DialogDescription).toBe(basePrimitives.Description)
expect(DialogClose).toBe(basePrimitives.Close)
})
})
})

View File

@@ -0,0 +1,322 @@
import type { Placement } from '@floating-ui/react'
import { Menu } from '@base-ui/react/menu'
import { render, screen } from '@testing-library/react'
import { parsePlacement } from '@/app/components/base/ui/placement'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuCheckboxItemIndicator,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '../index'
vi.mock('@base-ui/react/menu', async () => {
const React = await import('react')
type PrimitiveProps = React.HTMLAttributes<HTMLDivElement> & {
children?: React.ReactNode
}
type PositionerProps = PrimitiveProps & {
side?: string
align?: string
sideOffset?: number
alignOffset?: number
}
const createPrimitive = (testId: string) => {
const Primitive = React.forwardRef<HTMLDivElement, PrimitiveProps>(({ children, ...props }, ref) => {
return React.createElement('div', { ref, 'data-testid': testId, ...props }, children)
})
Primitive.displayName = testId
return Primitive
}
const Positioner = React.forwardRef<HTMLDivElement, PositionerProps>(({ children, side, align, sideOffset, alignOffset, ...props }, ref) => {
return React.createElement(
'div',
{
ref,
'data-testid': 'menu-positioner',
'data-side': side,
'data-align': align,
'data-side-offset': sideOffset,
'data-align-offset': alignOffset,
...props,
},
children,
)
})
Positioner.displayName = 'menu-positioner'
const Menu = {
Root: createPrimitive('menu-root'),
Portal: createPrimitive('menu-portal'),
Trigger: createPrimitive('menu-trigger'),
SubmenuRoot: createPrimitive('menu-submenu-root'),
Group: createPrimitive('menu-group'),
GroupLabel: createPrimitive('menu-group-label'),
RadioGroup: createPrimitive('menu-radio-group'),
RadioItem: createPrimitive('menu-radio-item'),
RadioItemIndicator: createPrimitive('menu-radio-item-indicator'),
CheckboxItem: createPrimitive('menu-checkbox-item'),
CheckboxItemIndicator: createPrimitive('menu-checkbox-item-indicator'),
Positioner,
Popup: createPrimitive('menu-popup'),
SubmenuTrigger: createPrimitive('menu-submenu-trigger'),
Item: createPrimitive('menu-item'),
Separator: createPrimitive('menu-separator'),
}
return { Menu }
})
vi.mock('@/app/components/base/ui/placement', () => ({
parsePlacement: vi.fn((placement: Placement) => {
const [side, align] = placement.split('-') as [string, string | undefined]
return {
side: side as 'top' | 'right' | 'bottom' | 'left',
align: (align ?? 'center') as 'start' | 'center' | 'end',
}
}),
}))
describe('dropdown-menu wrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Ensures exported aliases stay aligned with the wrapped Menu primitives.
describe('alias exports', () => {
it('should map each alias export to the corresponding Menu primitive', () => {
// Arrange
// Act
// Assert
expect(DropdownMenu).toBe(Menu.Root)
expect(DropdownMenuPortal).toBe(Menu.Portal)
expect(DropdownMenuTrigger).toBe(Menu.Trigger)
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot)
expect(DropdownMenuGroup).toBe(Menu.Group)
expect(DropdownMenuGroupLabel).toBe(Menu.GroupLabel)
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup)
expect(DropdownMenuRadioItem).toBe(Menu.RadioItem)
expect(DropdownMenuRadioItemIndicator).toBe(Menu.RadioItemIndicator)
expect(DropdownMenuCheckboxItem).toBe(Menu.CheckboxItem)
expect(DropdownMenuCheckboxItemIndicator).toBe(Menu.CheckboxItemIndicator)
})
})
describe('DropdownMenuContent', () => {
it('should use default placement and offsets when props are omitted', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
// Act
render(
<DropdownMenuContent>
<span>content child</span>
</DropdownMenuContent>,
)
// Assert
const positioner = screen.getByTestId('menu-positioner')
const popup = screen.getByTestId('menu-popup')
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('bottom-end')
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(positioner).toHaveAttribute('data-side-offset', '4')
expect(positioner).toHaveAttribute('data-align-offset', '0')
expect(positioner).toHaveClass('outline-none')
expect(popup).toHaveClass('rounded-xl')
expect(popup).toHaveClass('py-1')
expect(screen.getByText('content child')).toBeInTheDocument()
})
it('should parse custom placement and merge custom class names', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
// Act
render(
<DropdownMenuContent
placement="top-start"
sideOffset={12}
alignOffset={-3}
className="content-positioner-custom"
popupClassName="content-popup-custom"
>
<span>custom content</span>
</DropdownMenuContent>,
)
// Assert
const positioner = screen.getByTestId('menu-positioner')
const popup = screen.getByTestId('menu-popup')
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('top-start')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(positioner).toHaveAttribute('data-side-offset', '12')
expect(positioner).toHaveAttribute('data-align-offset', '-3')
expect(positioner).toHaveClass('outline-none')
expect(positioner).toHaveClass('content-positioner-custom')
expect(popup).toHaveClass('content-popup-custom')
expect(screen.getByText('custom content')).toBeInTheDocument()
})
})
describe('DropdownMenuSubContent', () => {
it('should use the default sub-content placement and offsets', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
// Act
render(
<DropdownMenuSubContent>
<span>sub content child</span>
</DropdownMenuSubContent>,
)
// Assert
const positioner = screen.getByTestId('menu-positioner')
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('left-start')
expect(positioner).toHaveAttribute('data-side', 'left')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(positioner).toHaveAttribute('data-side-offset', '4')
expect(positioner).toHaveAttribute('data-align-offset', '0')
expect(positioner).toHaveClass('outline-none')
expect(screen.getByText('sub content child')).toBeInTheDocument()
})
it('should parse custom placement and merge popup class names', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
// Act
render(
<DropdownMenuSubContent
placement="right-end"
sideOffset={6}
alignOffset={2}
className="sub-positioner-custom"
popupClassName="sub-popup-custom"
>
<span>custom sub content</span>
</DropdownMenuSubContent>,
)
// Assert
const positioner = screen.getByTestId('menu-positioner')
const popup = screen.getByTestId('menu-popup')
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('right-end')
expect(positioner).toHaveAttribute('data-side', 'right')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(positioner).toHaveAttribute('data-side-offset', '6')
expect(positioner).toHaveAttribute('data-align-offset', '2')
expect(positioner).toHaveClass('outline-none')
expect(positioner).toHaveClass('sub-positioner-custom')
expect(popup).toHaveClass('sub-popup-custom')
})
})
describe('DropdownMenuSubTrigger', () => {
it('should merge className and apply destructive style when destructive is true', () => {
// Arrange
// Act
render(
<DropdownMenuSubTrigger className="sub-trigger-custom" destructive>
Trigger item
</DropdownMenuSubTrigger>,
)
// Assert
const subTrigger = screen.getByTestId('menu-submenu-trigger')
expect(subTrigger).toHaveClass('mx-1')
expect(subTrigger).toHaveClass('sub-trigger-custom')
expect(subTrigger).toHaveClass('text-text-destructive')
})
it('should not apply destructive style when destructive is false', () => {
// Arrange
// Act
render(
<DropdownMenuSubTrigger className="sub-trigger-custom">
Trigger item
</DropdownMenuSubTrigger>,
)
// Assert
expect(screen.getByTestId('menu-submenu-trigger')).not.toHaveClass('text-text-destructive')
})
})
describe('DropdownMenuItem', () => {
it('should merge className and apply destructive style when destructive is true', () => {
// Arrange
// Act
render(
<DropdownMenuItem className="item-custom" destructive>
Item label
</DropdownMenuItem>,
)
// Assert
const item = screen.getByTestId('menu-item')
expect(item).toHaveClass('mx-1')
expect(item).toHaveClass('item-custom')
expect(item).toHaveClass('text-text-destructive')
})
it('should not apply destructive style when destructive is false', () => {
// Arrange
// Act
render(
<DropdownMenuItem className="item-custom">
Item label
</DropdownMenuItem>,
)
// Assert
expect(screen.getByTestId('menu-item')).not.toHaveClass('text-text-destructive')
})
})
describe('DropdownMenuSeparator', () => {
it('should merge custom class names with default separator classes', () => {
// Arrange
// Act
render(<DropdownMenuSeparator className="separator-custom" />)
// Assert
const separator = screen.getByTestId('menu-separator')
expect(separator).toHaveClass('my-1')
expect(separator).toHaveClass('h-px')
expect(separator).toHaveClass('bg-divider-regular')
expect(separator).toHaveClass('separator-custom')
})
})
})

View File

@@ -0,0 +1,187 @@
import type { Placement } from '@floating-ui/react'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { PopoverContent } from '..'
type ParsedPlacement = {
side: 'top' | 'bottom' | 'left' | 'right'
align: 'start' | 'center' | 'end'
}
type PositionerMockProps = ComponentPropsWithoutRef<'div'> & {
side?: ParsedPlacement['side']
align?: ParsedPlacement['align']
sideOffset?: number
alignOffset?: number
}
const positionerPropsSpy = vi.fn<(props: PositionerMockProps) => void>()
const popupClassNameSpy = vi.fn<(className: string | undefined) => void>()
const parsePlacementMock = vi.fn<(placement: Placement) => ParsedPlacement>()
vi.mock('@base-ui/react/popover', () => {
const Root = ({ children, ...props }: ComponentPropsWithoutRef<'div'>) => (
<div {...props}>{children}</div>
)
const Trigger = ({ children, ...props }: ComponentPropsWithoutRef<'button'>) => (
<button type="button" {...props}>{children}</button>
)
const Close = ({ children, ...props }: ComponentPropsWithoutRef<'button'>) => (
<button type="button" {...props}>{children}</button>
)
const Title = ({ children, ...props }: ComponentPropsWithoutRef<'h2'>) => (
<h2 {...props}>{children}</h2>
)
const Description = ({ children, ...props }: ComponentPropsWithoutRef<'p'>) => (
<p {...props}>{children}</p>
)
const Portal = ({ children }: { children?: ReactNode }) => (
<div data-testid="mock-portal">{children}</div>
)
const Positioner = ({ children, ...props }: PositionerMockProps) => {
positionerPropsSpy(props)
return (
<div data-testid="mock-positioner" className={props.className}>
{children}
</div>
)
}
const Popup = ({ children, className }: ComponentPropsWithoutRef<'div'>) => {
popupClassNameSpy(className)
return (
<div data-testid="mock-popup" className={className}>
{children}
</div>
)
}
return {
Popover: {
Root,
Trigger,
Close,
Title,
Description,
Portal,
Positioner,
Popup,
},
}
})
vi.mock('@/app/components/base/ui/placement', () => ({
parsePlacement: (placement: Placement) => parsePlacementMock(placement),
}))
describe('PopoverContent', () => {
beforeEach(() => {
vi.clearAllMocks()
parsePlacementMock.mockReturnValue({
side: 'bottom',
align: 'center',
})
})
describe('Default props', () => {
it('should use bottom placement and default offsets when optional props are not provided', () => {
// Arrange
render(
<PopoverContent>
<span>Default content</span>
</PopoverContent>,
)
// Act
const positioner = screen.getByTestId('mock-positioner')
// Assert
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('bottom')
expect(positionerPropsSpy).toHaveBeenCalledWith(
expect.objectContaining({
side: 'bottom',
align: 'center',
sideOffset: 8,
alignOffset: 0,
}),
)
expect(positioner).toHaveClass('outline-none')
expect(screen.getByText('Default content')).toBeInTheDocument()
})
})
describe('Placement parsing', () => {
it('should use parsePlacement output and forward custom placement offsets to Positioner', () => {
// Arrange
parsePlacementMock.mockReturnValue({
side: 'left',
align: 'end',
})
// Act
render(
<PopoverContent placement="top-end" sideOffset={14} alignOffset={6}>
<span>Parsed content</span>
</PopoverContent>,
)
// Assert
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('top-end')
expect(positionerPropsSpy).toHaveBeenCalledWith(
expect.objectContaining({
side: 'left',
align: 'end',
sideOffset: 14,
alignOffset: 6,
}),
)
})
})
describe('ClassName behavior', () => {
it('should merge custom className values into Positioner and Popup class names', () => {
// Arrange
render(
<PopoverContent className="custom-positioner" popupClassName="custom-popup">
<span>Styled content</span>
</PopoverContent>,
)
// Act
const positioner = screen.getByTestId('mock-positioner')
const popup = screen.getByTestId('mock-popup')
// Assert
expect(positioner).toHaveClass('outline-none')
expect(positioner).toHaveClass('custom-positioner')
expect(popup).toHaveClass('rounded-xl')
expect(popup).toHaveClass('custom-popup')
expect(popupClassNameSpy).toHaveBeenCalledWith(expect.stringContaining('custom-popup'))
})
})
describe('Children rendering', () => {
it('should render children inside Popup', () => {
// Arrange
render(
<PopoverContent>
<button type="button">Child action</button>
</PopoverContent>,
)
// Act
const popup = screen.getByTestId('mock-popup')
// Assert
expect(popup).toContainElement(screen.getByRole('button', { name: 'Child action' }))
})
})
})

View File

@@ -0,0 +1,263 @@
import type { Placement } from '@floating-ui/react'
import type {
ButtonHTMLAttributes,
HTMLAttributes,
ReactNode,
} from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
SelectContent,
SelectItem,
SelectTrigger,
} from '../index'
type ParsedPlacement = {
side: 'top' | 'bottom' | 'left' | 'right'
align: 'start' | 'center' | 'end'
}
const { mockParsePlacement } = vi.hoisted(() => ({
mockParsePlacement: vi.fn<(placement: Placement) => ParsedPlacement>(),
}))
vi.mock('@/app/components/base/ui/placement', () => ({
parsePlacement: mockParsePlacement,
}))
vi.mock('@base-ui/react/select', () => {
type WithChildren = { children?: ReactNode }
type PositionerProps = HTMLAttributes<HTMLDivElement> & {
side?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'center' | 'end'
sideOffset?: number
alignOffset?: number
}
const Root = ({ children }: WithChildren) => <div data-testid="base-select-root">{children}</div>
const Value = ({ children }: WithChildren) => <span data-testid="base-select-value">{children}</span>
const Group = ({ children }: WithChildren) => <div data-testid="base-select-group">{children}</div>
const GroupLabel = ({ children }: WithChildren) => <div data-testid="base-select-group-label">{children}</div>
const Separator = (props: HTMLAttributes<HTMLHRElement>) => <hr data-testid="base-select-separator" {...props} />
const Trigger = ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button data-testid="base-select-trigger" type="button" {...props}>
{children}
</button>
)
const Icon = ({ children, ...props }: HTMLAttributes<HTMLSpanElement>) => (
<span data-testid="base-select-icon" {...props}>
{children}
</span>
)
const Portal = ({ children }: WithChildren) => <div data-testid="base-select-portal">{children}</div>
const Positioner = ({
children,
side,
align,
sideOffset,
alignOffset,
className,
...props
}: PositionerProps) => (
<div
data-align={align}
data-align-offset={alignOffset}
data-side={side}
data-side-offset={sideOffset}
data-testid="base-select-positioner"
className={className}
{...props}
>
{children}
</div>
)
const Popup = ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div data-testid="base-select-popup" {...props}>
{children}
</div>
)
const List = ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div data-testid="base-select-list" {...props}>
{children}
</div>
)
const Item = ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div data-testid="base-select-item" {...props}>
{children}
</div>
)
const ItemText = ({ children, ...props }: HTMLAttributes<HTMLSpanElement>) => (
<span data-testid="base-select-item-text" {...props}>
{children}
</span>
)
const ItemIndicator = ({ children, ...props }: HTMLAttributes<HTMLSpanElement>) => (
<span data-testid="base-select-item-indicator" {...props}>
{children}
</span>
)
return {
Select: {
Root,
Value,
Group,
GroupLabel,
Separator,
Trigger,
Icon,
Portal,
Positioner,
Popup,
List,
Item,
ItemText,
ItemIndicator,
},
}
})
describe('Select wrappers', () => {
beforeEach(() => {
vi.clearAllMocks()
mockParsePlacement.mockReturnValue({ side: 'bottom', align: 'start' })
})
// Covers trigger-level wrapper behavior.
describe('SelectTrigger', () => {
it('should forward trigger props when trigger props are provided', () => {
// Arrange
render(
<SelectTrigger
aria-label="Choose option"
data-testid="custom-trigger"
disabled
>
Trigger Label
</SelectTrigger>,
)
// Assert
const trigger = screen.getByTestId('custom-trigger')
expect(trigger).toBeDisabled()
expect(trigger).toHaveAttribute('aria-label', 'Choose option')
expect(trigger).toHaveAttribute('data-testid', 'custom-trigger')
})
it('should compose default and custom class names when className is provided', () => {
// Arrange & Act
render(<SelectTrigger className="custom-trigger-class">Trigger Label</SelectTrigger>)
// Assert
const trigger = screen.getByTestId('base-select-trigger')
expect(trigger).toHaveClass('group')
expect(trigger).toHaveClass('h-8')
expect(trigger).toHaveClass('custom-trigger-class')
})
it('should render children and icon when content is provided', () => {
// Arrange & Act
render(<SelectTrigger>Trigger Label</SelectTrigger>)
// Assert
expect(screen.getByText('Trigger Label')).toBeInTheDocument()
expect(screen.getByTestId('base-select-icon')).toBeInTheDocument()
})
})
// Covers content placement parsing and positioner forwarding.
describe('SelectContent', () => {
it('should call parsePlacement with default placement when placement is not provided', () => {
// Arrange
mockParsePlacement.mockReturnValueOnce({ side: 'bottom', align: 'start' })
// Act
render(<SelectContent><div>Option A</div></SelectContent>)
// Assert
expect(mockParsePlacement).toHaveBeenCalledWith('bottom-start')
})
it('should pass parsed side align and offsets to Positioner when custom placement and offsets are provided', () => {
// Arrange
mockParsePlacement.mockReturnValueOnce({ side: 'top', align: 'end' })
// Act
render(
<SelectContent alignOffset={6} placement="top-end" sideOffset={12}>
<div>Option A</div>
</SelectContent>,
)
// Assert
const positioner = screen.getByTestId('base-select-positioner')
expect(mockParsePlacement).toHaveBeenCalledWith('top-end')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(positioner).toHaveAttribute('data-side-offset', '12')
expect(positioner).toHaveAttribute('data-align-offset', '6')
})
it('should compose positioner popup and list class names when custom class props are provided', () => {
// Arrange & Act
render(
<SelectContent
className="custom-positioner"
listClassName="custom-list"
popupClassName="custom-popup"
>
<div>Option A</div>
</SelectContent>,
)
// Assert
expect(screen.getByTestId('base-select-positioner')).toHaveClass('outline-none', 'custom-positioner')
expect(screen.getByTestId('base-select-popup')).toHaveClass('rounded-xl', 'custom-popup')
expect(screen.getByTestId('base-select-list')).toHaveClass('max-h-80', 'custom-list')
})
it('should render children inside list when children are provided', () => {
// Arrange & Act
render(
<SelectContent>
<span data-testid="list-child">Option A</span>
</SelectContent>,
)
// Assert
const list = screen.getByTestId('base-select-list')
expect(list).toContainElement(screen.getByTestId('list-child'))
})
})
// Covers option item wrapper behavior.
describe('SelectItem', () => {
it('should forward props and compose class names when item props are provided', () => {
// Arrange & Act
render(
<SelectItem aria-label="City option" className="custom-item-class" data-testid="city-option-item">
Seattle
</SelectItem>,
)
// Assert
const item = screen.getByTestId('city-option-item')
expect(item).toHaveAttribute('aria-label', 'City option')
expect(item).toHaveAttribute('data-testid', 'city-option-item')
expect(item).toHaveClass('h-8')
expect(item).toHaveClass('custom-item-class')
})
it('should render item text and indicator when children are provided', () => {
// Arrange & Act
render(<SelectItem>Seattle</SelectItem>)
// Assert
expect(screen.getByTestId('base-select-item-text')).toHaveTextContent('Seattle')
expect(screen.getByTestId('base-select-item-indicator')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,219 @@
import type { Placement } from '@floating-ui/react'
import type { HTMLAttributes, ReactNode } from 'react'
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../index'
type MockPortalProps = {
children: ReactNode
}
type MockPositionerProps = {
children: ReactNode
side: string
align: string
sideOffset: number
alignOffset: number
className?: string
}
type MockPopupProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode
className?: string
}
vi.mock('@/app/components/base/ui/placement', () => ({
parsePlacement: vi.fn(),
}))
vi.mock('@base-ui/react/tooltip', () => ({
Tooltip: {
Portal: ({ children }: MockPortalProps) => (
<div data-testid="tooltip-portal">{children}</div>
),
Positioner: ({
children,
side,
align,
sideOffset,
alignOffset,
className,
}: MockPositionerProps) => (
<div
data-testid="tooltip-positioner"
data-side={side}
data-align={align}
data-side-offset={String(sideOffset)}
data-align-offset={String(alignOffset)}
className={className}
>
{children}
</div>
),
Popup: ({ children, className, ...props }: MockPopupProps) => (
<div data-testid="tooltip-popup" className={className} {...props}>
{children}
</div>
),
Provider: ({ children }: MockPortalProps) => (
<div data-testid="tooltip-provider">{children}</div>
),
Root: ({ children }: MockPortalProps) => (
<div data-testid="tooltip-root">{children}</div>
),
Trigger: ({ children }: MockPortalProps) => (
<button data-testid="tooltip-trigger" type="button">
{children}
</button>
),
},
}))
const mockParsePlacement = vi.mocked(parsePlacement)
describe('TooltipContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockParsePlacement.mockReturnValue({ side: 'top', align: 'center' })
})
describe('Placement and offsets', () => {
it('should use default placement and offsets when optional props are not provided', () => {
// Arrange
render(<TooltipContent>Tooltip body</TooltipContent>)
// Act
const positioner = screen.getByTestId('tooltip-positioner')
// Assert
expect(mockParsePlacement).toHaveBeenCalledWith('top')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'center')
expect(positioner).toHaveAttribute('data-side-offset', '8')
expect(positioner).toHaveAttribute('data-align-offset', '0')
})
it('should use parsed placement and custom offsets when placement props are provided', () => {
// Arrange
mockParsePlacement.mockReturnValue({ side: 'bottom', align: 'start' })
const customPlacement: Placement = 'bottom-start'
// Act
render(
<TooltipContent
placement={customPlacement}
sideOffset={16}
alignOffset={6}
>
Tooltip body
</TooltipContent>,
)
const positioner = screen.getByTestId('tooltip-positioner')
// Assert
expect(mockParsePlacement).toHaveBeenCalledWith(customPlacement)
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(positioner).toHaveAttribute('data-side-offset', '16')
expect(positioner).toHaveAttribute('data-align-offset', '6')
})
})
describe('Class behavior', () => {
it('should merge the positioner className with wrapper base class', () => {
// Arrange
render(<TooltipContent className="custom-positioner">Tooltip body</TooltipContent>)
// Act
const positioner = screen.getByTestId('tooltip-positioner')
// Assert
expect(positioner).toHaveClass('outline-none')
expect(positioner).toHaveClass('custom-positioner')
})
it('should apply default variant popup classes and merge popupClassName when variant is default', () => {
// Arrange
render(
<TooltipContent popupClassName="custom-popup">
Tooltip body
</TooltipContent>,
)
// Act
const popup = screen.getByTestId('tooltip-popup')
// Assert
expect(popup.className).toContain('bg-components-panel-bg')
expect(popup.className).toContain('rounded-md')
expect(popup).toHaveClass('custom-popup')
})
it('should avoid default variant popup classes when variant is plain', () => {
// Arrange
render(
<TooltipContent variant="plain" popupClassName="plain-popup">
Tooltip body
</TooltipContent>,
)
// Act
const popup = screen.getByTestId('tooltip-popup')
// Assert
expect(popup).toHaveClass('plain-popup')
expect(popup.className).not.toContain('bg-components-panel-bg')
expect(popup.className).not.toContain('rounded-md')
})
})
describe('Popup prop forwarding', () => {
it('should forward popup props to BaseTooltip.Popup when popup props are provided', () => {
// Arrange
render(
<TooltipContent
id="popup-id"
role="tooltip"
aria-label="help text"
data-track-id="tooltip-track"
>
Tooltip body
</TooltipContent>,
)
// Act
const popup = screen.getByTestId('tooltip-popup')
// Assert
expect(popup).toHaveAttribute('id', 'popup-id')
expect(popup).toHaveAttribute('role', 'tooltip')
expect(popup).toHaveAttribute('aria-label', 'help text')
expect(popup).toHaveAttribute('data-track-id', 'tooltip-track')
})
})
})
describe('Tooltip aliases', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should map alias exports to BaseTooltip components when wrapper exports are imported', () => {
// Arrange
const provider = BaseTooltip.Provider
const root = BaseTooltip.Root
const trigger = BaseTooltip.Trigger
// Act
const exportedProvider = TooltipProvider
const exportedTooltip = Tooltip
const exportedTrigger = TooltipTrigger
// Assert
expect(exportedProvider).toBe(provider)
expect(exportedTooltip).toBe(root)
expect(exportedTrigger).toBe(trigger)
})
})