chore: use semantic tags for interactive elements (#41813)

This commit is contained in:
Joel 2026-09-04 10:02:23 +00:00 committed by GitHub
parent dcb7b36ad4
commit 6f37f0661d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 351 additions and 262 deletions

View File

@ -191,12 +191,6 @@
"eslint-react/set-state-in-effect": {
"count": 5
},
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
},
"typescript/no-explicit-any": {
"count": 1
}
@ -295,12 +289,6 @@
"eslint-react/set-state-in-effect": {
"count": 1
},
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
},
"typescript/no-explicit-any": {
"count": 4
}
@ -618,14 +606,6 @@
"count": 3
}
},
"web/app/components/base/chat/chat-with-history/sidebar/item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/base/chat/chat/__tests__/hooks.spec.tsx": {
"no-restricted-imports": {
"count": 1
@ -1209,14 +1189,6 @@
"count": 3
}
},
"web/app/components/base/image-gallery/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-noninteractive-element-interactions": {
"count": 1
}
},
"web/app/components/base/image-uploader/__tests__/image-preview.spec.tsx": {
"erasable-syntax-only/parameter-properties": {
"count": 1
@ -1240,14 +1212,6 @@
"count": 1
}
},
"web/app/components/base/image-uploader/image-list.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-noninteractive-element-interactions": {
"count": 1
}
},
"web/app/components/base/image-uploader/image-preview.tsx": {
"jsx-a11y/no-static-element-interactions": {
"count": 1
@ -1309,12 +1273,6 @@
}
},
"web/app/components/base/markdown-blocks/link.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-noninteractive-element-interactions": {
"count": 1
},
"typescript/no-explicit-any": {
"count": 1
}
@ -1628,14 +1586,6 @@
"count": 1
}
},
"web/app/components/base/tab-slider-plain/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/base/tag-input/index.stories.tsx": {
"jsx-a11y/label-has-associated-control": {
"count": 12
@ -1698,11 +1648,6 @@
"count": 3
}
},
"web/app/components/datasets/chunk.tsx": {
"jsx-a11y/label-has-associated-control": {
"count": 2
}
},
"web/app/components/datasets/common/document-status-with-action/status-with-action.tsx": {
"eslint-react/static-components": {
"count": 2
@ -1794,14 +1739,6 @@
"count": 2
}
},
"web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-noninteractive-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/create/file-uploader/hooks/use-file-upload.ts": {
"no-restricted-imports": {
"count": 1
@ -1963,14 +1900,6 @@
"count": 2
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-noninteractive-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -2160,12 +2089,6 @@
"web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx": {
"eslint-react/set-state-in-effect": {
"count": 1
},
"jsx-a11y/click-events-have-key-events": {
"count": 3
},
"jsx-a11y/no-static-element-interactions": {
"count": 3
}
},
"web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx": {
@ -3369,14 +3292,6 @@
"count": 2
}
},
"web/app/components/workflow/nodes/_base/components/form-input-boolean.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 2
},
"jsx-a11y/no-static-element-interactions": {
"count": 2
}
},
"web/app/components/workflow/nodes/_base/components/form-input-item.tsx": {
"no-restricted-imports": {
"count": 1
@ -3754,14 +3669,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/http/components/authorization/radio-group.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx": {
"typescript/no-explicit-any": {
"count": 2

View File

@ -294,13 +294,16 @@ const ViewAnnotationModal: FC<Props> = ({ appId, isShow, onHide, item, onSave, o
</div>
{id && (
<div className="flex h-16 shrink-0 items-center justify-between rounded-b-xl border-t border-divider-subtle bg-background-section-burn px-4 system-sm-medium text-text-tertiary">
<div
className="flex cursor-pointer items-center space-x-2 pl-3"
<button
type="button"
className="flex cursor-pointer appearance-none items-center space-x-2 border-0 bg-transparent py-0 pr-0 pl-3 text-left"
onClick={() => setShowModal(true)}
>
<MessageCheckRemove />
<div>{t(($) => $['editModal.removeThisCache'], { ns: 'appAnnotation' })}</div>
</div>
<span>
{t(($) => $['editModal.removeThisCache'], { ns: 'appAnnotation' })}
</span>
</button>
<div>
{t(($) => $['editModal.createdAt'], { ns: 'appAnnotation' })}
&nbsp;

View File

@ -197,13 +197,14 @@ const SettingBuiltInTool: FC<Props> = ({
</IconButton>
</div>
{showBackButton && (
<div
className="mb-2 flex cursor-pointer items-center gap-1 system-xs-semibold-uppercase text-text-accent-secondary"
<button
type="button"
className="mb-2 flex cursor-pointer appearance-none items-center gap-1 border-0 bg-transparent p-0 text-left system-xs-semibold-uppercase text-text-accent-secondary"
onClick={onHide}
>
<RiArrowLeftLine className="size-4" />
{t(($) => $['detailPanel.operation.back'], { ns: 'plugin' })}
</div>
</button>
)}
<div className="flex items-center gap-1">
<Icon size="tiny" className="size-6" src={collection.icon} />

View File

@ -28,16 +28,20 @@ const Item: FC<ItemProps> = ({
ref={ref}
key={item.id}
className={cn(
'group flex cursor-pointer rounded-lg p-1 pl-3 system-sm-medium text-components-menu-item-text hover:bg-state-base-hover',
'group flex rounded-lg p-1 pl-3 system-sm-medium text-components-menu-item-text hover:bg-state-base-hover',
isSelected && 'bg-state-accent-active text-text-accent hover:bg-state-accent-active',
)}
onClick={() => onChangeConversation(item.id)}
>
<div className="grow truncate p-1 pl-0" title={item.name}>
<button
type="button"
className="min-w-0 grow cursor-pointer appearance-none truncate border-0 bg-transparent p-1 pl-0 text-left"
title={item.name}
onClick={() => onChangeConversation(item.id)}
>
{item.name}
</div>
</button>
{item.id !== '' && (
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<div className="shrink-0">
<Operation
isActive={isSelected}
isPinned={!!isPin}

View File

@ -1,7 +1,14 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createReactI18nextMock } from '@/test/i18n-mock'
import ImageGallery from '..'
vi.mock('react-i18next', () =>
createReactI18nextMock({
'imageGallery.previewImage': 'Preview image {{index}} of {{total}}',
}),
)
describe('ImageGallery', () => {
it('previews the selected image and closes on Escape', async () => {
const user = userEvent.setup()
@ -9,7 +16,7 @@ describe('ImageGallery', () => {
<ImageGallery srcs={['https://example.com/first.png', 'https://example.com/second.png']} />,
)
await user.click(screen.getAllByTestId('gallery-image')[1]!)
await user.click(screen.getByRole('button', { name: 'Preview image 2 of 2' }))
expect(screen.getByTestId('image-preview-container').querySelector('img')).toHaveAttribute(
'src',

View File

@ -3,6 +3,7 @@ import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import s from './style.module.css'
@ -29,6 +30,7 @@ const getWidthStyle = (imgNum: number) => {
}
const ImageGallery: FC<Props> = ({ srcs }) => {
const { t } = useTranslation('common')
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const imgNum = srcs.length
@ -37,16 +39,25 @@ const ImageGallery: FC<Props> = ({ srcs }) => {
<div className={cn(s[`img-${imgNum}`], 'flex flex-wrap')} data-testid="image-gallery">
{srcs.map((src, index) =>
!src ? null : (
<img
<button
type="button"
key={index}
className={s.item}
style={imgStyle}
src={src}
alt=""
data-testid="gallery-image" // Added for testing
aria-label={t(($) => $['imageGallery.previewImage'], {
index: index + 1,
total: imgNum,
})}
onClick={() => setImagePreviewUrl(src)}
onError={(e) => e.currentTarget.remove()}
/>
>
<img
className={s.image}
src={src}
alt=""
data-testid="gallery-image" // Added for testing
onError={(e) => e.currentTarget.parentElement?.remove()}
/>
</button>
),
)}
{imagePreviewUrl && (

View File

@ -2,12 +2,29 @@
max-height: 200px;
margin-right: 8px;
margin-bottom: 8px;
object-fit: contain;
object-position: center;
padding: 0;
overflow: hidden;
line-height: 0;
appearance: none;
background: transparent;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.image {
display: block;
width: 100%;
max-height: 200px;
object-fit: contain;
object-position: center;
}
.img-1 .image {
width: auto;
max-width: 100%;
}
.item:nth-child(3n) {
margin-right: 0;
}

View File

@ -1,9 +1,18 @@
import type { ImageFile } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createReactI18nextMock } from '@/test/i18n-mock'
import { TransferMethod } from '@/types/app'
import ImageList from '../image-list'
vi.mock('react-i18next', () =>
createReactI18nextMock({
'imageGallery.previewImage': 'Preview image {{index}} of {{total}}',
}),
)
const previewButtonName = 'Preview image 1 of 1'
const createLocalFile = (overrides: Partial<ImageFile> = {}): ImageFile => ({
type: TransferMethod.local_file,
_id: `local-${Date.now()}-${Math.random()}`,
@ -31,32 +40,32 @@ describe('ImageList', () => {
describe('Rendering', () => {
it('should render images for each item in the list', () => {
const list = [createLocalFile({ _id: 'file-1' }), createLocalFile({ _id: 'file-2' })]
render(<ImageList list={list} />)
const { container } = render(<ImageList list={list} />)
const images = screen.getAllByRole('img')
const images = container.querySelectorAll('img')
expect(images).toHaveLength(2)
})
it('should use base64Url as src for local files', () => {
const list = [createLocalFile({ _id: 'file-1', base64Url: 'data:image/png;base64,xyz' })]
render(<ImageList list={list} />)
const { container } = render(<ImageList list={list} />)
expect(screen.getByRole('img')).toHaveAttribute('src', 'data:image/png;base64,xyz')
expect(container.querySelector('img')).toHaveAttribute('src', 'data:image/png;base64,xyz')
})
it('should use url as src for remote files', () => {
const list = [createRemoteFile({ _id: 'file-1', url: 'https://example.com/img.jpg' })]
render(<ImageList list={list} />)
const { container } = render(<ImageList list={list} />)
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://example.com/img.jpg')
expect(container.querySelector('img')).toHaveAttribute('src', 'https://example.com/img.jpg')
})
it('should set alt attribute from file name', () => {
it('should use the file name for a non-interactive local thumbnail', () => {
const file = new File(['test'], 'my-image.png', { type: 'image/png' })
const list = [createLocalFile({ _id: 'file-1', file })]
const list = [createLocalFile({ _id: 'file-1', file, progress: 50 })]
render(<ImageList list={list} />)
expect(screen.getByRole('img')).toHaveAttribute('alt', 'my-image.png')
expect(screen.getByRole('img', { name: 'my-image.png' })).toBeInTheDocument()
})
})
@ -72,7 +81,9 @@ describe('ImageList', () => {
const list = [createLocalFile({ _id: 'file-1' })]
render(<ImageList list={list} readonly onRemove={vi.fn()} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.operation.remove' }),
).not.toBeInTheDocument()
})
})
@ -94,9 +105,10 @@ describe('ImageList', () => {
it('should show retry icon when local file upload fails (progress -1)', () => {
const onReUpload = vi.fn()
const list = [createLocalFile({ _id: 'file-1', progress: -1 })]
render(<ImageList list={list} onReUpload={onReUpload} />)
render(<ImageList list={list} onReUpload={onReUpload} readonly />)
expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeInTheDocument()
expect(within(screen.getByRole('listitem')).getAllByRole('button')).toHaveLength(1)
expect(screen.queryByText(/\d+\s*%/)).not.toBeInTheDocument()
})
})
@ -131,7 +143,7 @@ describe('ImageList', () => {
const list = [createLocalFile({ _id: 'file-1' })]
render(<ImageList list={list} onRemove={onRemove} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(onRemove).toHaveBeenCalledTimes(1)
expect(onRemove).toHaveBeenCalledWith('file-1')
@ -153,19 +165,17 @@ describe('ImageList', () => {
]
render(<ImageList list={list} />)
await user.click(screen.getByRole('img'))
await user.click(screen.getByRole('button', { name: previewButtonName }))
const preview = screen.getByTestId('image-preview-container')
expect(preview).toBeInTheDocument()
})
it('should not open image preview when clicking an in-progress image', async () => {
const user = userEvent.setup()
it('should not expose a preview action for an in-progress image', () => {
const list = [createLocalFile({ _id: 'file-1', progress: 50 })]
render(<ImageList list={list} />)
await user.click(screen.getByRole('img'))
render(<ImageList list={list} readonly />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
expect(screen.queryByTestId('image-preview-container')).not.toBeInTheDocument()
})
@ -175,7 +185,7 @@ describe('ImageList', () => {
render(<ImageList list={list} />)
// Open preview
await user.click(screen.getByRole('img'))
await user.click(screen.getByRole('button', { name: previewButtonName }))
expect(screen.queryByTestId('image-preview-container')).toBeInTheDocument()
// Close preview
@ -195,7 +205,7 @@ describe('ImageList', () => {
]
render(<ImageList list={list} />)
await user.click(screen.getByRole('img'))
await user.click(screen.getByRole('button', { name: previewButtonName }))
const previewImage = screen.getByTestId('image-preview-image')
expect(previewImage).toBeInTheDocument()
@ -207,9 +217,11 @@ describe('ImageList', () => {
it('should call onImageLinkLoadSuccess for remote URL on load when progress is not -1', () => {
const onImageLinkLoadSuccess = vi.fn()
const list = [createRemoteFile({ _id: 'file-1', progress: 0 })]
render(<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />)
const { container } = render(
<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />,
)
const img = screen.getByRole('img')
const img = container.querySelector('img')!
fireEvent.load(img)
expect(onImageLinkLoadSuccess).toHaveBeenCalledWith('file-1')
})
@ -217,9 +229,11 @@ describe('ImageList', () => {
it('should not call onImageLinkLoadSuccess for remote URL when progress is -1', () => {
const onImageLinkLoadSuccess = vi.fn()
const list = [createRemoteFile({ _id: 'file-1', progress: -1 })]
render(<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />)
const { container } = render(
<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />,
)
const img = screen.getByRole('img')
const img = container.querySelector('img')!
fireEvent.load(img)
expect(onImageLinkLoadSuccess).not.toHaveBeenCalled()
@ -228,9 +242,11 @@ describe('ImageList', () => {
it('should not call onImageLinkLoadSuccess for local file type', () => {
const onImageLinkLoadSuccess = vi.fn()
const list = [createLocalFile({ _id: 'file-1', progress: 50 })]
render(<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />)
const { container } = render(
<ImageList list={list} onImageLinkLoadSuccess={onImageLinkLoadSuccess} />,
)
const img = screen.getByRole('img')
const img = container.querySelector('img')!
fireEvent.load(img)
expect(onImageLinkLoadSuccess).not.toHaveBeenCalled()
@ -239,9 +255,11 @@ describe('ImageList', () => {
it('should call onImageLinkLoadError for remote URL on error', () => {
const onImageLinkLoadError = vi.fn()
const list = [createRemoteFile({ _id: 'file-1', progress: 0 })]
render(<ImageList list={list} onImageLinkLoadError={onImageLinkLoadError} />)
const { container } = render(
<ImageList list={list} onImageLinkLoadError={onImageLinkLoadError} />,
)
const img = screen.getByRole('img')
const img = container.querySelector('img')!
fireEvent.error(img)
expect(onImageLinkLoadError).toHaveBeenCalledWith('file-1')
@ -250,9 +268,11 @@ describe('ImageList', () => {
it('should not call onImageLinkLoadError for local file type', () => {
const onImageLinkLoadError = vi.fn()
const list = [createLocalFile({ _id: 'file-1', progress: 50 })]
render(<ImageList list={list} onImageLinkLoadError={onImageLinkLoadError} />)
const { container } = render(
<ImageList list={list} onImageLinkLoadError={onImageLinkLoadError} />,
)
const img = screen.getByRole('img')
const img = container.querySelector('img')!
fireEvent.error(img)
expect(onImageLinkLoadError).not.toHaveBeenCalled()
@ -262,17 +282,16 @@ describe('ImageList', () => {
describe('Edge Cases', () => {
it('should handle list with mixed local and remote files', () => {
const list = [createLocalFile({ _id: 'local-1' }), createRemoteFile({ _id: 'remote-1' })]
render(<ImageList list={list} />)
const { container } = render(<ImageList list={list} />)
expect(screen.getAllByRole('img')).toHaveLength(2)
expect(container.querySelectorAll('img')).toHaveLength(2)
})
it('should handle item without file property for alt attribute', () => {
const list = [createLocalFile({ _id: 'file-1', file: undefined })]
render(<ImageList list={list} />)
const { container } = render(<ImageList list={list} />)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
expect(container.querySelector('img')).toHaveAttribute('alt', '')
})
it('should handle onRemove not provided gracefully', async () => {
@ -280,8 +299,8 @@ describe('ImageList', () => {
const list = [createLocalFile({ _id: 'file-1' })]
render(<ImageList list={list} />)
// Button exists, clicking it should not throw
await user.click(screen.getByRole('button'))
// Remove button exists, clicking it should not throw
await user.click(screen.getByRole('button', { name: 'common.operation.remove' }))
})
})
})

View File

@ -37,6 +37,15 @@ const ImageList: FC<ImageListProps> = ({
if (item.type === TransferMethod.remote_url && onImageLinkLoadError)
onImageLinkLoadError(item._id)
}
const renderImage = (item: ImageFile) => (
<img
className="block h-16 w-16 rounded-lg border-[0.5px] border-black/5 object-cover"
alt={item.progress === 100 ? '' : (item.file?.name ?? '')}
onLoad={() => handleImageLinkLoadSuccess(item)}
onError={() => handleImageLinkLoadError(item)}
src={item.type === TransferMethod.remote_url ? item.url : item.base64Url}
/>
)
return (
<>
@ -47,7 +56,7 @@ const ImageList: FC<ImageListProps> = ({
role="list"
aria-label={t(($) => $['imageUploader.imageList'], { ns: 'common' })}
>
{list.map((item) => (
{list.map((item, index) => (
<li
key={item._id}
className="group relative mr-1 rounded-lg border-[0.5px] border-black/5"
@ -104,19 +113,26 @@ const ImageList: FC<ImageListProps> = ({
)}
</div>
)}
<img
className="h-16 w-16 cursor-pointer rounded-lg border-[0.5px] border-black/5 object-cover"
alt={item.file?.name}
onLoad={() => handleImageLinkLoadSuccess(item)}
onError={() => handleImageLinkLoadError(item)}
src={item.type === TransferMethod.remote_url ? item.url : item.base64Url}
onClick={() =>
item.progress === 100 &&
setImagePreviewUrl(
(item.type === TransferMethod.remote_url ? item.url : item.base64Url) as string,
)
}
/>
{item.progress === 100 ? (
<button
type="button"
aria-label={t(($) => $['imageGallery.previewImage'], {
ns: 'common',
index: index + 1,
total: list.length,
})}
className="block cursor-pointer appearance-none rounded-lg border-0 bg-transparent p-0 leading-none"
onClick={() =>
setImagePreviewUrl(
(item.type === TransferMethod.remote_url ? item.url : item.base64Url) as string,
)
}
>
{renderImage(item)}
</button>
) : (
renderImage(item)
)}
{!readonly && (
<button
type="button"

View File

@ -23,9 +23,9 @@ describe('Link component', () => {
})
// --------------------------
// ABBR LINK
// ABBR ACTION
// --------------------------
it('renders abbr link and calls onSend when clicked', () => {
it('renders abbr action as a button and calls onSend when clicked', () => {
const node = {
properties: {
href: 'abbr:hello%20world',
@ -35,15 +35,15 @@ describe('Link component', () => {
render(<Link node={node} />)
const abbr = screen.getByText('Tooltip text')
expect(abbr.tagName).toBe('ABBR')
const button = screen.getByRole('button', { name: 'Tooltip text' })
expect(button.tagName).toBe('BUTTON')
fireEvent.click(abbr)
fireEvent.click(button)
expect(mockOnSend).toHaveBeenCalledWith('hello world')
})
it('renders abbr with empty fallback title/value when child value is missing', () => {
it('renders abbr action with empty fallback title/value when child value is missing', () => {
const node = {
properties: {
href: 'abbr:hi',
@ -53,10 +53,10 @@ describe('Link component', () => {
const { container } = render(<Link node={node} />)
const abbr = container.querySelector('abbr')
expect(abbr).toBeTruthy()
expect(abbr?.tagName).toBe('ABBR')
fireEvent.click(abbr as HTMLElement)
const button = container.querySelector('button')
expect(button).toBeTruthy()
expect(button?.tagName).toBe('BUTTON')
fireEvent.click(button as HTMLElement)
expect(mockOnSend).toHaveBeenCalledWith('hi')
})

View File

@ -26,13 +26,14 @@ const Link = ({ node, children, ...props }: any) => {
const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1])
return (
<abbr
className={commonClassName}
<button
type="button"
className={`${commonClassName} font-inherit inline appearance-none border-0 bg-transparent p-0 text-inherit`}
onClick={() => onSend?.(hidden_text)}
title={node.children[0]?.value || ''}
>
{node.children[0]?.value || ''}
</abbr>
</button>
)
} else {
const rawHref = props.href || node.properties?.href

View File

@ -17,30 +17,31 @@ type ItemProps = Readonly<{
}>
const Item: FC<ItemProps> = ({ className, isActive, onClick, option, smallItem }) => {
return (
<div
<button
type="button"
key={option.value}
data-testid={`tab-slider-item-${option.value}`}
className={cn(
'relative pb-2.5',
'relative appearance-none border-0 bg-transparent px-0 pt-0 pb-2.5 text-left',
!isActive && 'cursor-pointer',
smallItem ? 'system-sm-semibold-uppercase' : 'system-xl-semibold',
className,
)}
onClick={() => !isActive && onClick(option.value)}
>
<div
<span
data-testid="tab-slider-item-text"
className={cn(isActive ? 'text-text-primary' : 'text-text-tertiary')}
className={cn('block', isActive ? 'text-text-primary' : 'text-text-tertiary')}
>
{option.text}
</div>
</span>
{isActive && (
<div
<span
data-testid="tab-active-indicator"
className="absolute inset-x-0 bottom-0 h-0.5 bg-util-colors-blue-brand-blue-brand-600"
></div>
></span>
)}
</div>
</button>
)
}

View File

@ -42,11 +42,11 @@ export const QAPreview: FC<QAPreviewProps> = (props) => {
return (
<div className="flex flex-col gap-y-2">
<div className="flex gap-x-1">
<label className="shrink-0 text-[13px] leading-5 font-medium text-text-tertiary">Q</label>
<span className="shrink-0 text-[13px] leading-5 font-medium text-text-tertiary">Q</span>
<p className="body-md-regular text-text-secondary">{qa.question}</p>
</div>
<div className="flex gap-x-1">
<label className="shrink-0 text-[13px] leading-5 font-medium text-text-tertiary">A</label>
<span className="shrink-0 text-[13px] leading-5 font-medium text-text-tertiary">A</span>
<p className="body-md-regular text-text-secondary">{qa.answer}</p>
</div>
</div>

View File

@ -60,14 +60,18 @@ describe('UploadDropzone', () => {
expect(icon).toBeInTheDocument()
})
it('should render browse label when extensions are allowed', () => {
it('should render browse button when extensions are allowed', () => {
render(<UploadDropzone {...defaultProps} />)
expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'datasetCreation.stepOne.uploader.browse' }),
).toBeInTheDocument()
})
it('should not render browse label when no extensions allowed', () => {
it('should not render browse button when no extensions allowed', () => {
render(<UploadDropzone {...defaultProps} acceptTypes={[]} />)
expect(screen.queryByText('datasetCreation.stepOne.uploader.browse')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'datasetCreation.stepOne.uploader.browse' }),
).not.toBeInTheDocument()
})
it('should render file size and count limits', () => {
@ -195,12 +199,14 @@ describe('UploadDropzone', () => {
})
describe('event handlers', () => {
it('should call onSelectFile when browse label is clicked', () => {
it('should call onSelectFile when browse button is clicked', () => {
const onSelectFile = vi.fn()
render(<UploadDropzone {...defaultProps} onSelectFile={onSelectFile} />)
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
fireEvent.click(browseLabel)
const browseButton = screen.getByRole('button', {
name: 'datasetCreation.stepOne.uploader.browse',
})
fireEvent.click(browseButton)
expect(onSelectFile).toHaveBeenCalledTimes(1)
})
@ -252,10 +258,12 @@ describe('UploadDropzone', () => {
})
describe('styling', () => {
it('should have cursor-pointer on browse label', () => {
it('should have cursor-pointer on browse button', () => {
render(<UploadDropzone {...defaultProps} />)
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
expect(browseLabel).toHaveClass('cursor-pointer')
const browseButton = screen.getByRole('button', {
name: 'datasetCreation.stepOne.uploader.browse',
})
expect(browseButton).toHaveClass('cursor-pointer')
})
})

View File

@ -58,9 +58,13 @@ const UploadDropzone = ({
? t(($) => $['stepOne.uploader.button'], { ns: 'datasetCreation' })
: t(($) => $['stepOne.uploader.buttonSingleFile'], { ns: 'datasetCreation' })}
{acceptTypes.length > 0 && (
<label className="ml-1 cursor-pointer text-text-accent" onClick={onSelectFile}>
<button
type="button"
className="font-inherit ml-1 inline cursor-pointer appearance-none border-0 bg-transparent p-0 text-text-accent"
onClick={onSelectFile}
>
{t(($) => $['stepOne.uploader.browse'], { ns: 'datasetCreation' })}
</label>
</button>
)}
</span>
</div>

View File

@ -10,9 +10,9 @@ describe('DelimiterInput', () => {
vi.clearAllMocks()
})
it('should render separator label', () => {
it('should associate the separator label with its input', () => {
render(<DelimiterInput />)
expect(screen.getByText(`${ns}.stepTwo.separator`))!.toBeInTheDocument()
expect(screen.getByRole('textbox', { name: `${ns}.stepTwo.separator` })).toBeInTheDocument()
})
it('should render text input with placeholder', () => {

View File

@ -15,20 +15,12 @@ import {
NumberFieldInput,
NumberFieldUnit,
} from '@langgenius/dify-ui/number-field'
import { useRef, useState } from 'react'
import { useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Input from '@/app/components/base/input'
import { env } from '@/env'
const TextLabel: FC<PropsWithChildren> = (props) => {
return (
<label className="text-xs leading-none font-semibold text-text-secondary">
{props.children}
</label>
)
}
const FormField: FC<PropsWithChildren<{ label: ReactNode }>> = (props) => {
return (
// Reflow on the container (a @container/chunkfields ancestor), not the
@ -37,7 +29,7 @@ const FormField: FC<PropsWithChildren<{ label: ReactNode }>> = (props) => {
// At/above 552px this restores flex-1 with no cap, so three columns resolve
// to (container - gaps)/3 — pixel-identical to the stock flex-1 layout.
<div className="max-w-[288px] space-y-2 @min-[552px]/chunkfields:max-w-none @min-[552px]/chunkfields:flex-1">
<TextLabel>{props.label}</TextLabel>
<div className="text-xs leading-none font-semibold text-text-secondary">{props.label}</div>
{props.children}
</div>
)
@ -50,6 +42,8 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({
...rest
}) => {
const { t } = useTranslation()
const generatedInputId = useId()
const inputId = rest.id ?? generatedInputId
const isComposing = useRef(false)
const [compositionValue, setCompositionValue] = useState('')
@ -57,9 +51,9 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({
<FormField
label={
<div className="mb-1 flex items-center">
<span className="mr-0.5 system-sm-semibold">
<label htmlFor={inputId} className="mr-0.5 system-sm-semibold">
{t(($) => $['stepTwo.separator'], { ns: 'datasetCreation' })}
</span>
</label>
<Infotip
aria-label={tooltip || t(($) => $['stepTwo.separatorTip'], { ns: 'datasetCreation' })}
popupClassName="max-w-[200px]"
@ -70,6 +64,7 @@ export const DelimiterInput: FC<InputProps & { tooltip?: string }> = ({
}
>
<Input
id={inputId}
type="text"
className="h-9"
placeholder={t(($) => $['stepTwo.separatorPlaceholder'], { ns: 'datasetCreation' })!}

View File

@ -64,16 +64,20 @@ describe('UploadDropzone', () => {
expect(icon).toBeInTheDocument()
})
it('should render browse label when extensions are allowed', () => {
it('should render browse button when extensions are allowed', () => {
render(<UploadDropzone {...defaultProps} />)
expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'datasetCreation.stepOne.uploader.browse' }),
).toBeInTheDocument()
})
it('should not render browse label when no extensions allowed', () => {
it('should not render browse button when no extensions allowed', () => {
render(<UploadDropzone {...defaultProps} allowedExtensions={[]} />)
expect(screen.queryByText('datasetCreation.stepOne.uploader.browse')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'datasetCreation.stepOne.uploader.browse' }),
).not.toBeInTheDocument()
})
it('should render file size and count limits', () => {
@ -209,12 +213,14 @@ describe('UploadDropzone', () => {
})
describe('event handlers', () => {
it('should call onSelectFile when browse label is clicked', () => {
it('should call onSelectFile when browse button is clicked', () => {
const onSelectFile = vi.fn()
render(<UploadDropzone {...defaultProps} onSelectFile={onSelectFile} />)
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
fireEvent.click(browseLabel)
const browseButton = screen.getByRole('button', {
name: 'datasetCreation.stepOne.uploader.browse',
})
fireEvent.click(browseButton)
expect(onSelectFile).toHaveBeenCalledTimes(1)
})
@ -269,11 +275,13 @@ describe('UploadDropzone', () => {
})
describe('styling', () => {
it('should have cursor-pointer on browse label', () => {
it('should have cursor-pointer on browse button', () => {
render(<UploadDropzone {...defaultProps} />)
const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse')
expect(browseLabel).toHaveClass('cursor-pointer')
const browseButton = screen.getByRole('button', {
name: 'datasetCreation.stepOne.uploader.browse',
})
expect(browseButton).toHaveClass('cursor-pointer')
})
})

View File

@ -64,9 +64,13 @@ const UploadDropzone = ({
? t(($) => $['stepOne.uploader.button'], { ns: 'datasetCreation' })
: t(($) => $['stepOne.uploader.buttonSingleFile'], { ns: 'datasetCreation' })}
{allowedExtensions.length > 0 && (
<label className="ml-1 cursor-pointer text-text-accent" onClick={onSelectFile}>
<button
type="button"
className="font-inherit ml-1 inline cursor-pointer appearance-none border-0 bg-transparent p-0 text-text-accent"
onClick={onSelectFile}
>
{t(($) => $['stepOne.uploader.browse'], { ns: 'datasetCreation' })}
</label>
</button>
)}
</span>
</div>

View File

@ -15,12 +15,18 @@ type ApiItem = {
}
type ExternalApiSelectProps = {
'aria-labelledby'?: string
items: ApiItem[]
value?: string
onSelect: (item: ApiItem) => void
}
const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onSelect }) => {
const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({
'aria-labelledby': ariaLabelledBy,
items,
value,
onSelect,
}) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [selectedItem, setSelectedItem] = useState<ApiItem | null>(
@ -57,19 +63,21 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS
return (
<div className="relative w-full">
<div
className={`flex cursor-pointer items-center justify-between gap-0.5 self-stretch rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt ${isOpen && 'bg-state-base-hover-alt'}`}
<button
type="button"
aria-labelledby={ariaLabelledBy}
className={`flex w-full cursor-pointer appearance-none items-center justify-between gap-0.5 self-stretch rounded-lg border-0 bg-components-input-bg-normal px-2 py-1 text-left hover:bg-state-base-hover-alt ${isOpen && 'bg-state-base-hover-alt'}`}
onClick={() => setIsOpen(!isOpen)}
>
{selectedItem ? (
<div className="flex items-center gap-2 self-stretch rounded-lg p-1">
<span className="flex items-center gap-2 self-stretch rounded-lg p-1">
<ApiConnectionMod className="size-4 text-text-secondary" />
<div className="flex grow items-center">
<span className="flex grow items-center">
<span className="overflow-hidden system-sm-regular text-ellipsis text-components-input-text-filled">
{selectedItem.name}
</span>
</div>
</div>
</span>
</span>
) : (
<span className="system-sm-regular text-components-input-text-placeholder">
{t(($) => $['selectExternalKnowledgeAPI.placeholder'], { ns: 'dataset' })}
@ -78,16 +86,17 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS
<RiArrowDownSLine
className={`size-4 text-text-quaternary transition-transform ${isOpen ? 'text-text-secondary' : ''}`}
/>
</div>
</button>
{isOpen && (
<div className="absolute z-10 mt-1 w-full rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg">
{items.map((item) => (
<div
<button
type="button"
key={item.value}
className="flex cursor-pointer items-center p-1"
className="flex w-full cursor-pointer appearance-none items-center border-0 bg-transparent p-1 text-left"
onClick={() => handleSelect(item)}
>
<div className="flex w-full items-center gap-2 self-stretch rounded-lg p-2 hover:bg-state-base-hover">
<span className="flex w-full items-center gap-2 self-stretch rounded-lg p-2 hover:bg-state-base-hover">
<ApiConnectionMod className="size-4 text-text-secondary" />
<span className="grow overflow-hidden system-sm-medium text-ellipsis text-text-secondary">
{item.name}
@ -95,19 +104,20 @@ const ExternalApiSelect: React.FC<ExternalApiSelectProps> = ({ items, value, onS
<span className="overflow-hidden text-right system-xs-regular text-ellipsis text-text-tertiary">
{item.url}
</span>
</div>
</div>
</span>
</button>
))}
<div className="flex flex-col items-start self-stretch p-1">
<div
className="flex cursor-pointer items-center gap-2 self-stretch rounded-lg p-2 hover:bg-state-base-hover"
<button
type="button"
className="flex w-full cursor-pointer appearance-none items-center gap-2 self-stretch rounded-lg border-0 bg-transparent p-2 text-left hover:bg-state-base-hover"
onClick={handleAddNewAPI}
>
<RiAddLine className="size-4 text-text-secondary" />
<span className="grow overflow-hidden system-sm-medium text-ellipsis text-text-secondary">
{t(($) => $.createNewExternalAPI, { ns: 'dataset' })}
</span>
</div>
</button>
</div>
</div>
)}

View File

@ -30,6 +30,7 @@ const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({
consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({ input: {} })
const { data } = useQuery(externalKnowledgeApiQueryOptions)
const externalKnowledgeApiList = data?.data ?? []
const externalKnowledgeApiLabelId = useId()
const externalKnowledgeIdInputId = useId()
const [selectedApiId, setSelectedApiId] = useState(external_knowledge_api_id)
const { setShowExternalKnowledgeAPIModal } = useModalContext()
@ -71,12 +72,13 @@ const ExternalApiSelection: React.FC<ExternalApiSelectionProps> = ({
<form className="flex flex-col gap-4 self-stretch">
<div className="flex flex-col gap-1 self-stretch">
<div className="flex flex-col self-stretch">
<label className="system-sm-semibold text-text-secondary">
<span id={externalKnowledgeApiLabelId} className="system-sm-semibold text-text-secondary">
{t(($) => $.externalAPIPanelTitle, { ns: 'dataset' })}
</label>
</span>
</div>
{apiItems.length > 0 ? (
<ExternalApiSelect
aria-labelledby={externalKnowledgeApiLabelId}
items={apiItems}
value={selectedApiId}
onSelect={(e) => {

View File

@ -64,6 +64,21 @@ describe('ExternalApiSelect', () => {
})
describe('rendering', () => {
it('uses the referenced visible label to name the trigger', () => {
render(
<>
<span id="external-api-label">External API</span>
<ExternalApiSelect
aria-labelledby="external-api-label"
items={items}
onSelect={onSelect}
/>
</>,
)
expect(screen.getByRole('button', { name: 'External API' })).toBeInTheDocument()
})
it('should show placeholder when no value selected', () => {
render(<ExternalApiSelect items={items} onSelect={onSelect} />)
expect(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')).toBeInTheDocument()

View File

@ -53,11 +53,14 @@ vi.mock('../ExternalApiSelect', () => ({
default: ({
items,
onSelect,
'aria-labelledby': ariaLabelledBy,
}: {
items: Array<{ value: string; name: string }>
onSelect: (item: { value: string; name: string }) => void
'aria-labelledby'?: string
}) => (
<div>
<button type="button" aria-labelledby={ariaLabelledBy} />
{items.map((item) => (
<button type="button" key={item.value} onClick={() => onSelect(item)}>
{item.name}
@ -95,6 +98,14 @@ describe('ExternalApiSelection', () => {
)
})
it('associates the external API label with its selector', () => {
render(<ExternalApiSelection {...defaultProps} />)
expect(
screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' }),
).toBeInTheDocument()
})
it('updates the external knowledge ID', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

View File

@ -10,9 +10,10 @@ type Props = Readonly<{
const FormInputBoolean: FC<Props> = ({ value, onChange }) => {
return (
<div className="flex w-full space-x-1">
<div
<button
type="button"
className={cn(
'flex h-8 grow cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
'flex h-8 grow cursor-default appearance-none items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
!value &&
'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
value &&
@ -21,10 +22,11 @@ const FormInputBoolean: FC<Props> = ({ value, onChange }) => {
onClick={() => onChange(true)}
>
True
</div>
<div
</button>
<button
type="button"
className={cn(
'flex h-8 grow cursor-default items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
'flex h-8 grow cursor-default appearance-none items-center justify-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
value &&
'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
!value &&
@ -33,7 +35,7 @@ const FormInputBoolean: FC<Props> = ({ value, onChange }) => {
onClick={() => onChange(false)}
>
False
</div>
</button>
</div>
)
}

View File

@ -16,9 +16,10 @@ type ItemProps = {
}
const Item: FC<ItemProps> = ({ title, onClick, isSelected }) => {
return (
<div
<button
type="button"
className={cn(
'flex h-8 grow cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 system-sm-regular text-text-secondary',
'flex h-8 grow cursor-default appearance-none items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-left system-sm-regular text-text-secondary',
!isSelected &&
'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
isSelected &&
@ -27,7 +28,7 @@ const Item: FC<ItemProps> = ({ title, onClick, isSelected }) => {
onClick={onClick}
>
{title}
</div>
</button>
)
}

View File

@ -3,6 +3,14 @@ import userEvent from '@testing-library/user-event'
import MonthlyDaysSelector from '../monthly-days-selector'
describe('trigger-schedule/monthly-days-selector', () => {
it('names the monthly day selection group', () => {
render(<MonthlyDaysSelector selectedDays={[1]} onChange={vi.fn()} />)
expect(
screen.getByRole('group', { name: 'workflow.nodes.triggerSchedule.days' }),
).toBeInTheDocument()
})
it('toggles monthly days and shows the day-31 warning', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

View File

@ -3,6 +3,14 @@ import userEvent from '@testing-library/user-event'
import WeekdaySelector from '../weekday-selector'
describe('trigger-schedule/weekday-selector', () => {
it('names the weekday selection group', () => {
render(<WeekdaySelector selectedDays={['mon']} onChange={vi.fn()} />)
expect(
screen.getByRole('group', { name: 'workflow.nodes.triggerSchedule.weekdays' }),
).toBeInTheDocument()
})
it('keeps at least one weekday selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

View File

@ -1,3 +1,4 @@
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
@ -29,10 +30,10 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp
]
return (
<div className="space-y-2">
<label className="mb-2 block text-xs font-medium text-text-tertiary">
<Fieldset className="space-y-2">
<FieldsetLegend className="mb-2 py-0 text-xs font-medium text-text-tertiary">
{t(($) => $['nodes.triggerSchedule.days'], { ns: 'workflow' })}
</label>
</FieldsetLegend>
<div className="space-y-1.5">
{rows.map((row, rowIndex) => (
@ -96,7 +97,7 @@ const MonthlyDaysSelector = ({ selectedDays, onChange }: MonthlyDaysSelectorProp
</div>
</div>
)}
</div>
</Fieldset>
)
}

View File

@ -1,3 +1,4 @@
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@ -30,10 +31,10 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
const isDaySelected = (dayKey: string) => selectedDays.includes(dayKey)
return (
<div className="space-y-2">
<label className="mb-2 block text-xs font-medium text-text-tertiary">
<Fieldset className="space-y-2">
<FieldsetLegend className="mb-2 py-0 text-xs font-medium text-text-tertiary">
{t(($) => $['nodes.triggerSchedule.weekdays'], { ns: 'workflow' })}
</label>
</FieldsetLegend>
<div className="flex gap-1.5">
{weekdays.map((day) => (
<button
@ -50,7 +51,7 @@ const WeekdaySelector = ({ selectedDays, onChange }: WeekdaySelectorProps) => {
</button>
))}
</div>
</div>
</Fieldset>
)
}

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "تحميل {{type}} لا يمكن أن يتجاوز {{size}}",
"fileUploader.uploadFromComputerReadError": "فشل قراءة الملف، يرجى المحاولة مرة أخرى.",
"fileUploader.uploadFromComputerUploadError": "فشل تحميل الملف، يرجى التحميل مرة أخرى.",
"imageGallery.previewImage": "معاينة الصورة {{index}} من {{total}}",
"imageInput.browse": "تصفح",
"imageInput.dropImageHere": "أسقط صورتك هنا، أو",
"imageInput.supportedFormats": "يدعم PNG و JPG و JPEG و WEBP و GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Der Upload von {{type}} darf {{size}} nicht überschreiten",
"fileUploader.uploadFromComputerReadError": "Lesen der Datei fehlgeschlagen, bitte versuchen Sie es erneut.",
"fileUploader.uploadFromComputerUploadError": "Datei-Upload fehlgeschlagen, bitte erneut hochladen.",
"imageGallery.previewImage": "Bild {{index}} von {{total}} in der Vorschau anzeigen",
"imageInput.browse": "blättern",
"imageInput.dropImageHere": "Laden Sie Ihr Bild hierher hoch oder",
"imageInput.supportedFormats": "Unterstützt PNG, JPG, JPEG, WEBP und GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Upload {{type}} cannot exceed {{size}}",
"fileUploader.uploadFromComputerReadError": "File reading failed, please try again.",
"fileUploader.uploadFromComputerUploadError": "File upload failed, please upload again.",
"imageGallery.previewImage": "Preview image {{index}} of {{total}}",
"imageInput.browse": "browse",
"imageInput.dropImageHere": "Drop your image here, or",
"imageInput.supportedFormats": "Supports PNG, JPG, JPEG, WEBP and GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "La carga de {{type}} no puede exceder {{size}}",
"fileUploader.uploadFromComputerReadError": "Error en la lectura del archivo, inténtelo de nuevo.",
"fileUploader.uploadFromComputerUploadError": "Error en la carga del archivo, vuelva a cargarlo.",
"imageGallery.previewImage": "Vista previa de la imagen {{index}} de {{total}}",
"imageInput.browse": "navegar",
"imageInput.dropImageHere": "Deja tu imagen aquí, o",
"imageInput.supportedFormats": "Soporta PNG, JPG, JPEG, WEBP y GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "آپلود {{type}} نمی تواند از {{size}} تجاوز کند",
"fileUploader.uploadFromComputerReadError": "خواندن فایل انجام نشد، لطفا دوباره امتحان کنید.",
"fileUploader.uploadFromComputerUploadError": "آپلود فایل انجام نشد، لطفا دوباره آپلود کنید.",
"imageGallery.previewImage": "پیش‌نمایش تصویر {{index}} از {{total}}",
"imageInput.browse": "مرورگر",
"imageInput.dropImageHere": "عکس خود را اینجا رها کنید، یا",
"imageInput.supportedFormats": "از فرمت‌های PNG، JPG، JPEG، WEBP و GIF پشتیبانی می‌کند",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Le téléchargement de {{type}} ne peut pas dépasser {{size}}",
"fileUploader.uploadFromComputerReadError": "Échec de la lecture du fichier, veuillez réessayer.",
"fileUploader.uploadFromComputerUploadError": "Le téléchargement du fichier a échoué, veuillez le télécharger à nouveau.",
"imageGallery.previewImage": "Prévisualiser limage {{index}} sur {{total}}",
"imageInput.browse": "naviguer",
"imageInput.dropImageHere": "Déposez votre image ici, ou",
"imageInput.supportedFormats": "Prend en charge PNG, JPG, JPEG, WEBP et GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "{{type}} अपलोड {{size}} से ज़्यादा नहीं हो सकता",
"fileUploader.uploadFromComputerReadError": "फ़ाइल पढ़ना विफल रहा, कृपया पुनः प्रयास करें.",
"fileUploader.uploadFromComputerUploadError": "फ़ाइल अपलोड विफल रही, कृपया फिर से अपलोड करें।",
"imageGallery.previewImage": "{{total}} में से चित्र {{index}} का पूर्वावलोकन करें",
"imageInput.browse": "ब्राउज़ करें",
"imageInput.dropImageHere": "अपनी छवि यहाँ छोड़ें, या",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP और GIF का समर्थन करता है",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Unggahan {{type}} tidak boleh melebihi {{size}}",
"fileUploader.uploadFromComputerReadError": "Pembacaan file gagal, silakan coba lagi.",
"fileUploader.uploadFromComputerUploadError": "Unggahan file gagal, silakan unggah lagi.",
"imageGallery.previewImage": "Pratinjau gambar {{index}} dari {{total}}",
"imageInput.browse": "Telusuri",
"imageInput.dropImageHere": "Letakkan gambar Anda di sini, atau",
"imageInput.supportedFormats": "Mendukung PNG, JPG, JPEG, WEBP dan GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Il caricamento di {{type}} non può superare {{size}}",
"fileUploader.uploadFromComputerReadError": "Lettura del file non riuscita, riprovare.",
"fileUploader.uploadFromComputerUploadError": "Caricamento del file non riuscito, carica di nuovo.",
"imageGallery.previewImage": "Visualizza l'anteprima dell'immagine {{index}} di {{total}}",
"imageInput.browse": "sfogliare",
"imageInput.dropImageHere": "Trascina la tua immagine qui, oppure",
"imageInput.supportedFormats": "Supporta PNG, JPG, JPEG, WEBP e GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "{{type}}のアップロードは{{size}}を超えてはなりません",
"fileUploader.uploadFromComputerReadError": "ファイルの読み取りに失敗しました。もう一度やり直してください。",
"fileUploader.uploadFromComputerUploadError": "ファイルのアップロードに失敗しました。再度アップロードしてください。",
"imageGallery.previewImage": "{{total}}枚中{{index}}枚目の画像をプレビュー",
"imageInput.browse": "ブラウズする",
"imageInput.dropImageHere": "ここに画像をドロップするか、",
"imageInput.supportedFormats": "PNG、JPG、JPEG、WEBP、および GIF をサポートしています。",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "{{type}} 업로드는 {{size}}를 초과할 수 없습니다.",
"fileUploader.uploadFromComputerReadError": "파일 읽기에 실패했습니다. 다시 시도하십시오.",
"fileUploader.uploadFromComputerUploadError": "파일 업로드에 실패했습니다. 다시 업로드하십시오.",
"imageGallery.previewImage": "이미지 {{total}}개 중 {{index}}번째 미리보기",
"imageInput.browse": "찾아보기",
"imageInput.dropImageHere": "여기에 이미지를 드롭하거나",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP 및 GIF 를 지원합니다.",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "ອັບໂຫຼດ {{type}} ຕ້ອງບໍ່ເກີນ {{size}}",
"fileUploader.uploadFromComputerReadError": "ອ່ານໄຟລ໌ບໍ່ສຳເລັດ, ກະລຸນາລອງໃໝ່.",
"fileUploader.uploadFromComputerUploadError": "ອັບໂຫຼດໄຟລ໌ບໍ່ສຳເລັດ, ກະລຸນາອັບໂຫຼດໃໝ່.",
"imageGallery.previewImage": "ສະແດງຕົວຢ່າງຮູບທີ {{index}} ຈາກ {{total}}",
"imageInput.browse": "ເລືອກເບິ່ງ",
"imageInput.dropImageHere": "ວາງຮູບຂອງທ່ານທີ່ນີ້, ຫຼື",
"imageInput.supportedFormats": "ຮອງຮັບ PNG, JPG, JPEG, WEBP ແລະ GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Upload {{type}} cannot exceed {{size}}",
"fileUploader.uploadFromComputerReadError": "File reading failed, please try again.",
"fileUploader.uploadFromComputerUploadError": "File upload failed, please upload again.",
"imageGallery.previewImage": "Afbeelding {{index}} van {{total}} bekijken",
"imageInput.browse": "browse",
"imageInput.dropImageHere": "Drop your image here, or",
"imageInput.supportedFormats": "Supports PNG, JPG, JPEG, WEBP and GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Przesyłanie {{type}} nie może przekraczać {{size}}",
"fileUploader.uploadFromComputerReadError": "Odczyt pliku nie powiódł się, spróbuj ponownie.",
"fileUploader.uploadFromComputerUploadError": "Przesyłanie pliku nie powiodło się, prześlij ponownie.",
"imageGallery.previewImage": "Wyświetl podgląd obrazu {{index}} z {{total}}",
"imageInput.browse": "przeglądaj",
"imageInput.dropImageHere": "Upuść swój obraz tutaj, lub",
"imageInput.supportedFormats": "Obsługuje PNG, JPG, JPEG, WEBP i GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "O upload de {{type}} não pode exceder {{size}}",
"fileUploader.uploadFromComputerReadError": "Falha na leitura do arquivo, tente novamente.",
"fileUploader.uploadFromComputerUploadError": "Falha no upload do arquivo, faça o upload novamente.",
"imageGallery.previewImage": "Visualizar imagem {{index}} de {{total}}",
"imageInput.browse": "navegar",
"imageInput.dropImageHere": "Arraste sua imagem aqui, ou",
"imageInput.supportedFormats": "Suporta PNG, JPG, JPEG, WEBP e GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Încărcarea {{type}} nu poate depăși {{size}}",
"fileUploader.uploadFromComputerReadError": "Citirea fișierului a eșuat, vă rugăm să încercați din nou.",
"fileUploader.uploadFromComputerUploadError": "Încărcarea fișierului a eșuat, vă rugăm să încărcați din nou.",
"imageGallery.previewImage": "Previzualizează imaginea {{index}} din {{total}}",
"imageInput.browse": "naviga",
"imageInput.dropImageHere": "Trageți imaginea aici sau",
"imageInput.supportedFormats": "Suportă PNG, JPG, JPEG, WEBP și GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Загрузка {{type}} не может превышать {{size}}",
"fileUploader.uploadFromComputerReadError": "Чтение файла не удалось, пожалуйста, повторите попытку.",
"fileUploader.uploadFromComputerUploadError": "Загрузка файла не удалась, пожалуйста, загрузите еще раз.",
"imageGallery.previewImage": "Просмотреть изображение {{index}} из {{total}}",
"imageInput.browse": "просмотр",
"imageInput.dropImageHere": "Перетащите ваше изображение сюда или",
"imageInput.supportedFormats": "Поддерживает PNG, JPG, JPEG, WEBP и GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Nalaganje {{type}} ne sme presegati {{size}}",
"fileUploader.uploadFromComputerReadError": "Branje datoteke ni uspelo, poskusite znova.",
"fileUploader.uploadFromComputerUploadError": "Nalaganje datoteke ni uspelo, naložite ga znova.",
"imageGallery.previewImage": "Prikaži predogled slike {{index}} od {{total}}",
"imageInput.browse": "brskati",
"imageInput.dropImageHere": "Tukaj spustite svojo sliko ali",
"imageInput.supportedFormats": "Podpira PNG, JPG, JPEG, WEBP in GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "อัปโหลด {{type}} ต้องไม่เกิน {{size}}",
"fileUploader.uploadFromComputerReadError": "การอ่านไฟล์ล้มเหลว โปรดลองอีกครั้ง",
"fileUploader.uploadFromComputerUploadError": "อัปโหลดไฟล์ล้มเหลว โปรดอัปโหลดอีกครั้ง",
"imageGallery.previewImage": "ดูตัวอย่างรูปภาพที่ {{index}} จาก {{total}}",
"imageInput.browse": "ท่องเว็บ",
"imageInput.dropImageHere": "วางภาพของคุณที่นี่ หรือ",
"imageInput.supportedFormats": "รองรับ PNG, JPG, JPEG, WEBP และ GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "{{type}} yüklemesi {{size}}'ı aşamaz",
"fileUploader.uploadFromComputerReadError": "Dosya okuma başarısız oldu, lütfen tekrar deneyin.",
"fileUploader.uploadFromComputerUploadError": "Dosya yükleme başarısız oldu, lütfen tekrar yükleyin.",
"imageGallery.previewImage": "{{total}} görselden {{index}}. görseli önizle",
"imageInput.browse": "göz atın",
"imageInput.dropImageHere": "Görüntünüzü buraya bırakın veya",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP ve GIF'i destekler",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Завантаження {{type}} не може перевищувати {{size}}",
"fileUploader.uploadFromComputerReadError": "Не вдалося прочитати файл, будь ласка, спробуйте ще раз.",
"fileUploader.uploadFromComputerUploadError": "Не вдалося завантажити файл, будь ласка, завантажте ще раз.",
"imageGallery.previewImage": "Переглянути зображення {{index}} із {{total}}",
"imageInput.browse": "перегляд",
"imageInput.dropImageHere": "Перетягніть зображення сюди або",
"imageInput.supportedFormats": "Підтримує PNG, JPG, JPEG, WEBP і GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "Tải lên {{type}} không được vượt quá {{size}}",
"fileUploader.uploadFromComputerReadError": "Đọc tệp không thành công, vui lòng thử lại.",
"fileUploader.uploadFromComputerUploadError": "Tải lên tệp không thành công, vui lòng tải lên lại.",
"imageGallery.previewImage": "Xem trước ảnh {{index}} trên {{total}}",
"imageInput.browse": "duyệt",
"imageInput.dropImageHere": "Kéo hình ảnh của bạn vào đây, hoặc",
"imageInput.supportedFormats": "Hỗ trợ PNG, JPG, JPEG, WEBP và GIF",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "上传 {{type}} 不能超过 {{size}}",
"fileUploader.uploadFromComputerReadError": "文件读取失败,请重新选择。",
"fileUploader.uploadFromComputerUploadError": "文件上传失败,请重新上传。",
"imageGallery.previewImage": "预览第 {{index}} 张图片,共 {{total}} 张",
"imageInput.browse": "浏览",
"imageInput.dropImageHere": "将图片拖放到此处,或",
"imageInput.supportedFormats": "支持 PNG、JPG、JPEG、WEBP 和 GIF 格式",

View File

@ -177,6 +177,7 @@
"fileUploader.uploadFromComputerLimit": "上傳 {{type}} 不能超過 {{size}}",
"fileUploader.uploadFromComputerReadError": "檔案讀取失敗,請重試。",
"fileUploader.uploadFromComputerUploadError": "檔案上傳失敗,請重新上傳。",
"imageGallery.previewImage": "預覽第 {{index}} 張圖片,共 {{total}} 張",
"imageInput.browse": "瀏覽",
"imageInput.dropImageHere": "將您的圖片放在這裡,或",
"imageInput.supportedFormats": "支援 PNG、JPG、JPEG、WEBP 和 GIF",