chore: fix ts

This commit is contained in:
Joel
2026-03-10 15:04:12 +08:00
262 changed files with 58018 additions and 5040 deletions

View File

@ -45,6 +45,13 @@ type ChatInputAreaProps = {
theme?: Theme | null
isResponding?: boolean
disabled?: boolean
/**
* Controls whether pressing Enter sends the message.
* - true (default): Enter sends, Shift+Enter inserts newline
* - false: Enter inserts newline, Shift+Enter sends
* Useful for CJK (Japanese/Korean/Chinese) IME users who expect Enter to insert newlines.
*/
sendOnEnter?: boolean
}
const ChatInputArea = ({
readonly,
@ -61,6 +68,7 @@ const ChatInputArea = ({
theme,
isResponding,
disabled,
sendOnEnter = true,
}: ChatInputAreaProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
@ -104,7 +112,7 @@ const ChatInputArea = ({
if (onSend) {
const { files, setFiles } = filesStore.getState()
if (files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
if (files.some(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
return
}
@ -131,7 +139,14 @@ const ChatInputArea = ({
}, 50)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
// Determine if this key combo should trigger send:
// sendOnEnter=true (default): Enter sends, Shift+Enter inserts newline
// sendOnEnter=false: Shift+Enter sends, Enter inserts newline
const isSendCombo = sendOnEnter
? (e.key === 'Enter' && !e.shiftKey)
: (e.key === 'Enter' && e.shiftKey)
if (isSendCombo && !e.nativeEvent.isComposing) {
// if isComposing, exit
if (isComposingRef.current)
return

View File

@ -66,13 +66,13 @@ export const useChat = (
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const { notify } = useToastContext()
const conversationId = useRef('')
const hasStopResponded = useRef(false)
const conversationIdRef = useRef('')
const hasStopRespondedRef = useRef(false)
const [isResponding, setIsResponding] = useState(false)
const isRespondingRef = useRef(false)
const taskIdRef = useRef('')
const pausedStateRef = useRef(false)
const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>([])
const conversationMessagesAbortControllerRef = useRef<AbortController | null>(null)
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
const workflowEventsAbortControllerRef = useRef<AbortController | null>(null)
@ -165,7 +165,7 @@ export const useChat = (
}, [])
const handleStop = useCallback(() => {
hasStopResponded.current = true
hasStopRespondedRef.current = true
handleResponding(false)
if (stopChat && taskIdRef.current && !pausedStateRef.current)
stopChat(taskIdRef.current)
@ -178,11 +178,11 @@ export const useChat = (
}, [stopChat, handleResponding])
const handleRestart = useCallback((cb?: any) => {
conversationId.current = ''
conversationIdRef.current = ''
taskIdRef.current = ''
handleStop()
setChatTree([])
setSuggestQuestions([])
setSuggestedQuestions([])
cb?.()
}, [handleStop])
@ -245,7 +245,7 @@ export const useChat = (
})
if (isFirstMessage && newConversationId)
conversationId.current = newConversationId
conversationIdRef.current = newConversationId
if (taskId)
taskIdRef.current = taskId
@ -257,19 +257,19 @@ export const useChat = (
return
if (onConversationComplete)
onConversationComplete(conversationId.current)
onConversationComplete(conversationIdRef.current)
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
try {
const { data }: any = await onGetSuggestedQuestions(
messageId,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
)
setSuggestQuestions(data)
setSuggestedQuestions(data)
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setSuggestQuestions([])
setSuggestedQuestions([])
}
}
},
@ -299,7 +299,7 @@ export const useChat = (
updateChatTreeNode(messageId, (responseItem) => {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
if (lastThought) {
responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(lastThought as any).message_files, convertedFile]
responseItem.agent_thoughts!.at(-1)!.message_files = [...(lastThought as any).message_files, convertedFile]
}
else {
const currentFiles = (responseItem.message_files as FileEntity[] | undefined) ?? []
@ -321,7 +321,7 @@ export const useChat = (
responseItem.agent_thoughts.push(thought)
}
else {
const lastThought = responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1]
const lastThought = responseItem.agent_thoughts.at(-1)!
if (lastThought.id === thought.id) {
thought.thought = lastThought.thought
thought.message_files = lastThought.message_files
@ -357,7 +357,7 @@ export const useChat = (
},
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
handleResponding(true)
hasStopResponded.current = false
hasStopRespondedRef.current = false
updateChatTreeNode(messageId, (responseItem) => {
if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
responseItem.workflowProcess.status = WorkflowRunningStatus.Running
@ -609,7 +609,7 @@ export const useChat = (
isPublicAPI,
}: SendCallback,
) => {
setSuggestQuestions([])
setSuggestedQuestions([])
if (isRespondingRef.current) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
@ -656,12 +656,12 @@ export const useChat = (
}
handleResponding(true)
hasStopResponded.current = false
hasStopRespondedRef.current = false
const { query, files, inputs, ...restData } = data
const bodyParams = {
response_mode: 'streaming',
conversation_id: conversationId.current,
conversation_id: conversationIdRef.current,
files: getProcessedFiles(files || []),
query,
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
@ -707,7 +707,7 @@ export const useChat = (
}
if (isFirstMessage && newConversationId)
conversationId.current = newConversationId
conversationIdRef.current = newConversationId
taskIdRef.current = taskId
if (messageId)
@ -727,11 +727,11 @@ export const useChat = (
return
if (onConversationComplete)
onConversationComplete(conversationId.current)
onConversationComplete(conversationIdRef.current)
if (conversationId.current && !hasStopResponded.current && onGetConversationMessages) {
if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) {
const { data }: any = await onGetConversationMessages(
conversationId.current,
conversationIdRef.current,
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
)
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
@ -743,7 +743,7 @@ export const useChat = (
content: isUseAgentThought ? '' : newResponseItem.answer,
log: [
...newResponseItem.message,
...(newResponseItem.message[newResponseItem.message.length - 1].role !== 'assistant'
...(newResponseItem.message.at(-1).role !== 'assistant'
? [
{
role: 'assistant',
@ -760,24 +760,24 @@ export const useChat = (
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
},
// for agent log
conversationId: conversationId.current,
conversationId: conversationIdRef.current,
input: {
inputs: newResponseItem.inputs,
query: newResponseItem.query,
},
})
}
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
try {
const { data }: any = await onGetSuggestedQuestions(
responseItem.id,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
)
setSuggestQuestions(data)
setSuggestedQuestions(data)
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setSuggestQuestions([])
setSuggestedQuestions([])
}
}
},
@ -809,7 +809,7 @@ export const useChat = (
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
if (lastThought) {
const thought = lastThought as { message_files?: FileEntity[] }
responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(thought.message_files ?? []), convertedFile]
responseItem.agent_thoughts!.at(-1)!.message_files = [...(thought.message_files ?? []), convertedFile]
}
// For non-agent mode, add files directly to responseItem.message_files
else {
@ -836,7 +836,7 @@ export const useChat = (
response.agent_thoughts.push(thought)
}
else {
const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]
const lastThought = response.agent_thoughts.at(-1)
// thought changed but still the same thought, so update.
if (lastThought.id === thought.id) {
thought.thought = lastThought.thought
@ -867,6 +867,7 @@ export const useChat = (
responseItem,
parentId: data.parent_message_id,
})
handleResponding(false)
return
}
responseItem.citation = messageEnd.metadata?.retriever_resources || []
@ -895,7 +896,7 @@ export const useChat = (
onWorkflowStarted: ({ workflow_run_id, task_id, conversation_id, message_id }) => {
// If there are no streaming messages, we still need to set the conversation_id to avoid create a new conversation when regeneration in chat-flow.
if (conversation_id) {
conversationId.current = conversation_id
conversationIdRef.current = conversation_id
}
if (message_id && !hasSetResponseId) {
questionItem.id = `question-${message_id}`

View File

@ -75,6 +75,7 @@ export type ChatProps = {
inputDisabled?: boolean
sidebarCollapseState?: boolean
hideAvatar?: boolean
sendOnEnter?: boolean
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
getHumanInputNodeData?: (nodeID: string) => any
}
@ -119,6 +120,7 @@ const Chat: FC<ChatProps> = ({
inputDisabled,
sidebarCollapseState,
hideAvatar,
sendOnEnter,
onHumanInputFormSubmit,
getHumanInputNodeData,
}) => {
@ -244,7 +246,7 @@ const Chat: FC<ChatProps> = ({
useEffect(() => {
if (!sidebarCollapseState) {
const timer = setTimeout(() => handleWindowResize(), 200)
const timer = setTimeout(handleWindowResize, 200)
return () => clearTimeout(timer)
}
}, [handleWindowResize, sidebarCollapseState])
@ -283,7 +285,7 @@ const Chat: FC<ChatProps> = ({
{
chatList.map((item, index) => {
if (item.isAnswer) {
const isLast = item.id === chatList[chatList.length - 1]?.id
const isLast = item.id === chatList.at(-1)?.id
return (
<Answer
appData={appData}
@ -332,8 +334,7 @@ const Chat: FC<ChatProps> = ({
!noStopResponding && isResponding && (
<div data-testid="stop-responding-container" className="mb-2 flex justify-center">
<Button className="border-components-panel-border bg-components-panel-bg text-components-button-secondary-text" onClick={onStopResponding}>
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
<div className="i-custom-vender-solid-mediaanddevices-stop-circle mr-[5px] h-3.5 w-3.5" />
<div className="i-custom-vender-solid-mediaAndDevices-stop-circle mr-[5px] h-3.5 w-3.5" />
<span className="text-xs font-normal">{t('operation.stopResponding', { ns: 'appDebug' })}</span>
</Button>
</div>
@ -364,6 +365,7 @@ const Chat: FC<ChatProps> = ({
theme={themeBuilder?.theme}
isResponding={isResponding}
readonly={readonly}
sendOnEnter={sendOnEnter}
/>
)
}

View File

@ -58,6 +58,15 @@ const ChatWrapper = () => {
appSourceType,
} = useEmbeddedChatbotContext()
// Read sendOnEnter from URL params (e.g., ?sendOnEnter=false)
const sendOnEnter = useMemo(() => {
if (typeof window === 'undefined')
return true
const urlParams = new URLSearchParams(window.location.search)
const param = urlParams.get('sendOnEnter')
return param !== 'false'
}, [])
const appConfig = useMemo(() => {
const config = appParams || {}
@ -321,6 +330,7 @@ const ChatWrapper = () => {
themeBuilder={themeBuilder}
switchSibling={doSwitchSibling}
inputDisabled={inputDisabled}
sendOnEnter={sendOnEnter}
questionIcon={
initUserVariables?.avatar_url
? (

View File

@ -164,7 +164,7 @@ const VoiceParamConfig = ({
</div>
<div className="flex items-center gap-1">
<Listbox
value={voiceItem ?? {}}
value={voiceItem}
disabled={!languageItem}
onChange={(value: Item) => {
handleChange({

View File

@ -1,55 +1,72 @@
import type { FormType } from '../../..'
import type { CustomActionsProps } from '../actions'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { formContext } from '../../..'
import Actions from '../actions'
const renderWithForm = ({
canSubmit,
isSubmitting,
CustomActions,
}: {
canSubmit: boolean
isSubmitting: boolean
const mockFormState = vi.hoisted(() => ({
canSubmit: true,
isSubmitting: false,
}))
vi.mock('@tanstack/react-form', async () => {
const actual = await vi.importActual<typeof import('@tanstack/react-form')>('@tanstack/react-form')
return {
...actual,
useStore: (_store: unknown, selector: (state: typeof mockFormState) => unknown) => selector(mockFormState),
}
})
type RenderWithFormOptions = {
canSubmit?: boolean
isSubmitting?: boolean
CustomActions?: (props: CustomActionsProps) => React.ReactNode
}) => {
const submitSpy = vi.fn()
const state = {
canSubmit,
isSubmitting,
}
onSubmit?: () => void
}
const renderWithForm = ({
canSubmit = true,
isSubmitting = false,
CustomActions,
onSubmit = vi.fn(),
}: RenderWithFormOptions = {}) => {
mockFormState.canSubmit = canSubmit
mockFormState.isSubmitting = isSubmitting
const form = {
store: {
state,
subscribe: () => () => {},
},
handleSubmit: submitSpy,
store: {},
handleSubmit: onSubmit,
}
const TestComponent = () => {
return (
<formContext.Provider value={form as unknown as FormType}>
<Actions
CustomActions={CustomActions}
/>
</formContext.Provider>
)
}
render(
<formContext.Provider value={form as unknown as FormType}>
<Actions CustomActions={CustomActions} />
</formContext.Provider>,
)
render(<TestComponent />)
return { submitSpy }
return { onSubmit }
}
describe('Actions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should disable submit button when form cannot submit', () => {
renderWithForm({ canSubmit: false, isSubmitting: false })
renderWithForm({ canSubmit: false })
expect(screen.getByRole('button', { name: 'common.operation.submit' })).toBeDisabled()
})
it('should call form submit when users click submit button', () => {
const { submitSpy } = renderWithForm({ canSubmit: true, isSubmitting: false })
it('should call form submit when users click submit button', async () => {
const submitSpy = vi.fn()
renderWithForm({ onSubmit: submitSpy })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.submit' }))
expect(submitSpy).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(submitSpy).toHaveBeenCalledTimes(1)
})
})
it('should render custom actions when provided', () => {
@ -60,15 +77,14 @@ describe('Actions', () => {
))
renderWithForm({
canSubmit: true,
isSubmitting: true,
CustomActions: customActionsSpy,
})
expect(screen.queryByRole('button', { name: 'common.operation.submit' })).not.toBeInTheDocument()
expect(screen.getByText('custom-true-true')).toBeInTheDocument()
expect(screen.getByText('custom-false-true')).toBeInTheDocument()
expect(customActionsSpy).toHaveBeenCalledWith(expect.objectContaining({
isSubmitting: true,
form: expect.any(Object),
isSubmitting: false,
canSubmit: true,
}))
})

View File

@ -8,13 +8,14 @@ vi.mock('@/app/components/base/image-gallery', () => ({
),
}))
type MockChildNode = {
tagName?: string
properties?: { src?: string }
children?: MockChildNode[]
}
type MockNode = {
children?: Array<{
tagName?: string
properties?: {
src?: string
}
}>
children?: MockChildNode[]
}
type ParagraphProps = {
@ -93,4 +94,38 @@ describe('Paragraph', () => {
expect(screen.getByText('Fallback').tagName).toBe('P')
})
it('should render div instead of p when image is not the first child', () => {
renderParagraph({
node: {
children: [
{ tagName: 'span' },
{ tagName: 'img', properties: { src: 'test.png' } },
],
},
children: [<span key="0">Text before</span>, <img key="1" src="test.png" alt="" />],
})
const wrapper = screen.getByText('Text before').closest('.markdown-p')
expect(wrapper).toBeInTheDocument()
expect(wrapper!.tagName).toBe('DIV')
})
it('should render div when image is nested inside a link', () => {
renderParagraph({
node: {
children: [
{
tagName: 'a',
children: [{ tagName: 'img', properties: { src: 'nested.png' } }],
},
],
},
children: <a href="#"><img src="nested.png" alt="" /></a>,
})
const wrapper = screen.getByRole('link').closest('.markdown-p')
expect(wrapper).toBeInTheDocument()
expect(wrapper!.tagName).toBe('DIV')
})
})

View File

@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { usePluginReadmeAsset } from '@/service/use-plugins'
import { PluginParagraph } from '../plugin-paragraph'
import { getMarkdownImageURL } from '../utils'
import { getMarkdownImageURL, hasImageChild } from '../utils'
// Mock dependencies
vi.mock('@/service/use-plugins', () => ({
@ -13,6 +13,7 @@ vi.mock('@/service/use-plugins', () => ({
vi.mock('../utils', () => ({
getMarkdownImageURL: vi.fn(),
hasImageChild: vi.fn((): boolean => false),
}))
vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
@ -178,4 +179,24 @@ describe('PluginParagraph', () => {
await user.click(closeBtn)
expect(screen.queryByTestId('image-preview-modal')).not.toBeInTheDocument()
})
it('should render div instead of p when image is not the first child', () => {
vi.mocked(hasImageChild).mockReturnValue(true)
const node: MockNode = {
children: [
{ tagName: 'span' },
{ tagName: 'img', properties: { src: 'test.png' } },
],
}
render(
<PluginParagraph node={node}>
<span>Text</span>
</PluginParagraph>,
)
expect(screen.getByTestId('image-fallback-paragraph')).toBeInTheDocument()
expect(screen.getByTestId('image-fallback-paragraph').tagName).toBe('DIV')
})
})

View File

@ -1,26 +1,25 @@
/**
* @fileoverview Paragraph component for rendering <p> tags in Markdown.
* Extracted from the main markdown renderer for modularity.
* Handles special rendering for paragraphs that directly contain an image.
*/
import * as React from 'react'
import ImageGallery from '@/app/components/base/image-gallery'
import { hasImageChild } from './utils'
const Paragraph = (paragraph: any) => {
const { node }: any = paragraph
const children_node = node.children
if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') {
return (
<div className="markdown-img-wrapper">
<ImageGallery srcs={[children_node[0].properties.src]} />
{
Array.isArray(paragraph.children) && paragraph.children.length > 1 && (
<div className="mt-2">{paragraph.children.slice(1)}</div>
)
}
</div>
)
const hasImage = hasImageChild(children_node)
if (hasImage) {
if (children_node[0]?.tagName === 'img') {
return (
<div className="markdown-img-wrapper">
<ImageGallery srcs={[children_node[0].properties.src]} />
{Array.isArray(paragraph.children) && paragraph.children.length > 1
? <div className="mt-2">{paragraph.children.slice(1)}</div>
: null}
</div>
)
}
return <div className="markdown-p">{paragraph.children}</div>
}
return <p>{paragraph.children}</p>
}

View File

@ -1,14 +1,9 @@
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
/**
* @fileoverview Paragraph component for rendering <p> tags in Markdown.
* Extracted from the main markdown renderer for modularity.
* Handles special rendering for paragraphs that directly contain an image.
*/
import ImageGallery from '@/app/components/base/image-gallery'
import { usePluginReadmeAsset } from '@/service/use-plugins'
import { getMarkdownImageURL } from './utils'
import { getMarkdownImageURL, hasImageChild } from './utils'
type PluginParagraphProps = {
pluginInfo?: SimplePluginInfo
@ -66,5 +61,8 @@ export const PluginParagraph: React.FC<PluginParagraphProps> = ({ pluginInfo, no
</div>
)
}
if (hasImageChild(childrenNode))
return <div className="markdown-p" data-testid="image-fallback-paragraph">{children}</div>
return <p data-testid="standard-paragraph">{children}</p>
}

View File

@ -1,5 +1,19 @@
import { ALLOW_UNSAFE_DATA_SCHEME, MARKETPLACE_API_PREFIX } from '@/config'
type MdastNode = {
tagName?: string
children?: MdastNode[]
[key: string]: unknown
}
export const hasImageChild = (children: MdastNode[] | undefined): boolean => {
return children?.some((child) => {
if (child.tagName === 'img')
return true
return child.children ? hasImageChild(child.children) : false
}) ?? false
}
export const isValidUrl = (url: string): boolean => {
const validPrefixes = ['http:', 'https:', '//', 'mailto:']
if (ALLOW_UNSAFE_DATA_SCHEME)

View File

@ -100,11 +100,11 @@ const Select: FC<ISelectProps> = ({
disabled={disabled}
value={selectedItem}
className={className}
onChange={(value: Item) => {
onChange={(value) => {
if (!disabled) {
setSelectedItem(value)
setOpen(false)
onSelect(value)
onSelect(value as Item)
}
}}
>
@ -224,10 +224,10 @@ const SimpleSelect: FC<ISelectProps> = ({
<Listbox
ref={listboxRef}
value={selectedItem}
onChange={(value: Item) => {
onChange={(value) => {
if (!disabled) {
setSelectedItem(value)
onSelect(value)
onSelect(value as Item)
}
}}
>

View File

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SVGRenderer from '..'
const mockClick = vi.fn()
@ -117,6 +118,7 @@ describe('SVGRenderer', () => {
})
it('closes image preview on cancel', async () => {
const user = userEvent.setup()
render(<SVGRenderer content={validSvg} />)
await waitFor(() => {
@ -129,9 +131,11 @@ describe('SVGRenderer', () => {
expect(screen.getByAltText('Preview')).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
await user.click(screen.getByTestId('image-preview-close-button'))
expect(screen.queryByAltText('Preview')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByAltText('Preview')).not.toBeInTheDocument()
})
})
})
})

View File

@ -6,13 +6,6 @@ import * as React from 'react'
import Button from '@/app/components/base/button'
import { cn } from '@/utils/classnames'
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
// All overlay primitives (Tooltip / Popover / Dropdown / Select / Dialog / AlertDialog) — z-50
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
// This ensures overlays inside an AlertDialog (e.g. a Tooltip on a dialog button) render
// above the dialog backdrop instead of being clipped by it.
// Toast — z-[99], always on top (defined in toast component)
export const AlertDialog = BaseAlertDialog.Root
export const AlertDialogTrigger = BaseAlertDialog.Trigger
export const AlertDialogTitle = BaseAlertDialog.Title
@ -39,7 +32,7 @@ export function AlertDialogContent({
<BaseAlertDialog.Backdrop
{...backdropProps}
className={cn(
'fixed inset-0 z-50 bg-background-overlay',
'fixed inset-0 z-[1002] bg-background-overlay',
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
overlayClassName,
)}
@ -47,7 +40,7 @@ export function AlertDialogContent({
<BaseAlertDialog.Popup
{...popupProps}
className={cn(
'fixed left-1/2 top-1/2 z-50 max-h-[calc(100vh-2rem)] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'fixed left-1/2 top-1/2 z-[1002] max-h-[calc(100vh-2rem)] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'transition-[transform,scale,opacity] duration-150 data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
className,
)}

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-[1002] 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,11 +1,14 @@
'use client'
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
// All overlay primitives (Tooltip / Popover / Dropdown / Select / Dialog) — z-50
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
// All base/ui/* overlay primitives — z-[1002]
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
// This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render
// above the dialog backdrop instead of being clipped by it.
// Toast — z-[99], always on top (defined in toast component)
// During migration, z-[1002] is chosen to sit above all legacy overlays
// (Modal z-[60], PortalToFollowElem callers up to z-[1001]).
// Once all legacy overlays are migrated, this can be reduced back to z-50.
// Toast — z-[9999], always on top (defined in toast component)
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import * as React from 'react'
@ -54,14 +57,14 @@ export function DialogContent({
<DialogPortal>
<BaseDialog.Backdrop
className={cn(
'fixed inset-0 z-50 bg-background-overlay',
'fixed inset-0 z-[1002] bg-background-overlay',
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
overlayClassName,
)}
/>
<BaseDialog.Popup
className={cn(
'fixed left-1/2 top-1/2 z-50 max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'fixed left-1/2 top-1/2 z-[1002] max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'transition-[transform,scale,opacity] duration-150 data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
className,
)}

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 Link from 'next/link'
import { describe, expect, it, vi } from 'vitest'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuLinkItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
@ -15,18 +14,22 @@ import {
DropdownMenuTrigger,
} from '../index'
describe('dropdown-menu wrapper', () => {
describe('alias exports', () => {
it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => {
expect(DropdownMenu).toBe(Menu.Root)
expect(DropdownMenuPortal).toBe(Menu.Portal)
expect(DropdownMenuTrigger).toBe(Menu.Trigger)
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot)
expect(DropdownMenuGroup).toBe(Menu.Group)
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup)
})
})
vi.mock('next/link', () => ({
default: ({
href,
children,
...props
}: {
href: string
children?: ReactNode
} & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
<a href={href} {...props}>
{children}
</a>
),
}))
describe('dropdown-menu wrapper', () => {
describe('DropdownMenuContent', () => {
it('should position content at bottom-end with default placement when props are omitted', () => {
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', () => {
it('should forward passthrough props and handlers when separator props are provided', () => {
const handleMouseEnter = vi.fn()

View File

@ -8,6 +8,7 @@ import {
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuLinkItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
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 [sortOrder, setSortOrder] = useState('newest')
const [showArchived, setShowArchived] = useState(false)

View File

@ -3,6 +3,14 @@
import type { Placement } from '@/app/components/base/ui/placement'
import { Menu } from '@base-ui/react/menu'
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 { cn } from '@/utils/classnames'
@ -13,20 +21,13 @@ export const DropdownMenuSub = Menu.SubmenuRoot
export const DropdownMenuGroup = Menu.Group
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({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
return (
<Menu.RadioItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
className={cn(menuRowClassName, className)}
{...props}
/>
)
@ -38,10 +39,7 @@ export function DropdownMenuRadioItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
return (
<Menu.RadioItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -55,11 +53,7 @@ export function DropdownMenuCheckboxItem({
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
return (
<Menu.CheckboxItem
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
className,
)}
className={cn(menuRowClassName, className)}
{...props}
/>
)
@ -71,10 +65,7 @@ export function DropdownMenuCheckboxItemIndicator({
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
return (
<Menu.CheckboxItemIndicator
className={cn(
'ml-auto flex shrink-0 items-center text-text-accent',
className,
)}
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
@ -88,10 +79,7 @@ export function DropdownMenuGroupLabel({
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return (
<Menu.GroupLabel
className={cn(
'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase',
className,
)}
className={cn(menuGroupLabelClassName, className)}
{...props}
/>
)
@ -143,13 +131,13 @@ function renderDropdownMenuPopup({
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
className={cn('z-[1002] outline-none', className)}
{...positionerProps}
>
<Menu.Popup
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]',
'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',
menuPopupBaseClassName,
menuPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
@ -195,12 +183,7 @@ export function DropdownMenuSubTrigger({
}: DropdownMenuSubTriggerProps) {
return (
<Menu.SubmenuTrigger
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
>
{children}
@ -253,12 +236,26 @@ export function DropdownMenuItem({
}: DropdownMenuItemProps) {
return (
<Menu.Item
className={cn(
menuRowBaseClassName,
menuRowStateClassName,
destructive && 'text-text-destructive',
className,
)}
className={cn(menuRowClassName, destructive && 'text-text-destructive', className)}
{...props}
/>
)
}
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}
/>
)
@ -270,7 +267,7 @@ export function DropdownMenuSeparator({
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
return (
<Menu.Separator
className={cn('my-1 h-px bg-divider-subtle', className)}
className={cn(menuSeparatorClassName, className)}
{...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-[1002] bg-transparent transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none'

View File

@ -48,7 +48,7 @@ export function PopoverContent({
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
className={cn('z-[1002] outline-none', className)}
{...positionerProps}
>
<BasePopover.Popup

View File

@ -115,7 +115,7 @@ export function SelectContent({
sideOffset={sideOffset}
alignOffset={alignOffset}
alignItemWithTrigger={false}
className={cn('z-50 outline-none', className)}
className={cn('z-[1002] outline-none', className)}
{...positionerProps}
>
<BaseSelect.Popup

View File

@ -37,7 +37,7 @@ export function TooltipContent({
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-none', className)}
className={cn('z-[1002] outline-none', className)}
>
<BaseTooltip.Popup
className={cn(