mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
chore: fix ts
This commit is contained in:
@ -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
|
||||
|
||||
@ -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}`
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
? (
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
257
web/app/components/base/ui/context-menu/__tests__/index.spec.tsx
Normal file
257
web/app/components/base/ui/context-menu/__tests__/index.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
215
web/app/components/base/ui/context-menu/index.stories.tsx
Normal file
215
web/app/components/base/ui/context-menu/index.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
302
web/app/components/base/ui/context-menu/index.tsx
Normal file
302
web/app/components/base/ui/context-menu/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
7
web/app/components/base/ui/menu-shared.ts
Normal file
7
web/app/components/base/ui/menu-shared.ts
Normal 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'
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user