diff --git a/web/app/components/base/ui/dialog/__tests__/index.spec.tsx b/web/app/components/base/ui/dialog/__tests__/index.spec.tsx new file mode 100644 index 0000000000..eb8cb179a6 --- /dev/null +++ b/web/app/components/base/ui/dialog/__tests__/index.spec.tsx @@ -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) => ( +
+ {children} +
+ )) + } + + 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( + + {contentText} + , + ) + + // 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( + + content + , + ) + + // 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( + + content + , + ) + + 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( + +
{childText}
+
, + ) + + 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) + }) + }) +}) diff --git a/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx new file mode 100644 index 0000000000..4ccf74209b --- /dev/null +++ b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx @@ -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 & { + children?: React.ReactNode + } + + type PositionerProps = PrimitiveProps & { + side?: string + align?: string + sideOffset?: number + alignOffset?: number + } + + const createPrimitive = (testId: string) => { + const Primitive = React.forwardRef(({ children, ...props }, ref) => { + return React.createElement('div', { ref, 'data-testid': testId, ...props }, children) + }) + Primitive.displayName = testId + return Primitive + } + + const Positioner = React.forwardRef(({ 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( + + content child + , + ) + + // 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( + + custom content + , + ) + + // 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( + + sub content child + , + ) + + // 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( + + custom sub content + , + ) + + // 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( + + Trigger item + , + ) + + // 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( + + Trigger item + , + ) + + // 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( + + Item label + , + ) + + // 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( + + Item label + , + ) + + // 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() + + // 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') + }) + }) +}) diff --git a/web/app/components/base/ui/popover/__tests__/index.spec.tsx b/web/app/components/base/ui/popover/__tests__/index.spec.tsx new file mode 100644 index 0000000000..d9176e26c0 --- /dev/null +++ b/web/app/components/base/ui/popover/__tests__/index.spec.tsx @@ -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'>) => ( +
{children}
+ ) + + const Trigger = ({ children, ...props }: ComponentPropsWithoutRef<'button'>) => ( + + ) + + const Close = ({ children, ...props }: ComponentPropsWithoutRef<'button'>) => ( + + ) + + const Title = ({ children, ...props }: ComponentPropsWithoutRef<'h2'>) => ( +

{children}

+ ) + + const Description = ({ children, ...props }: ComponentPropsWithoutRef<'p'>) => ( +

{children}

+ ) + + const Portal = ({ children }: { children?: ReactNode }) => ( +
{children}
+ ) + + const Positioner = ({ children, ...props }: PositionerMockProps) => { + positionerPropsSpy(props) + return ( +
+ {children} +
+ ) + } + + const Popup = ({ children, className }: ComponentPropsWithoutRef<'div'>) => { + popupClassNameSpy(className) + return ( +
+ {children} +
+ ) + } + + 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( + + Default content + , + ) + + // 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( + + Parsed content + , + ) + + // 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( + + Styled content + , + ) + + // 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( + + + , + ) + + // Act + const popup = screen.getByTestId('mock-popup') + + // Assert + expect(popup).toContainElement(screen.getByRole('button', { name: 'Child action' })) + }) + }) +}) diff --git a/web/app/components/base/ui/select/__tests__/index.spec.tsx b/web/app/components/base/ui/select/__tests__/index.spec.tsx new file mode 100644 index 0000000000..386b25c4f2 --- /dev/null +++ b/web/app/components/base/ui/select/__tests__/index.spec.tsx @@ -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 & { + side?: 'top' | 'bottom' | 'left' | 'right' + align?: 'start' | 'center' | 'end' + sideOffset?: number + alignOffset?: number + } + + const Root = ({ children }: WithChildren) =>
{children}
+ const Value = ({ children }: WithChildren) => {children} + const Group = ({ children }: WithChildren) =>
{children}
+ const GroupLabel = ({ children }: WithChildren) =>
{children}
+ const Separator = (props: HTMLAttributes) =>
+ + const Trigger = ({ children, ...props }: ButtonHTMLAttributes) => ( + + ) + const Icon = ({ children, ...props }: HTMLAttributes) => ( + + {children} + + ) + + const Portal = ({ children }: WithChildren) =>
{children}
+ const Positioner = ({ + children, + side, + align, + sideOffset, + alignOffset, + className, + ...props + }: PositionerProps) => ( +
+ {children} +
+ ) + const Popup = ({ children, ...props }: HTMLAttributes) => ( +
+ {children} +
+ ) + const List = ({ children, ...props }: HTMLAttributes) => ( +
+ {children} +
+ ) + + const Item = ({ children, ...props }: HTMLAttributes) => ( +
+ {children} +
+ ) + const ItemText = ({ children, ...props }: HTMLAttributes) => ( + + {children} + + ) + const ItemIndicator = ({ children, ...props }: HTMLAttributes) => ( + + {children} + + ) + + 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( + + Trigger Label + , + ) + + // 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(Trigger Label) + + // 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(Trigger Label) + + // 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(
Option A
) + + // 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( + +
Option A
+
, + ) + + // 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( + +
Option A
+
, + ) + + // 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( + + Option A + , + ) + + // 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( + + Seattle + , + ) + + // 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(Seattle) + + // Assert + expect(screen.getByTestId('base-select-item-text')).toHaveTextContent('Seattle') + expect(screen.getByTestId('base-select-item-indicator')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx b/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx new file mode 100644 index 0000000000..3432b19eaa --- /dev/null +++ b/web/app/components/base/ui/tooltip/__tests__/index.spec.tsx @@ -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 & { + children: ReactNode + className?: string +} + +vi.mock('@/app/components/base/ui/placement', () => ({ + parsePlacement: vi.fn(), +})) + +vi.mock('@base-ui/react/tooltip', () => ({ + Tooltip: { + Portal: ({ children }: MockPortalProps) => ( +
{children}
+ ), + Positioner: ({ + children, + side, + align, + sideOffset, + alignOffset, + className, + }: MockPositionerProps) => ( +
+ {children} +
+ ), + Popup: ({ children, className, ...props }: MockPopupProps) => ( +
+ {children} +
+ ), + Provider: ({ children }: MockPortalProps) => ( +
{children}
+ ), + Root: ({ children }: MockPortalProps) => ( +
{children}
+ ), + Trigger: ({ children }: MockPortalProps) => ( + + ), + }, +})) + +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(Tooltip body) + + // 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( + + Tooltip body + , + ) + 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(Tooltip body) + + // 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( + + Tooltip body + , + ) + + // 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( + + Tooltip body + , + ) + + // 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( + + Tooltip body + , + ) + + // 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) + }) +})