diff --git a/web/app/components/base/ui/dialog/__tests__/index.spec.tsx b/web/app/components/base/ui/dialog/__tests__/index.spec.tsx
index eb8cb179a6..1c9cb5d12f 100644
--- a/web/app/components/base/ui/dialog/__tests__/index.spec.tsx
+++ b/web/app/components/base/ui/dialog/__tests__/index.spec.tsx
@@ -11,27 +11,45 @@ import {
DialogTrigger,
} from '../index'
-type PrimitiveProps = ComponentPropsWithoutRef<'div'>
+type DivPrimitiveProps = ComponentPropsWithoutRef<'div'>
+type ButtonPrimitiveProps = ComponentPropsWithoutRef<'button'>
+type SectionPrimitiveProps = ComponentPropsWithoutRef<'section'>
vi.mock('@base-ui/react/dialog', () => {
- const createPrimitive = (testId: string) => {
- return vi.fn(({ children, ...props }: PrimitiveProps) => (
-
+ const createDivPrimitive = () => {
+ return vi.fn(({ children, ...props }: DivPrimitiveProps) => (
+
{children}
))
}
+ const createButtonPrimitive = () => {
+ return vi.fn(({ children, ...props }: ButtonPrimitiveProps) => (
+
+ ))
+ }
+
+ const createPopupPrimitive = () => {
+ return vi.fn(({ children, ...props }: SectionPrimitiveProps) => (
+
+ ))
+ }
+
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'),
+ Root: createDivPrimitive(),
+ Trigger: createDivPrimitive(),
+ Title: createDivPrimitive(),
+ Description: createDivPrimitive(),
+ Close: createButtonPrimitive(),
+ Portal: createDivPrimitive(),
+ Backdrop: createDivPrimitive(),
+ Popup: createPopupPrimitive(),
},
}
})
@@ -43,7 +61,7 @@ describe('Dialog wrapper', () => {
// Rendering behavior for wrapper-specific structure and content.
describe('Rendering', () => {
- it('should render backdrop and popup when DialogContent is rendered', () => {
+ it('should render portal structure and dialog content when DialogContent is rendered', () => {
// Arrange
const contentText = 'dialog body'
@@ -55,81 +73,56 @@ describe('Dialog wrapper', () => {
)
// Assert
- expect(screen.getByTestId('base-dialog-portal')).toBeInTheDocument()
- expect(screen.getByTestId('base-dialog-backdrop')).toBeInTheDocument()
- expect(screen.getByTestId('base-dialog-popup')).toBeInTheDocument()
+ expect(vi.mocked(BaseDialog.Portal)).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(BaseDialog.Backdrop)).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(BaseDialog.Popup)).toHaveBeenCalledTimes(1)
+ expect(screen.getByRole('dialog')).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.
+ // Props behavior for closable semantics and defaults.
describe('Props', () => {
- it('should merge overlayClassName and className with default classes when overrides are provided', () => {
+ it('should not render close button when closable is omitted', () => {
// 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)]')
+ expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
})
- it('should render children inside popup when children are provided', () => {
+ it('should not render close button when closable is false', () => {
// Arrange
- const childText = 'child content'
-
- // Act
render(
-
- {childText}
+
+ content
,
)
- const popup = screen.getByTestId('base-dialog-popup')
+ // Assert
+ expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
+ })
+
+ it('should render semantic close button when closable is true', () => {
+ // Arrange
+ render(
+
+ content
+ ,
+ )
// Assert
- expect(popup).toContainElement(screen.getByText(childText))
+ const closeButton = screen.getByRole('button', { name: 'Close' })
+ expect(closeButton).toHaveAttribute('aria-label', 'Close')
+ expect(screen.getByRole('dialog')).toContainElement(closeButton)
+
+ const closeIcon = closeButton.querySelector('span')
+ expect(closeIcon).toBeInTheDocument()
+ expect(closeButton).toContainElement(closeIcon)
})
})
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
index f0c0a0d019..a11634a6ef 100644
--- a/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx
+++ b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx
@@ -1,19 +1,14 @@
-import type { Placement } from '@floating-ui/react'
+import type { Placement } from '@/app/components/base/ui/placement'
import { Menu } from '@base-ui/react/menu'
-import { render, screen } from '@testing-library/react'
+import { fireEvent, render, screen, within } 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,
@@ -28,6 +23,11 @@ vi.mock('@base-ui/react/menu', async () => {
children?: React.ReactNode
}
+ type PrimitiveOptions = {
+ displayName: string
+ defaultRole?: React.AriaRole
+ }
+
type PositionerProps = PrimitiveProps & {
side?: string
align?: string
@@ -35,20 +35,33 @@ vi.mock('@base-ui/react/menu', async () => {
alignOffset?: number
}
- const createPrimitive = (testId: string) => {
- const Primitive = React.forwardRef(({ children, ...props }, ref) => {
- return React.createElement('div', { ref, 'data-testid': testId, ...props }, children)
+ const createPrimitive = ({ displayName, defaultRole }: PrimitiveOptions) => {
+ const Primitive = React.forwardRef(({ children, role, ...props }, ref) => {
+ return React.createElement(
+ 'div',
+ {
+ ref,
+ role: role ?? defaultRole,
+ ...props,
+ },
+ children,
+ )
})
- Primitive.displayName = testId
+ Primitive.displayName = displayName
return Primitive
}
- const Positioner = React.forwardRef(({ children, side, align, sideOffset, alignOffset, ...props }, ref) => {
+ const Portal = ({ children }: PrimitiveProps) => {
+ return React.createElement(React.Fragment, null, children)
+ }
+ Portal.displayName = 'menu-portal'
+
+ const Positioner = React.forwardRef(({ children, role, side, align, sideOffset, alignOffset, ...props }, ref) => {
return React.createElement(
'div',
{
ref,
- 'data-testid': 'menu-positioner',
+ 'role': role ?? 'group',
'data-side': side,
'data-align': align,
'data-side-offset': sideOffset,
@@ -61,22 +74,22 @@ vi.mock('@base-ui/react/menu', async () => {
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'),
+ Root: createPrimitive({ displayName: 'menu-root' }),
+ Portal,
+ Trigger: createPrimitive({ displayName: 'menu-trigger', defaultRole: 'button' }),
+ SubmenuRoot: createPrimitive({ displayName: 'menu-submenu-root' }),
+ Group: createPrimitive({ displayName: 'menu-group', defaultRole: 'group' }),
+ GroupLabel: createPrimitive({ displayName: 'menu-group-label' }),
+ RadioGroup: createPrimitive({ displayName: 'menu-radio-group', defaultRole: 'radiogroup' }),
+ RadioItem: createPrimitive({ displayName: 'menu-radio-item', defaultRole: 'menuitemradio' }),
+ RadioItemIndicator: createPrimitive({ displayName: 'menu-radio-item-indicator' }),
+ CheckboxItem: createPrimitive({ displayName: 'menu-checkbox-item', defaultRole: 'menuitemcheckbox' }),
+ CheckboxItemIndicator: createPrimitive({ displayName: 'menu-checkbox-item-indicator' }),
Positioner,
- Popup: createPrimitive('menu-popup'),
- SubmenuTrigger: createPrimitive('menu-submenu-trigger'),
- Item: createPrimitive('menu-item'),
- Separator: createPrimitive('menu-separator'),
+ Popup: createPrimitive({ displayName: 'menu-popup', defaultRole: 'menu' }),
+ SubmenuTrigger: createPrimitive({ displayName: 'menu-submenu-trigger', defaultRole: 'menuitem' }),
+ Item: createPrimitive({ displayName: 'menu-item', defaultRole: 'menuitem' }),
+ Separator: createPrimitive({ displayName: 'menu-separator', defaultRole: 'separator' }),
}
return { Menu }
@@ -99,7 +112,7 @@ describe('dropdown-menu wrapper', () => {
// Ensures exported aliases stay aligned with the wrapped Menu primitives.
describe('alias exports', () => {
- it('should map each alias export to the corresponding Menu primitive', () => {
+ it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => {
// Arrange
// Act
@@ -110,44 +123,38 @@ describe('dropdown-menu wrapper', () => {
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)
})
})
+ // Verifies content popup placement and passthrough behavior.
describe('DropdownMenuContent', () => {
- it('should use default placement and offsets when props are omitted', () => {
+ it('should position content at bottom-end with default offsets when placement 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')
+ const popup = screen.getByRole('menu')
+ const positioner = popup.parentElement
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('bottom-end')
+ expect(positioner).not.toBeNull()
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()
+ expect(within(popup).getByRole('button', { name: 'content action' })).toBeInTheDocument()
})
- it('should parse custom placement and merge custom class names', () => {
+ it('should apply custom placement offsets when custom positioning props are provided', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
@@ -157,41 +164,42 @@ describe('dropdown-menu wrapper', () => {
placement="top-start"
sideOffset={12}
alignOffset={-3}
- className="content-positioner-custom"
- popupClassName="content-popup-custom"
>
custom content
,
)
// Assert
- const positioner = screen.getByTestId('menu-positioner')
- const popup = screen.getByTestId('menu-popup')
+ const popup = screen.getByRole('menu')
+ const positioner = popup.parentElement
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('top-start')
+ expect(positioner).not.toBeNull()
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()
+ expect(within(popup).getByText('custom content')).toBeInTheDocument()
})
- it('should forward positioner and popup passthrough props when passthrough props are provided', () => {
+ it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
// Arrange
+ const handlePositionerMouseEnter = vi.fn()
+ const handlePopupClick = vi.fn()
// Act
render(
passthrough content
@@ -199,41 +207,50 @@ describe('dropdown-menu wrapper', () => {
)
// Assert
- const positioner = screen.getByTestId('menu-positioner')
- const popup = screen.getByTestId('menu-popup')
- expect(positioner).toHaveAttribute('aria-label', 'dropdown content positioner')
- expect(popup).toHaveAttribute('role', 'menu')
- expect(popup).toHaveAttribute('aria-label', 'dropdown content popup')
+ const positioner = screen.getByRole('group', { name: 'dropdown content positioner' })
+ const popup = screen.getByRole('menu', { name: 'dropdown content popup' })
+ fireEvent.mouseEnter(positioner)
+ fireEvent.click(popup)
+
+ expect(positioner).toHaveAttribute('id', 'dropdown-content-positioner')
+ expect(popup).toHaveAttribute('id', 'dropdown-content-popup')
+ expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
+ expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
+ // Verifies submenu popup placement and passthrough behavior.
describe('DropdownMenuSubContent', () => {
- it('should use the default sub-content placement and offsets', () => {
+ it('should position sub-content at left-start with default offsets when props are omitted', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
// Act
render(
- sub content child
+
,
)
// Assert
- const positioner = screen.getByTestId('menu-positioner')
+ const popup = screen.getByRole('menu')
+ const positioner = popup.parentElement
+
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('left-start')
+ expect(positioner).not.toBeNull()
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()
+ expect(within(popup).getByRole('button', { name: 'sub action' })).toBeInTheDocument()
})
- it('should parse custom placement and merge popup class names', () => {
+ it('should apply custom placement offsets and forward passthrough props when custom sub-content props are provided', () => {
// Arrange
const parsePlacementMock = vi.mocked(parsePlacement)
+ const handlePositionerFocus = vi.fn()
+ const handlePopupClick = vi.fn()
// Act
render(
@@ -241,16 +258,26 @@ describe('dropdown-menu wrapper', () => {
placement="right-end"
sideOffset={6}
alignOffset={2}
- className="sub-positioner-custom"
- popupClassName="sub-popup-custom"
+ positionerProps={{
+ 'aria-label': 'dropdown sub positioner',
+ 'id': 'dropdown-sub-positioner',
+ 'onFocus': handlePositionerFocus,
+ }}
+ popupProps={{
+ 'aria-label': 'dropdown sub popup',
+ 'id': 'dropdown-sub-popup',
+ 'onClick': handlePopupClick,
+ }}
>
custom sub content
,
)
// Assert
- const positioner = screen.getByTestId('menu-positioner')
- const popup = screen.getByTestId('menu-popup')
+ const positioner = screen.getByRole('group', { name: 'dropdown sub positioner' })
+ const popup = screen.getByRole('menu', { name: 'dropdown sub popup' })
+ fireEvent.focus(positioner)
+ fireEvent.click(popup)
expect(parsePlacementMock).toHaveBeenCalledTimes(1)
expect(parsePlacementMock).toHaveBeenCalledWith('right-end')
@@ -258,117 +285,123 @@ describe('dropdown-menu wrapper', () => {
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')
- })
-
- it('should forward passthrough props for sub-content positioner and popup when passthrough props are provided', () => {
- // Arrange
-
- // Act
- render(
-
- passthrough sub content
- ,
- )
-
- // Assert
- const positioner = screen.getByTestId('menu-positioner')
- const popup = screen.getByTestId('menu-popup')
- expect(positioner).toHaveAttribute('aria-label', 'dropdown sub positioner')
- expect(popup).toHaveAttribute('role', 'menu')
- expect(popup).toHaveAttribute('aria-label', 'dropdown sub popup')
+ expect(positioner).toHaveAttribute('id', 'dropdown-sub-positioner')
+ expect(popup).toHaveAttribute('id', 'dropdown-sub-popup')
+ expect(handlePositionerFocus).toHaveBeenCalledTimes(1)
+ expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
+ // Covers submenu trigger behavior with and without destructive flag.
describe('DropdownMenuSubTrigger', () => {
- it('should merge className and apply destructive style when destructive is true', () => {
+ it('should render label and submenu chevron when trigger children are provided', () => {
// 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')
+ const subTrigger = screen.getByRole('menuitem', { name: 'Trigger item' })
+ expect(subTrigger.querySelector('span[aria-hidden="true"]')).not.toBeNull()
})
- it('should not apply destructive style when destructive is false', () => {
+ it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
// Arrange
+ const handleClick = vi.fn()
// Act
render(
-
+ ,
)
// Assert
- expect(screen.getByTestId('menu-submenu-trigger')).not.toHaveClass('text-text-destructive')
+ const subTrigger = screen.getByRole('menuitem', { name: 'submenu action' })
+ fireEvent.click(subTrigger)
+
+ expect(subTrigger).toHaveAttribute('id', `submenu-trigger-${String(destructive)}`)
+ expect(subTrigger).not.toHaveAttribute('destructive')
+ expect(handleClick).toHaveBeenCalledTimes(1)
})
})
+ // Covers menu item behavior with and without destructive flag.
describe('DropdownMenuItem', () => {
- it('should merge className and apply destructive style when destructive is true', () => {
+ it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
// Arrange
+ const handleClick = vi.fn()
// Act
render(
-
+ ,
)
// Assert
- const item = screen.getByTestId('menu-item')
- expect(item).toHaveClass('mx-1')
- expect(item).toHaveClass('item-custom')
- expect(item).toHaveClass('text-text-destructive')
- })
+ const item = screen.getByRole('menuitem', { name: 'menu action' })
+ fireEvent.click(item)
- 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')
+ expect(item).toHaveAttribute('id', `menu-item-${String(destructive)}`)
+ expect(item).not.toHaveAttribute('destructive')
+ expect(handleClick).toHaveBeenCalledTimes(1)
})
})
+ // Verifies separator semantics and row separation behavior.
describe('DropdownMenuSeparator', () => {
- it('should merge custom class names with default separator classes', () => {
+ it('should forward passthrough props and handlers when separator props are provided', () => {
+ // Arrange
+ const handleMouseEnter = vi.fn()
+
+ // Act
+ render(
+ ,
+ )
+
+ // Assert
+ const separator = screen.getByRole('separator', { name: 'actions divider' })
+ fireEvent.mouseEnter(separator)
+
+ expect(separator).toHaveAttribute('id', 'menu-separator')
+ expect(handleMouseEnter).toHaveBeenCalledTimes(1)
+ })
+
+ it('should keep surrounding menu rows rendered when separator is placed between items', () => {
// Arrange
// Act
- render()
+ render(
+ <>
+ First action
+
+ Second action
+ >,
+ )
// 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')
+ expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
+ expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
+ expect(screen.getAllByRole('separator')).toHaveLength(1)
})
})
})
diff --git a/web/app/components/base/ui/popover/__tests__/index.spec.tsx b/web/app/components/base/ui/popover/__tests__/index.spec.tsx
index c509d0eefb..9712bc2589 100644
--- a/web/app/components/base/ui/popover/__tests__/index.spec.tsx
+++ b/web/app/components/base/ui/popover/__tests__/index.spec.tsx
@@ -1,68 +1,85 @@
-import type { Placement } from '@floating-ui/react'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
+import { Popover as BasePopover } from '@base-ui/react/popover'
import { render, screen } from '@testing-library/react'
-import { PopoverContent } from '..'
+import {
+ Popover,
+ PopoverClose,
+ PopoverContent,
+ PopoverDescription,
+ PopoverTitle,
+ PopoverTrigger,
+} from '..'
-type ParsedPlacement = {
- side: 'top' | 'bottom' | 'left' | 'right'
- align: 'start' | 'center' | 'end'
+type PrimitiveProps = ComponentPropsWithoutRef<'div'> & {
+ children?: ReactNode
}
-type PositionerMockProps = ComponentPropsWithoutRef<'div'> & {
- side?: ParsedPlacement['side']
- align?: ParsedPlacement['align']
+type PositionerProps = PrimitiveProps & {
+ side?: 'top' | 'bottom' | 'left' | 'right'
+ align?: 'start' | 'center' | 'end'
sideOffset?: number
alignOffset?: number
}
-const positionerPropsSpy = vi.fn<(props: PositionerMockProps) => void>()
-const popupClassNameSpy = vi.fn<(className: string | undefined) => void>()
-const popupPropsSpy = vi.fn<(props: ComponentPropsWithoutRef<'div'>) => void>()
-const parsePlacementMock = vi.fn<(placement: Placement) => ParsedPlacement>()
-
vi.mock('@base-ui/react/popover', () => {
- const Root = ({ children, ...props }: ComponentPropsWithoutRef<'div'>) => (
- {children}
+ const Root = ({ children, ...props }: PrimitiveProps) => (
+
+ {children}
+
)
const Trigger = ({ children, ...props }: ComponentPropsWithoutRef<'button'>) => (
-
+
)
const Close = ({ children, ...props }: ComponentPropsWithoutRef<'button'>) => (
-
+
)
const Title = ({ children, ...props }: ComponentPropsWithoutRef<'h2'>) => (
- {children}
+
+ {children}
+
)
const Description = ({ children, ...props }: ComponentPropsWithoutRef<'p'>) => (
- {children}
+
+ {children}
+
)
- const Portal = ({ children }: { children?: ReactNode }) => (
- {children}
+ const Portal = ({ children }: PrimitiveProps) => (
+ {children}
)
- const Positioner = ({ children, ...props }: PositionerMockProps) => {
- positionerPropsSpy(props)
- return (
-
- {children}
-
- )
- }
+ const Positioner = ({
+ children,
+ side,
+ align,
+ sideOffset,
+ alignOffset,
+ ...props
+ }: PositionerProps) => (
+
+ {children}
+
+ )
- const Popup = ({ children, className, ...props }: ComponentPropsWithoutRef<'div'>) => {
- popupClassNameSpy(className)
- popupPropsSpy({ className, ...props })
- return (
-
- {children}
-
- )
- }
+ const Popup = ({ children, ...props }: PrimitiveProps) => (
+
+ {children}
+
+ )
return {
Popover: {
@@ -78,98 +95,57 @@ vi.mock('@base-ui/react/popover', () => {
}
})
-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', () => {
+ // Placement and default value behaviors.
+ describe('Placement', () => {
+ it('should use bottom placement and default offsets when placement props are not provided', () => {
// Arrange
render(
-
+
Default content
,
)
// Act
- const positioner = screen.getByTestId('mock-positioner')
+ const positioner = screen.getByLabelText('default 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(positioner).toHaveAttribute('data-side', 'bottom')
+ expect(positioner).toHaveAttribute('data-align', 'center')
+ expect(positioner).toHaveAttribute('data-side-offset', '8')
+ expect(positioner).toHaveAttribute('data-align-offset', '0')
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', () => {
+ it('should apply parsed custom placement and custom offsets when placement props are provided', () => {
// Arrange
render(
-
- Styled content
+
+ Custom placement content
,
)
// Act
- const positioner = screen.getByTestId('mock-positioner')
- const popup = screen.getByTestId('mock-popup')
+ const positioner = screen.getByLabelText('custom positioner')
// 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'))
+ expect(positioner).toHaveAttribute('data-side', 'top')
+ expect(positioner).toHaveAttribute('data-align', 'end')
+ expect(positioner).toHaveAttribute('data-side-offset', '14')
+ expect(positioner).toHaveAttribute('data-align-offset', '6')
})
})
+ // Passthrough behavior for delegated primitives.
describe('Passthrough props', () => {
it('should forward positionerProps and popupProps when passthrough props are provided', () => {
// Arrange
@@ -179,6 +155,7 @@ describe('PopoverContent', () => {
'aria-label': 'popover positioner',
}}
popupProps={{
+ 'id': 'popover-popup-id',
'role': 'dialog',
'aria-label': 'popover content',
}}
@@ -188,39 +165,35 @@ describe('PopoverContent', () => {
)
// Act
- const popup = screen.getByTestId('mock-popup')
+ const positioner = screen.getByLabelText('popover positioner')
+ const popup = screen.getByRole('dialog', { name: 'popover content' })
// Assert
- expect(positionerPropsSpy).toHaveBeenCalledWith(
- expect.objectContaining({
- 'aria-label': 'popover positioner',
- }),
- )
- expect(popupPropsSpy).toHaveBeenCalledWith(
- expect.objectContaining({
- 'role': 'dialog',
- 'aria-label': 'popover content',
- }),
- )
+ expect(positioner).toHaveAttribute('aria-label', 'popover positioner')
+ expect(popup).toHaveAttribute('id', 'popover-popup-id')
expect(popup).toHaveAttribute('role', 'dialog')
expect(popup).toHaveAttribute('aria-label', 'popover content')
})
})
+})
- describe('Children rendering', () => {
- it('should render children inside Popup', () => {
+describe('Popover aliases', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // Export mapping behavior to keep wrapper aliases aligned.
+ describe('Export mapping', () => {
+ it('should map aliases to the matching base popover primitives when wrapper exports are imported', () => {
// Arrange
- render(
-
-
- ,
- )
+ const basePrimitives = BasePopover
- // Act
- const popup = screen.getByTestId('mock-popup')
-
- // Assert
- expect(popup).toContainElement(screen.getByRole('button', { name: 'Child action' }))
+ // Act & Assert
+ expect(Popover).toBe(basePrimitives.Root)
+ expect(PopoverTrigger).toBe(basePrimitives.Trigger)
+ expect(PopoverClose).toBe(basePrimitives.Close)
+ expect(PopoverTitle).toBe(basePrimitives.Title)
+ expect(PopoverDescription).toBe(basePrimitives.Description)
})
})
})
diff --git a/web/app/components/base/ui/select/__tests__/index.spec.tsx b/web/app/components/base/ui/select/__tests__/index.spec.tsx
index ca7586c7e7..c520ca4f44 100644
--- a/web/app/components/base/ui/select/__tests__/index.spec.tsx
+++ b/web/app/components/base/ui/select/__tests__/index.spec.tsx
@@ -1,10 +1,9 @@
-import type { Placement } from '@floating-ui/react'
import type {
ButtonHTMLAttributes,
HTMLAttributes,
ReactNode,
} from 'react'
-import { render, screen } from '@testing-library/react'
+import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
SelectContent,
@@ -18,7 +17,7 @@ type ParsedPlacement = {
}
const { mockParsePlacement } = vi.hoisted(() => ({
- mockParsePlacement: vi.fn<(placement: Placement) => ParsedPlacement>(),
+ mockParsePlacement: vi.fn<(placement: string) => ParsedPlacement>(),
}))
vi.mock('@/app/components/base/ui/placement', () => ({
@@ -32,41 +31,43 @@ vi.mock('@base-ui/react/select', () => {
align?: 'start' | 'center' | 'end'
sideOffset?: number
alignOffset?: number
+ alignItemWithTrigger?: boolean
}
- 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 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) => (
-