mirror of https://github.com/langgenius/dify.git
feat(web): add context menu primitive and dropdown link item (#33125)
This commit is contained in:
parent
66f9fde2fe
commit
0590b09958
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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' })}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue