feat(web): add context menu primitive and dropdown link item (#33125)

This commit is contained in:
yyh 2026-03-09 12:05:38 +08:00 committed by GitHub
parent 66f9fde2fe
commit 0590b09958
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 992 additions and 76 deletions

View File

@ -2,6 +2,12 @@
- Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions. - Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions.
## Overlay Components (Mandatory)
- `./docs/overlay-migration.md` is the source of truth for overlay-related work.
- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`.
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding).
## Automated Test Generation ## Automated Test Generation
- Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests. - Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests.

View File

@ -0,0 +1,257 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '../index'
describe('context-menu wrapper', () => {
describe('ContextMenuContent', () => {
it('should position content at bottom-start with default placement when props are omitted', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}>
<ContextMenuItem>Content action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'bottom')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
})
it('should apply custom placement when custom positioning props are provided', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent
placement="top-end"
sideOffset={12}
alignOffset={-3}
positionerProps={{ 'role': 'group', 'aria-label': 'custom content positioner' }}
>
<ContextMenuItem>Custom content</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'custom content positioner' })
const popup = screen.getByRole('menu')
expect(positioner).toHaveAttribute('data-side', 'top')
expect(positioner).toHaveAttribute('data-align', 'end')
expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
})
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
const handlePositionerMouseEnter = vi.fn()
const handlePopupClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent
positionerProps={{
'role': 'group',
'aria-label': 'context content positioner',
'id': 'context-content-positioner',
'onMouseEnter': handlePositionerMouseEnter,
}}
popupProps={{
role: 'menu',
id: 'context-content-popup',
onClick: handlePopupClick,
}}
>
<ContextMenuItem>Passthrough content</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'context content positioner' })
const popup = screen.getByRole('menu')
fireEvent.mouseEnter(positioner)
fireEvent.click(popup)
expect(positioner).toHaveAttribute('id', 'context-content-positioner')
expect(popup).toHaveAttribute('id', 'context-content-popup')
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
expect(handlePopupClick).toHaveBeenCalledTimes(1)
})
})
describe('ContextMenuSubContent', () => {
it('should position sub-content at right-start with default placement when props are omitted', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub open>
<ContextMenuSubTrigger>More actions</ContextMenuSubTrigger>
<ContextMenuSubContent positionerProps={{ 'role': 'group', 'aria-label': 'sub positioner' }}>
<ContextMenuItem>Sub action</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>,
)
const positioner = screen.getByRole('group', { name: 'sub positioner' })
expect(positioner).toHaveAttribute('data-side', 'right')
expect(positioner).toHaveAttribute('data-align', 'start')
expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
})
})
describe('destructive prop behavior', () => {
it.each([true, false])('should remain interactive and not leak destructive prop on item when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
destructive={destructive}
aria-label="menu action"
id={`context-item-${String(destructive)}`}
onClick={handleClick}
>
Item label
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const item = screen.getByRole('menuitem', { name: 'menu action' })
fireEvent.click(item)
expect(item).toHaveAttribute('id', `context-item-${String(destructive)}`)
expect(item).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it.each([true, false])('should remain interactive and not leak destructive prop on submenu trigger when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub open>
<ContextMenuSubTrigger
destructive={destructive}
aria-label="submenu action"
id={`context-sub-${String(destructive)}`}
onClick={handleClick}
>
Trigger item
</ContextMenuSubTrigger>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>,
)
const trigger = screen.getByRole('menuitem', { name: 'submenu action' })
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('id', `context-sub-${String(destructive)}`)
expect(trigger).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
it.each([true, false])('should remain interactive and not leak destructive prop on link item when destructive is %s', (destructive) => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLinkItem
destructive={destructive}
href="https://example.com/docs"
aria-label="context docs link"
id={`context-link-${String(destructive)}`}
target="_blank"
rel="noopener noreferrer"
>
Docs
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>,
)
const link = screen.getByRole('menuitem', { name: 'context docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('id', `context-link-${String(destructive)}`)
expect(link).not.toHaveAttribute('destructive')
})
})
describe('ContextMenuLinkItem close behavior', () => {
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLinkItem
href="https://example.com/docs"
closeOnClick={false}
aria-label="docs link"
>
Docs
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).not.toHaveAttribute('closeOnClick')
})
})
describe('ContextMenuTrigger interaction', () => {
it('should open menu when right-clicking trigger area', () => {
render(
<ContextMenu>
<ContextMenuTrigger aria-label="context trigger area">
Trigger area
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Open on right click</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
const trigger = screen.getByLabelText('context trigger area')
fireEvent.contextMenu(trigger)
expect(screen.getByRole('menuitem', { name: 'Open on right click' })).toBeInTheDocument()
})
})
describe('ContextMenuSeparator', () => {
it('should render separator and keep surrounding rows when separator is between items', () => {
render(
<ContextMenu open>
<ContextMenuTrigger aria-label="context trigger">Open</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>First action</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>Second action</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>,
)
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
expect(screen.getAllByRole('separator')).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,215 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuCheckboxItemIndicator,
ContextMenuContent,
ContextMenuGroup,
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuLinkItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuRadioItemIndicator,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '.'
const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: string }) => (
<ContextMenuTrigger
aria-label="context menu trigger area"
render={<button type="button" className="flex h-44 w-80 select-none items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center text-sm text-text-tertiary" />}
>
{label}
</ContextMenuTrigger>
)
const meta = {
title: 'Base/Navigation/ContextMenu',
component: ContextMenu,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound context menu built on Base UI ContextMenu. Open by right-clicking the trigger area.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ContextMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuItem>Edit</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
<ContextMenuItem>Archive</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
),
}
export const WithSubmenu: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuItem>Copy</ContextMenuItem>
<ContextMenuItem>Paste</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>Share</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Email</ContextMenuItem>
<ContextMenuItem>Slack</ContextMenuItem>
<ContextMenuItem>Copy link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
),
}
export const WithGroupLabel: Story = {
render: () => (
<ContextMenu>
<TriggerArea />
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuGroupLabel>Actions</ContextMenuGroupLabel>
<ContextMenuItem>Rename</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuGroupLabel>Danger Zone</ContextMenuGroupLabel>
<ContextMenuItem destructive>Delete</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
),
}
const WithRadioItemsDemo = () => {
const [value, setValue] = useState('comfortable')
return (
<ContextMenu>
<TriggerArea label={`Right-click to set density: ${value}`} />
<ContextMenuContent>
<ContextMenuRadioGroup value={value} onValueChange={setValue}>
<ContextMenuRadioItem value="compact">
Compact
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
<ContextMenuRadioItem value="comfortable">
Comfortable
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
<ContextMenuRadioItem value="spacious">
Spacious
<ContextMenuRadioItemIndicator />
</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuContent>
</ContextMenu>
)
}
export const WithRadioItems: Story = {
render: () => <WithRadioItemsDemo />,
}
const WithCheckboxItemsDemo = () => {
const [showToolbar, setShowToolbar] = useState(true)
const [showSidebar, setShowSidebar] = useState(false)
const [showStatusBar, setShowStatusBar] = useState(true)
return (
<ContextMenu>
<TriggerArea label="Right-click to configure panel visibility" />
<ContextMenuContent>
<ContextMenuCheckboxItem checked={showToolbar} onCheckedChange={setShowToolbar}>
Toolbar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showSidebar} onCheckedChange={setShowSidebar}>
Sidebar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showStatusBar} onCheckedChange={setShowStatusBar}>
Status bar
<ContextMenuCheckboxItemIndicator />
</ContextMenuCheckboxItem>
</ContextMenuContent>
</ContextMenu>
)
}
export const WithCheckboxItems: Story = {
render: () => <WithCheckboxItemsDemo />,
}
export const WithLinkItems: Story = {
render: () => (
<ContextMenu>
<TriggerArea label="Right-click to open links" />
<ContextMenuContent>
<ContextMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
Dify Docs
</ContextMenuLinkItem>
<ContextMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
Product Roadmap
</ContextMenuLinkItem>
<ContextMenuSeparator />
<ContextMenuLinkItem destructive href="https://example.com/delete" rel="noopener noreferrer" target="_blank">
Dangerous External Action
</ContextMenuLinkItem>
</ContextMenuContent>
</ContextMenu>
),
}
export const Complex: Story = {
render: () => (
<ContextMenu>
<TriggerArea label="Right-click to inspect all menu capabilities" />
<ContextMenuContent>
<ContextMenuItem>
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
Rename
</ContextMenuItem>
<ContextMenuItem>
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
Share
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Email</ContextMenuItem>
<ContextMenuItem>Slack</ContextMenuItem>
<ContextMenuItem>Copy Link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem destructive>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
),
}

View File

@ -0,0 +1,302 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
import * as React from 'react'
import {
menuBackdropClassName,
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
export const ContextMenu = BaseContextMenu.Root
export const ContextMenuTrigger = BaseContextMenu.Trigger
export const ContextMenuPortal = BaseContextMenu.Portal
export const ContextMenuBackdrop = BaseContextMenu.Backdrop
export const ContextMenuSub = BaseContextMenu.SubmenuRoot
export const ContextMenuGroup = BaseContextMenu.Group
export const ContextMenuRadioGroup = BaseContextMenu.RadioGroup
type ContextMenuContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Positioner>,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<
React.ComponentPropsWithoutRef<typeof BaseContextMenu.Popup>,
'children' | 'className'
>
}
type ContextMenuPopupRenderProps = Required<Pick<ContextMenuContentProps, 'children'>> & {
placement: Placement
sideOffset: number
alignOffset: number
className?: string
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
withBackdrop?: boolean
}
function renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
withBackdrop = false,
}: ContextMenuPopupRenderProps) {
const { side, align } = parsePlacement(placement)
return (
<BaseContextMenu.Portal>
{withBackdrop && (
<BaseContextMenu.Backdrop className={menuBackdropClassName} />
)}
<BaseContextMenu.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
{...positionerProps}
>
<BaseContextMenu.Popup
className={cn(
menuPopupBaseClassName,
menuPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
>
{children}
</BaseContextMenu.Popup>
</BaseContextMenu.Positioner>
</BaseContextMenu.Portal>
)
}
export function ContextMenuContent({
children,
placement = 'bottom-start',
sideOffset = 0,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: ContextMenuContentProps) {
return renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
withBackdrop: true,
})
}
type ContextMenuItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.Item> & {
destructive?: boolean
}
export function ContextMenuItem({
className,
destructive,
...props
}: ContextMenuItemProps) {
return (
<BaseContextMenu.Item
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
}
type ContextMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.LinkItem> & {
destructive?: boolean
}
export function ContextMenuLinkItem({
className,
destructive,
closeOnClick = true,
...props
}: ContextMenuLinkItemProps) {
return (
<BaseContextMenu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props}
/>
)
}
export function ContextMenuRadioItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>) {
return (
<BaseContextMenu.RadioItem
className={cn(menuRowClassName, className)}
{...props}
/>
)
}
export function ContextMenuCheckboxItem({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>) {
return (
<BaseContextMenu.CheckboxItem
className={cn(menuRowClassName, className)}
{...props}
/>
)
}
type ContextMenuIndicatorProps = Omit<React.ComponentPropsWithoutRef<'span'>, 'children'> & {
children?: React.ReactNode
}
export function ContextMenuItemIndicator({
className,
children,
...props
}: ContextMenuIndicatorProps) {
return (
<span
aria-hidden
className={cn(menuIndicatorClassName, className)}
{...props}
>
{children ?? <span aria-hidden className="i-ri-check-line h-4 w-4" />}
</span>
)
}
export function ContextMenuCheckboxItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItemIndicator>, 'children'>) {
return (
<BaseContextMenu.CheckboxItemIndicator
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</BaseContextMenu.CheckboxItemIndicator>
)
}
export function ContextMenuRadioItemIndicator({
className,
...props
}: Omit<React.ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItemIndicator>, 'children'>) {
return (
<BaseContextMenu.RadioItemIndicator
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
</BaseContextMenu.RadioItemIndicator>
)
}
type ContextMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof BaseContextMenu.SubmenuTrigger> & {
destructive?: boolean
}
export function ContextMenuSubTrigger({
className,
destructive,
children,
...props
}: ContextMenuSubTriggerProps) {
return (
<BaseContextMenu.SubmenuTrigger
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-4 shrink-0 text-text-tertiary" />
</BaseContextMenu.SubmenuTrigger>
)
}
type ContextMenuSubContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
}
export function ContextMenuSubContent({
children,
placement = 'right-start',
sideOffset = 4,
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: ContextMenuSubContentProps) {
return renderContextMenuPopup({
children,
placement,
sideOffset,
alignOffset,
className,
popupClassName,
positionerProps,
popupProps,
})
}
export function ContextMenuGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>) {
return (
<BaseContextMenu.GroupLabel
className={cn(menuGroupLabelClassName, className)}
{...props}
/>
)
}
export function ContextMenuSeparator({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>) {
return (
<BaseContextMenu.Separator
className={cn(menuSeparatorClassName, className)}
{...props}
/>
)
}

View File

@ -1,13 +1,12 @@
import { Menu } from '@base-ui/react/menu' import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { fireEvent, render, screen, within } from '@testing-library/react' import { fireEvent, render, screen, within } from '@testing-library/react'
import Link from 'next/link'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuPortal, DropdownMenuLinkItem,
DropdownMenuRadioGroup,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
@ -15,18 +14,22 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../index' } from '../index'
describe('dropdown-menu wrapper', () => { vi.mock('next/link', () => ({
describe('alias exports', () => { default: ({
it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => { href,
expect(DropdownMenu).toBe(Menu.Root) children,
expect(DropdownMenuPortal).toBe(Menu.Portal) ...props
expect(DropdownMenuTrigger).toBe(Menu.Trigger) }: {
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot) href: string
expect(DropdownMenuGroup).toBe(Menu.Group) children?: ReactNode
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup) } & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
}) <a href={href} {...props}>
}) {children}
</a>
),
}))
describe('dropdown-menu wrapper', () => {
describe('DropdownMenuContent', () => { describe('DropdownMenuContent', () => {
it('should position content at bottom-end with default placement when props are omitted', () => { it('should position content at bottom-end with default placement when props are omitted', () => {
render( render(
@ -250,6 +253,99 @@ describe('dropdown-menu wrapper', () => {
}) })
}) })
describe('DropdownMenuLinkItem', () => {
it('should render as anchor and keep href/target attributes when link props are provided', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem href="https://example.com/docs" target="_blank" rel="noopener noreferrer">
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'Docs' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should keep link semantics and not leak closeOnClick prop when closeOnClick is false', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
href="https://example.com/docs"
closeOnClick={false}
aria-label="docs link"
>
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).not.toHaveAttribute('closeOnClick')
})
it('should preserve link semantics when render prop uses a custom link component', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
render={<Link href="/account" />}
aria-label="account link"
>
Account settings
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'account link' })
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('href', '/account')
expect(link).toHaveTextContent('Account settings')
})
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
const handleClick = vi.fn()
render(
<DropdownMenu open>
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
destructive={destructive}
href="https://example.com/docs"
aria-label="docs link"
id={`menu-link-${String(destructive)}`}
onClick={handleClick}
>
Docs
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>,
)
const link = screen.getByRole('menuitem', { name: 'docs link' })
fireEvent.click(link)
expect(link.tagName.toLowerCase()).toBe('a')
expect(link).toHaveAttribute('id', `menu-link-${String(destructive)}`)
expect(link).not.toHaveAttribute('destructive')
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('DropdownMenuSeparator', () => { describe('DropdownMenuSeparator', () => {
it('should forward passthrough props and handlers when separator props are provided', () => { it('should forward passthrough props and handlers when separator props are provided', () => {
const handleMouseEnter = vi.fn() const handleMouseEnter = vi.fn()

View File

@ -8,6 +8,7 @@ import {
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuGroupLabel, DropdownMenuGroupLabel,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator, DropdownMenuRadioItemIndicator,
@ -234,6 +235,22 @@ export const WithIcons: Story = {
), ),
} }
export const WithLinkItems: Story = {
render: () => (
<DropdownMenu>
<TriggerButton label="Open links" />
<DropdownMenuContent>
<DropdownMenuLinkItem href="https://docs.dify.ai" rel="noopener noreferrer" target="_blank">
Dify Docs
</DropdownMenuLinkItem>
<DropdownMenuLinkItem href="https://roadmap.dify.ai" rel="noopener noreferrer" target="_blank">
Product Roadmap
</DropdownMenuLinkItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
const ComplexDemo = () => { const ComplexDemo = () => {
const [sortOrder, setSortOrder] = useState('newest') const [sortOrder, setSortOrder] = useState('newest')
const [showArchived, setShowArchived] = useState(false) const [showArchived, setShowArchived] = useState(false)

View File

@ -3,6 +3,14 @@
import type { Placement } from '@/app/components/base/ui/placement' import type { Placement } from '@/app/components/base/ui/placement'
import { Menu } from '@base-ui/react/menu' import { Menu } from '@base-ui/react/menu'
import * as React from 'react' import * as React from 'react'
import {
menuGroupLabelClassName,
menuIndicatorClassName,
menuPopupAnimationClassName,
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
import { parsePlacement } from '@/app/components/base/ui/placement' import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@ -13,20 +21,13 @@ export const DropdownMenuSub = Menu.SubmenuRoot
export const DropdownMenuGroup = Menu.Group export const DropdownMenuGroup = Menu.Group
export const DropdownMenuRadioGroup = Menu.RadioGroup export const DropdownMenuRadioGroup = Menu.RadioGroup
const menuRowBaseClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none'
const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
export function DropdownMenuRadioItem({ export function DropdownMenuRadioItem({
className, className,
...props ...props
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) { }: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
return ( return (
<Menu.RadioItem <Menu.RadioItem
className={cn( className={cn(menuRowClassName, className)}
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
{...props} {...props}
/> />
) )
@ -38,10 +39,7 @@ export function DropdownMenuRadioItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) { }: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
return ( return (
<Menu.RadioItemIndicator <Menu.RadioItemIndicator
className={cn( className={cn(menuIndicatorClassName, className)}
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
{...props} {...props}
> >
<span aria-hidden className="i-ri-check-line h-4 w-4" /> <span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -55,11 +53,7 @@ export function DropdownMenuCheckboxItem({
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) { }: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
return ( return (
<Menu.CheckboxItem <Menu.CheckboxItem
className={cn( className={cn(menuRowClassName, className)}
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
{...props} {...props}
/> />
) )
@ -71,10 +65,7 @@ export function DropdownMenuCheckboxItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) { }: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
return ( return (
<Menu.CheckboxItemIndicator <Menu.CheckboxItemIndicator
className={cn( className={cn(menuIndicatorClassName, className)}
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
{...props} {...props}
> >
<span aria-hidden className="i-ri-check-line h-4 w-4" /> <span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -88,10 +79,7 @@ export function DropdownMenuGroupLabel({
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) { }: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return ( return (
<Menu.GroupLabel <Menu.GroupLabel
className={cn( className={cn(menuGroupLabelClassName, className)}
'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase',
className,
)}
{...props} {...props}
/> />
) )
@ -148,8 +136,8 @@ function renderDropdownMenuPopup({
> >
<Menu.Popup <Menu.Popup
className={cn( className={cn(
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg backdrop-blur-[5px]', menuPopupBaseClassName,
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none', menuPopupAnimationClassName,
popupClassName, popupClassName,
)} )}
{...popupProps} {...popupProps}
@ -195,12 +183,7 @@ export function DropdownMenuSubTrigger({
}: DropdownMenuSubTriggerProps) { }: DropdownMenuSubTriggerProps) {
return ( return (
<Menu.SubmenuTrigger <Menu.SubmenuTrigger
className={cn( className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
{...props} {...props}
> >
{children} {children}
@ -253,12 +236,26 @@ export function DropdownMenuItem({
}: DropdownMenuItemProps) { }: DropdownMenuItemProps) {
return ( return (
<Menu.Item <Menu.Item
className={cn( className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
menuRowBaseClassName, {...props}
menuRowStateClassName, />
destructive && 'text-text-destructive', )
className, }
)}
type DropdownMenuLinkItemProps = React.ComponentPropsWithoutRef<typeof Menu.LinkItem> & {
destructive?: boolean
}
export function DropdownMenuLinkItem({
className,
destructive,
closeOnClick = true,
...props
}: DropdownMenuLinkItemProps) {
return (
<Menu.LinkItem
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
closeOnClick={closeOnClick}
{...props} {...props}
/> />
) )
@ -270,7 +267,7 @@ export function DropdownMenuSeparator({
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) { }: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
return ( return (
<Menu.Separator <Menu.Separator
className={cn('my-1 h-px bg-divider-subtle', className)} className={cn(menuSeparatorClassName, className)}
{...props} {...props}
/> />
) )

View File

@ -0,0 +1,7 @@
export const menuRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-none data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30'
export const menuIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent'
export const menuGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase'
export const menuSeparatorClassName = 'my-1 h-px bg-divider-subtle'
export const menuPopupBaseClassName = 'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-none focus:outline-none focus-visible:outline-none backdrop-blur-[5px]'
export const menuPopupAnimationClassName = 'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'
export const menuBackdropClassName = 'fixed inset-0 z-50 bg-transparent transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'

View File

@ -184,7 +184,7 @@ export default function Compliance() {
<DropdownMenuSubContent <DropdownMenuSubContent
popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm" popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
> >
<DropdownMenuGroup className="p-1"> <DropdownMenuGroup className="py-1">
<ComplianceDocRowItem <ComplianceDocRowItem
icon={<Soc2 aria-hidden className="size-7 shrink-0" />} icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
label={t('compliance.soc2Type1', { ns: 'common' })} label={t('compliance.soc2Type1', { ns: 'common' })}

View File

@ -9,7 +9,7 @@ import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar' import Avatar from '@/app/components/base/avatar'
import PremiumBadge from '@/app/components/base/premium-badge' import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher' import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config' import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
@ -41,12 +41,12 @@ function AccountMenuRouteItem({
trailing, trailing,
}: AccountMenuRouteItemProps) { }: AccountMenuRouteItemProps) {
return ( return (
<DropdownMenuItem <DropdownMenuLinkItem
className="justify-between" className="justify-between"
render={<Link href={href} />} render={<Link href={href} />}
> >
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} /> <MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem> </DropdownMenuLinkItem>
) )
} }
@ -64,12 +64,14 @@ function AccountMenuExternalItem({
trailing, trailing,
}: AccountMenuExternalItemProps) { }: AccountMenuExternalItemProps) {
return ( return (
<DropdownMenuItem <DropdownMenuLinkItem
className="justify-between" className="justify-between"
render={<a href={href} rel="noopener noreferrer" target="_blank" />} href={href}
rel="noopener noreferrer"
target="_blank"
> >
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} /> <MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem> </DropdownMenuLinkItem>
) )
} }
@ -101,7 +103,7 @@ type AccountMenuSectionProps = {
} }
function AccountMenuSection({ children }: AccountMenuSectionProps) { function AccountMenuSection({ children }: AccountMenuSectionProps) {
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup> return <DropdownMenuGroup className="py-1">{children}</DropdownMenuGroup>
} }
export default function AppSelector() { export default function AppSelector() {
@ -144,8 +146,8 @@ export default function AppSelector() {
sideOffset={6} sideOffset={6}
popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm" popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
> >
<DropdownMenuGroup className="px-1 py-1"> <DropdownMenuGroup className="py-1">
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2"> <div className="mx-1 flex flex-nowrap items-center py-2 pl-3 pr-2">
<div className="grow"> <div className="grow">
<div className="break-all text-text-primary system-md-medium"> <div className="break-all text-text-primary system-md-medium">
{userProfile.name} {userProfile.name}

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu' import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils' import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config' import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config'
@ -31,7 +31,7 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
<DropdownMenuSubContent <DropdownMenuSubContent
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm" popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
> >
<DropdownMenuGroup className="p-1"> <DropdownMenuGroup className="py-1">
{hasDedicatedChannel && hasZendeskWidget && ( {hasDedicatedChannel && hasZendeskWidget && (
<DropdownMenuItem <DropdownMenuItem
className="justify-between" className="justify-between"
@ -47,37 +47,43 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{hasDedicatedChannel && !hasZendeskWidget && ( {hasDedicatedChannel && !hasZendeskWidget && (
<DropdownMenuItem <DropdownMenuLinkItem
className="justify-between" className="justify-between"
render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)} rel="noopener noreferrer" target="_blank" />} href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version, SUPPORT_EMAIL_ADDRESS)}
rel="noopener noreferrer"
target="_blank"
> >
<MenuItemContent <MenuItemContent
iconClassName="i-ri-mail-send-line" iconClassName="i-ri-mail-send-line"
label={t('userProfile.emailSupport', { ns: 'common' })} label={t('userProfile.emailSupport', { ns: 'common' })}
trailing={<ExternalLinkIndicator />} trailing={<ExternalLinkIndicator />}
/> />
</DropdownMenuItem> </DropdownMenuLinkItem>
)} )}
<DropdownMenuItem <DropdownMenuLinkItem
className="justify-between" className="justify-between"
render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />} href="https://forum.dify.ai/"
rel="noopener noreferrer"
target="_blank"
> >
<MenuItemContent <MenuItemContent
iconClassName="i-ri-discuss-line" iconClassName="i-ri-discuss-line"
label={t('userProfile.forum', { ns: 'common' })} label={t('userProfile.forum', { ns: 'common' })}
trailing={<ExternalLinkIndicator />} trailing={<ExternalLinkIndicator />}
/> />
</DropdownMenuItem> </DropdownMenuLinkItem>
<DropdownMenuItem <DropdownMenuLinkItem
className="justify-between" className="justify-between"
render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />} href="https://discord.gg/5AEfbxcd9k"
rel="noopener noreferrer"
target="_blank"
> >
<MenuItemContent <MenuItemContent
iconClassName="i-ri-discord-line" iconClassName="i-ri-discord-line"
label={t('userProfile.community', { ns: 'common' })} label={t('userProfile.community', { ns: 'common' })}
trailing={<ExternalLinkIndicator />} trailing={<ExternalLinkIndicator />}
/> />
</DropdownMenuItem> </DropdownMenuLinkItem>
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>

View File

@ -16,6 +16,7 @@ This document tracks the migration away from legacy overlay APIs.
- Replacement primitives: - Replacement primitives:
- `@/app/components/base/ui/tooltip` - `@/app/components/base/ui/tooltip`
- `@/app/components/base/ui/dropdown-menu` - `@/app/components/base/ui/dropdown-menu`
- `@/app/components/base/ui/context-menu`
- `@/app/components/base/ui/popover` - `@/app/components/base/ui/popover`
- `@/app/components/base/ui/dialog` - `@/app/components/base/ui/dialog`
- `@/app/components/base/ui/alert-dialog` - `@/app/components/base/ui/alert-dialog`

View File

@ -80,6 +80,16 @@ if (typeof globalThis.IntersectionObserver === 'undefined') {
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView)
Element.prototype.scrollIntoView = function () { /* noop */ } Element.prototype.scrollIntoView = function () { /* noop */ }
// Mock DOMRect.fromRect for tests (not available in jsdom)
if (typeof DOMRect !== 'undefined' && typeof (DOMRect as typeof DOMRect & { fromRect?: unknown }).fromRect !== 'function') {
(DOMRect as typeof DOMRect & { fromRect: (rect?: DOMRectInit) => DOMRect }).fromRect = (rect = {}) => new DOMRect(
rect.x ?? 0,
rect.y ?? 0,
rect.width ?? 0,
rect.height ?? 0,
)
}
afterEach(async () => { afterEach(async () => {
// Wrap cleanup in act() to flush pending React scheduler work // Wrap cleanup in act() to flush pending React scheduler work
// This prevents "window is not defined" errors from React 19's scheduler // This prevents "window is not defined" errors from React 19's scheduler