mirror of
https://github.com/langgenius/dify.git
synced 2026-06-08 09:27:39 +08:00
fix: normalize app icon picker dialog state (#36621)
This commit is contained in:
@ -125,11 +125,11 @@ describe('AppIconPicker', () => {
|
||||
|
||||
const renderPicker = (props: Partial<ComponentProps<typeof AppIconPicker>> = {}) => {
|
||||
const onSelect = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
const { container } = render(<AppIconPicker onSelect={onSelect} onClose={onClose} {...props} />)
|
||||
const { container } = render(<AppIconPicker open onOpenChange={onOpenChange} onSelect={onSelect} {...props} />)
|
||||
|
||||
return { onSelect, onClose, container }
|
||||
return { onSelect, onOpenChange, container }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -157,8 +157,9 @@ describe('AppIconPicker', () => {
|
||||
it('should render emoji and image tabs when upload is enabled', async () => {
|
||||
renderPicker()
|
||||
|
||||
expect(await screen.findByText(/emoji/i))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/image/i))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: /emoji/i })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: /emoji/i }))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /image/i }))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/cancel/i))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/ok/i))!.toBeInTheDocument()
|
||||
})
|
||||
@ -173,12 +174,12 @@ describe('AppIconPicker', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when cancel is clicked', async () => {
|
||||
const { onClose } = renderPicker()
|
||||
it('should close when cancel is clicked', async () => {
|
||||
const { onOpenChange } = renderPicker()
|
||||
|
||||
await userEvent.click(screen.getByText(/cancel/i))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should switch between emoji and image tabs', async () => {
|
||||
@ -187,7 +188,7 @@ describe('AppIconPicker', () => {
|
||||
await userEvent.click(screen.getByText(/image/i))
|
||||
expect(screen.getByText(/drop.*here/i))!.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByText(/emoji/i))
|
||||
await userEvent.click(screen.getByRole('button', { name: /emoji/i }))
|
||||
expect(screen.getByPlaceholderText(/search/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -214,6 +215,14 @@ describe('AppIconPicker', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should close through the dialog open change contract when Escape is pressed', async () => {
|
||||
const { onOpenChange } = renderPicker()
|
||||
|
||||
await userEvent.keyboard('{Escape}')
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything())
|
||||
})
|
||||
|
||||
it('should not call onSelect when no emoji has been selected', async () => {
|
||||
const { onSelect } = renderPicker()
|
||||
|
||||
|
||||
@ -48,20 +48,20 @@ const AppIconPickerDemo = () => {
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<AppIconPicker
|
||||
onSelect={(result) => {
|
||||
setSelection(result)
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<AppIconPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onSelect={setSelection}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
open: false,
|
||||
onOpenChange: () => {},
|
||||
},
|
||||
render: () => <AppIconPickerDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
@ -74,15 +74,11 @@ const [selection, setSelection] = useState<AppIconSelection | null>(null)
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Choose icon…</button>
|
||||
{open && (
|
||||
<AppIconPicker
|
||||
onSelect={(result) => {
|
||||
setSelection(result)
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<AppIconPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onSelect={setSelection}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
`.trim(),
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Area } from 'react-easy-crop'
|
||||
import type { OnImageInput } from './ImageInput'
|
||||
import type { AppIconType, ImageFile } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { RiImageCircleAiLine } from '@remixicon/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
||||
import Divider from '../divider'
|
||||
import { defaultEmojiBackground } from '../emoji-picker/constants'
|
||||
import EmojiPickerInner from '../emoji-picker/Inner'
|
||||
import { useLocalFileUploader } from '../image-uploader/hooks'
|
||||
import ImageInput from './ImageInput'
|
||||
@ -31,8 +31,10 @@ export type AppIconImageSelection = {
|
||||
export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection
|
||||
|
||||
type AppIconPickerProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelect?: (payload: AppIconSelection) => void
|
||||
onClose?: () => void
|
||||
enableImageUpload?: boolean
|
||||
initialEmoji?: {
|
||||
icon: string
|
||||
background?: string | null
|
||||
@ -40,11 +42,50 @@ type AppIconPickerProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||
function AppIconPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
onClose,
|
||||
enableImageUpload = true,
|
||||
initialEmoji,
|
||||
}) => {
|
||||
className,
|
||||
}: AppIconPickerProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{open
|
||||
? (
|
||||
<AppIconPickerContent
|
||||
key={`${initialEmoji?.icon ?? ''}:${initialEmoji?.background ?? ''}`}
|
||||
initialEmoji={initialEmoji}
|
||||
enableImageUpload={enableImageUpload}
|
||||
className={className}
|
||||
onOpenChange={onOpenChange}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type AppIconPickerContentProps = {
|
||||
className?: string
|
||||
initialEmoji?: {
|
||||
icon: string
|
||||
background?: string | null
|
||||
}
|
||||
enableImageUpload: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelect?: (payload: AppIconSelection) => void
|
||||
}
|
||||
|
||||
function AppIconPickerContent({
|
||||
className,
|
||||
initialEmoji,
|
||||
enableImageUpload,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: AppIconPickerContentProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const tabs = [
|
||||
@ -52,11 +93,17 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||
{ key: 'image', label: t('iconPicker.image', { ns: 'app' }), icon: <RiImageCircleAiLine className="size-4" /> },
|
||||
]
|
||||
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
|
||||
const showImageUpload = enableImageUpload && !DISABLE_UPLOAD_IMAGE_AS_ICON
|
||||
|
||||
const [emoji, setEmoji] = useState<{ emoji: string, background: string }>()
|
||||
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
|
||||
setEmoji({ emoji, background })
|
||||
}, [setEmoji])
|
||||
const [emoji, setEmoji] = useState<{ emoji: string, background: string } | undefined>(() => {
|
||||
if (!initialEmoji?.icon)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
emoji: initialEmoji.icon,
|
||||
background: initialEmoji.background ?? defaultEmojiBackground,
|
||||
}
|
||||
})
|
||||
|
||||
const [uploading, setUploading] = useState<boolean>()
|
||||
|
||||
@ -71,6 +118,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||
fileId: imageFile.fileId,
|
||||
url: imageFile.url,
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -94,6 +142,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||
icon: emoji.emoji,
|
||||
background: emoji.background,
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
else {
|
||||
@ -111,54 +160,55 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', s.container, 'h-[min(462px,calc(100dvh-2rem))]! max-h-none! w-[362px]! p-0!')}>
|
||||
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', s.container, 'h-[min(462px,calc(100dvh-2rem))]! max-h-none! w-[362px]! p-0!', className)}>
|
||||
<DialogTitle className="sr-only">
|
||||
{t('iconPicker.emoji', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
|
||||
{!DISABLE_UPLOAD_IMAGE_AS_ICON && (
|
||||
<div className="w-full p-2 pb-0">
|
||||
<div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary',
|
||||
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.key as AppIconType)}
|
||||
>
|
||||
{tab.icon}
|
||||
{' '}
|
||||
{showImageUpload && (
|
||||
<div className="w-full p-2 pb-0">
|
||||
<div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary',
|
||||
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.key as AppIconType)}
|
||||
>
|
||||
{tab.icon}
|
||||
{' '}
|
||||
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'emoji' && (
|
||||
<EmojiPickerInner
|
||||
className={cn('flex-1 overflow-hidden pt-2')}
|
||||
emoji={initialEmoji?.icon}
|
||||
background={initialEmoji?.background ?? undefined}
|
||||
onSelect={handleSelectEmoji}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />}
|
||||
|
||||
<Divider className="m-0" />
|
||||
<div className="flex w-full items-center justify-center gap-2 p-3">
|
||||
<Button className="w-full" onClick={() => onClose?.()}>
|
||||
{t('iconPicker.cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
|
||||
<Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}>
|
||||
{t('iconPicker.ok', { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{activeTab === 'emoji' && (
|
||||
<EmojiPickerInner
|
||||
className={cn('flex-1 overflow-hidden pt-2')}
|
||||
emoji={initialEmoji?.icon}
|
||||
background={initialEmoji?.background ?? undefined}
|
||||
onSelect={(emoji, background) => setEmoji({ emoji, background })}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />}
|
||||
|
||||
<Divider className="m-0" />
|
||||
<div className="flex w-full items-center justify-center gap-2 p-3">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
{t('iconPicker.cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
|
||||
<Button variant="primary" className="w-full" disabled={uploading} loading={uploading} onClick={handleSelect}>
|
||||
{t('iconPicker.ok', { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { EmojiMartData } from '@emoji-mart/data'
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import data from '@emoji-mart/data'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
@ -12,31 +12,10 @@ import { useState } from 'react'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { searchEmoji } from '@/utils/emoji'
|
||||
import { backgroundColors, defaultEmojiBackground } from './constants'
|
||||
|
||||
init({ data })
|
||||
|
||||
const backgroundColors = [
|
||||
'#FFEAD5',
|
||||
'#E4FBCC',
|
||||
'#D3F8DF',
|
||||
'#E0F2FE',
|
||||
|
||||
'#E0EAFF',
|
||||
'#EFF1F5',
|
||||
'#FBE8FF',
|
||||
'#FCE7F6',
|
||||
|
||||
'#FEF7C3',
|
||||
'#E6F4D7',
|
||||
'#D5F5F6',
|
||||
'#D1E9FF',
|
||||
|
||||
'#D1E0FF',
|
||||
'#D5D9EB',
|
||||
'#ECE9FE',
|
||||
'#FFE4E8',
|
||||
]
|
||||
|
||||
type IEmojiPickerInnerProps = {
|
||||
emoji?: string
|
||||
background?: string
|
||||
@ -44,28 +23,32 @@ type IEmojiPickerInnerProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
|
||||
function EmojiPickerInner({
|
||||
emoji,
|
||||
background,
|
||||
onSelect,
|
||||
className,
|
||||
}) => {
|
||||
}: IEmojiPickerInnerProps) {
|
||||
const { categories } = data as EmojiMartData
|
||||
const [selectedEmoji, setSelectedEmoji] = useState(emoji || '')
|
||||
const [selectedBackground, setSelectedBackground] = useState(background || backgroundColors[0])
|
||||
const [selectedBackground, setSelectedBackground] = useState(background || defaultEmojiBackground)
|
||||
const [showStyleColors, setShowStyleColors] = useState(!!emoji)
|
||||
|
||||
const [searchedEmojis, setSearchedEmojis] = useState<string[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const styleColorsLabelId = React.useId()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedEmoji) {
|
||||
/* v8 ignore next 2 - @preserve */
|
||||
if (selectedBackground)
|
||||
onSelect?.(selectedEmoji, selectedBackground)
|
||||
}
|
||||
}, [onSelect, selectedEmoji, selectedBackground])
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
setSelectedEmoji(emoji)
|
||||
setShowStyleColors(true)
|
||||
onSelect?.(emoji, selectedBackground)
|
||||
}
|
||||
|
||||
const handleBackgroundSelect = (background: string) => {
|
||||
setSelectedBackground(background)
|
||||
if (selectedEmoji)
|
||||
onSelect?.(selectedEmoji, background)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'flex flex-col')}>
|
||||
@ -108,8 +91,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
|
||||
aria-label={emoji}
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0"
|
||||
onClick={() => {
|
||||
setSelectedEmoji(emoji)
|
||||
setShowStyleColors(true)
|
||||
handleEmojiSelect(emoji)
|
||||
}}
|
||||
>
|
||||
<span className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1">
|
||||
@ -136,8 +118,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
|
||||
aria-label={emoji}
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border-none bg-transparent p-0"
|
||||
onClick={() => {
|
||||
setSelectedEmoji(emoji)
|
||||
setShowStyleColors(true)
|
||||
handleEmojiSelect(emoji)
|
||||
}}
|
||||
>
|
||||
<span className="flex size-8 cursor-pointer items-center justify-center rounded-lg p-1 ring-components-input-border-hover ring-offset-1 hover:ring-1">
|
||||
@ -194,7 +175,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedBackground(color)
|
||||
handleBackgroundSelect(color)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
|
||||
@ -46,13 +46,11 @@ describe('EmojiPickerInner', () => {
|
||||
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('initializes selected emoji and background when provided', async () => {
|
||||
it('initializes selected emoji and background when provided', () => {
|
||||
render(<EmojiPickerInner emoji="rabbit" background="#E4FBCC" onSelect={mockOnSelect} />)
|
||||
|
||||
expect(screen.getByText('Choose Style'))!.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC')
|
||||
})
|
||||
expect(mockOnSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -26,26 +26,27 @@ vi.mock('@/utils/emoji', () => ({
|
||||
|
||||
describe('EmojiPicker', () => {
|
||||
const mockOnSelect = vi.fn()
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnOpenChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders nothing when isModal is false', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<EmojiPicker isModal={false} />,
|
||||
<EmojiPicker open={false} onOpenChange={mockOnOpenChange} />,
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders modal when isModal is true', async () => {
|
||||
it('renders modal when open', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<EmojiPicker isModal={true} />,
|
||||
<EmojiPicker open onOpenChange={mockOnOpenChange} />,
|
||||
)
|
||||
})
|
||||
expect(screen.getByRole('dialog', { name: /Emoji/i }))!.toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/Cancel/i))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/OK/i))!.toBeInTheDocument()
|
||||
@ -54,7 +55,7 @@ describe('EmojiPicker', () => {
|
||||
it('OK button is disabled initially', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<EmojiPicker />,
|
||||
<EmojiPicker open onOpenChange={mockOnOpenChange} />,
|
||||
)
|
||||
})
|
||||
const okButton = screen.getByText(/OK/i).closest('button')
|
||||
@ -65,7 +66,7 @@ describe('EmojiPicker', () => {
|
||||
const customClass = 'custom-wrapper-class'
|
||||
await act(async () => {
|
||||
render(
|
||||
<EmojiPicker className={customClass} />,
|
||||
<EmojiPicker open onOpenChange={mockOnOpenChange} className={customClass} />,
|
||||
)
|
||||
})
|
||||
const dialog = screen.getByRole('dialog')
|
||||
@ -77,7 +78,7 @@ describe('EmojiPicker', () => {
|
||||
it('calls onSelect with selected emoji and background when OK is clicked', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<EmojiPicker onSelect={mockOnSelect} />,
|
||||
<EmojiPicker open onOpenChange={mockOnOpenChange} onSelect={mockOnSelect} />,
|
||||
)
|
||||
})
|
||||
|
||||
@ -95,10 +96,10 @@ describe('EmojiPicker', () => {
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(expect.any(String), expect.any(String))
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', async () => {
|
||||
it('closes when Cancel is clicked', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<EmojiPicker onClose={mockOnClose} />,
|
||||
<EmojiPicker open onOpenChange={mockOnOpenChange} />,
|
||||
)
|
||||
})
|
||||
|
||||
@ -107,7 +108,7 @@ describe('EmojiPicker', () => {
|
||||
fireEvent.click(cancelButton)
|
||||
})
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
23
web/app/components/base/emoji-picker/constants.ts
Normal file
23
web/app/components/base/emoji-picker/constants.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export const backgroundColors = [
|
||||
'#FFEAD5',
|
||||
'#E4FBCC',
|
||||
'#D3F8DF',
|
||||
'#E0F2FE',
|
||||
|
||||
'#E0EAFF',
|
||||
'#EFF1F5',
|
||||
'#FBE8FF',
|
||||
'#FCE7F6',
|
||||
|
||||
'#FEF7C3',
|
||||
'#E6F4D7',
|
||||
'#D5F5F6',
|
||||
'#D1E9FF',
|
||||
|
||||
'#D1E0FF',
|
||||
'#D5D9EB',
|
||||
'#ECE9FE',
|
||||
'#FFE4E8',
|
||||
]
|
||||
|
||||
export const defaultEmojiBackground = backgroundColors[0]!
|
||||
@ -47,20 +47,20 @@ const EmojiPickerDemo = () => {
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<EmojiPicker
|
||||
onSelect={(emoji, background) => {
|
||||
setSelection({ emoji, background })
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<EmojiPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onSelect={(emoji, background) => setSelection({ emoji, background })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
open: false,
|
||||
onOpenChange: () => {},
|
||||
},
|
||||
render: () => <EmojiPickerDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
@ -73,15 +73,11 @@ const [selection, setSelection] = useState<{ emoji: string; background: string }
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Open emoji picker…</button>
|
||||
{open && (
|
||||
<EmojiPicker
|
||||
onSelect={(emoji, background) => {
|
||||
setSelection({ emoji, background })
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<EmojiPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onSelect={(emoji, background) => setSelection({ emoji, background })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
`.trim(),
|
||||
|
||||
@ -1,75 +1,95 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import EmojiPickerInner from './Inner'
|
||||
|
||||
type IEmojiPickerProps = {
|
||||
isModal?: boolean
|
||||
type EmojiPickerProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelect?: (emoji: string, background: string) => void
|
||||
onClose?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const EmojiPicker: FC<IEmojiPickerProps> = ({
|
||||
isModal = true,
|
||||
function EmojiPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
onClose,
|
||||
className,
|
||||
}) => {
|
||||
}: EmojiPickerProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{open
|
||||
? (
|
||||
<EmojiPickerContent
|
||||
className={className}
|
||||
onOpenChange={onOpenChange}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
type EmojiPickerContentProps = {
|
||||
className?: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelect?: (emoji: string, background: string) => void
|
||||
}
|
||||
|
||||
function EmojiPickerContent({
|
||||
className,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: EmojiPickerContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const [selectedEmoji, setSelectedEmoji] = useState('')
|
||||
const [selectedBackground, setSelectedBackground] = useState<string>()
|
||||
|
||||
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
|
||||
setSelectedEmoji(emoji)
|
||||
setSelectedBackground(background)
|
||||
}, [setSelectedEmoji, setSelectedBackground])
|
||||
return (
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'max-h-none w-full overflow-hidden! text-left align-middle',
|
||||
'flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
{t('iconPicker.emoji', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
|
||||
return isModal
|
||||
? (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'max-h-none w-full overflow-hidden! text-left align-middle',
|
||||
'flex max-h-[552px] flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
<EmojiPickerInner
|
||||
className="pt-3"
|
||||
onSelect={handleSelectEmoji}
|
||||
/>
|
||||
<Divider className="mt-3 mb-0" />
|
||||
<div className="flex w-full items-center justify-center gap-2 p-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onClose?.()
|
||||
}}
|
||||
>
|
||||
{t('iconPicker.cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selectedEmoji === '' || !selectedBackground}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onSelect?.(selectedEmoji, selectedBackground!)
|
||||
}}
|
||||
>
|
||||
{t('iconPicker.ok', { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
: <></>
|
||||
<EmojiPickerInner
|
||||
className="pt-3"
|
||||
onSelect={(emoji, background) => {
|
||||
setSelectedEmoji(emoji)
|
||||
setSelectedBackground(background)
|
||||
}}
|
||||
/>
|
||||
<Divider className="mt-3 mb-0" />
|
||||
<div className="flex w-full items-center justify-center gap-2 p-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('iconPicker.cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selectedEmoji === '' || !selectedBackground}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onSelect?.(selectedEmoji, selectedBackground!)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
{t('iconPicker.ok', { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
export default EmojiPicker
|
||||
|
||||
@ -81,7 +81,7 @@ export const useImageFiles = () => {
|
||||
filesRef.current = newFiles
|
||||
setFiles(newFiles)
|
||||
},
|
||||
}, !!params.token)
|
||||
}, !!params?.token)
|
||||
}
|
||||
}
|
||||
const handleClear = () => {
|
||||
@ -145,13 +145,13 @@ export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useL
|
||||
toast.error(errorMessage)
|
||||
onUpload({ ...imageFile, progress: -1 })
|
||||
},
|
||||
}, !!params.token)
|
||||
}, !!params?.token)
|
||||
}, false)
|
||||
reader.addEventListener('error', () => {
|
||||
toast.error(t('imageUploader.uploadFromComputerReadError', { ns: 'common' }))
|
||||
}, false)
|
||||
reader.readAsDataURL(file)
|
||||
}, [disabled, limit, t, onUpload, params.token])
|
||||
}, [disabled, limit, t, onUpload, params?.token])
|
||||
return { disabled, handleLocalFileUpload }
|
||||
}
|
||||
type useClipboardUploaderProps = {
|
||||
|
||||
Reference in New Issue
Block a user