Compare commits

..

16 Commits

59 changed files with 1631 additions and 2096 deletions

View File

@ -1,6 +1,6 @@
from flask_restx import Resource, marshal
from pydantic import BaseModel
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
import services
@ -54,12 +54,13 @@ class CreateRagPipelineDatasetApi(Resource):
yaml_content=payload.yaml_content,
)
try:
with sessionmaker(db.engine).begin() as session:
with Session(db.engine, expire_on_commit=False) as session:
rag_pipeline_dsl_service = RagPipelineDslService(session)
import_info = rag_pipeline_dsl_service.create_rag_pipeline_dataset(
tenant_id=current_tenant_id,
rag_pipeline_dataset_create_entity=rag_pipeline_dataset_create_entity,
)
session.commit()
if rag_pipeline_dataset_create_entity.permission == "partial_members":
DatasetPermissionService.update_partial_member_list(
current_tenant_id,

View File

@ -1,7 +1,7 @@
from flask import request
from flask_restx import Resource, fields, marshal_with # type: ignore
from pydantic import BaseModel, Field
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session
from controllers.common.schema import get_or_create_model, register_schema_models
from controllers.console import console_ns
@ -67,10 +67,12 @@ class RagPipelineImportApi(Resource):
current_user, _ = current_account_with_tenant()
payload = RagPipelineImportPayload.model_validate(console_ns.payload or {})
# Create service with session
with sessionmaker(db.engine).begin() as session:
# Use a plain Session so that caught exceptions inside the service
# (which return FAILED status instead of re-raising) do not leave the
# transaction in a closed state that a .begin() context manager cannot
# handle. See app_import.py for the canonical pattern.
with Session(db.engine, expire_on_commit=False) as session:
import_service = RagPipelineDslService(session)
# Import app
account = current_user
result = import_service.import_rag_pipeline(
account=account,
@ -80,6 +82,10 @@ class RagPipelineImportApi(Resource):
pipeline_id=payload.pipeline_id,
dataset_name=payload.name,
)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
# Return appropriate status code based on result
status = result.status
@ -102,12 +108,14 @@ class RagPipelineImportConfirmApi(Resource):
def post(self, import_id):
current_user, _ = current_account_with_tenant()
# Create service with session
with sessionmaker(db.engine).begin() as session:
with Session(db.engine, expire_on_commit=False) as session:
import_service = RagPipelineDslService(session)
# Confirm import
account = current_user
result = import_service.confirm_import(import_id=import_id, account=account)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
# Return appropriate status code based on result
if result.status == ImportStatus.FAILED:
@ -124,7 +132,7 @@ class RagPipelineImportCheckDependenciesApi(Resource):
@edit_permission_required
@marshal_with(pipeline_import_check_dependencies_model)
def get(self, pipeline: Pipeline):
with sessionmaker(db.engine).begin() as session:
with Session(db.engine, expire_on_commit=False) as session:
import_service = RagPipelineDslService(session)
result = import_service.check_dependencies(pipeline=pipeline)
@ -142,7 +150,7 @@ class RagPipelineExportApi(Resource):
# Add include_secret params
query = IncludeSecretQuery.model_validate(request.args.to_dict())
with sessionmaker(db.engine).begin() as session:
with Session(db.engine, expire_on_commit=False) as session:
export_service = RagPipelineDslService(session)
result = export_service.export_rag_pipeline_dsl(
pipeline=pipeline, include_secret=query.include_secret == "true"

View File

@ -1,6 +1,6 @@
from typing import Union
from typing import Any, Union
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from core.rag.entities import RerankingModelConfig, WeightedScoreConfig
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
@ -101,3 +101,14 @@ class KnowledgeIndexNodeData(BaseNodeData):
index_chunk_variable_selector: list[str]
indexing_technique: str | None = None
summary_index_setting: SummaryIndexSettingDict | None = None
@field_validator("summary_index_setting", mode="before")
@classmethod
def normalize_summary_index_setting(cls, v: Any) -> Any:
"""Treat dicts with enable=None (or missing enable) as None (#36233)."""
if v is None:
return None
if isinstance(v, dict):
if v.get("enable") is None:
return None
return v

View File

@ -78,9 +78,9 @@ class CheckDependenciesPendingData(BaseModel):
class RagPipelineDslService:
"""Import, export, and inspect RAG pipeline DSL using the caller-owned session.
Controllers wrap this service in a SQLAlchemy transaction context, so methods must only flush interim changes when
generated IDs are needed. Committing inside the service would close the caller's transaction and break later work in
the same context manager.
Callers pass a plain ``Session`` (not wrapped in ``.begin()``) and are responsible for calling
``session.commit()`` on success or ``session.rollback()`` on failure. Methods here only flush
when generated IDs are needed mid-operation; they never commit or rollback.
"""
def __init__(self, session: Session):

View File

@ -1571,19 +1571,6 @@
"count": 1
}
},
"web/app/components/base/pagination/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"unicorn/prefer-number-properties": {
"count": 1
}
},
"web/app/components/base/pagination/type.ts": {
"ts/no-empty-object-type": {
"count": 1
}
},
"web/app/components/base/prompt-editor/index.stories.tsx": {
"no-console": {
"count": 1

View File

@ -40,16 +40,16 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
## Primitives
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
| Category | Subpath | Notes |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
| Navigation | `./pagination`, `./tabs`, `./toggle-group` | Pagination for page navigation; Tabs for panels; ToggleGroup for segmented modes. |
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
Utilities:

View File

@ -77,6 +77,10 @@
"types": "./src/number-field/index.tsx",
"import": "./src/number-field/index.tsx"
},
"./pagination": {
"types": "./src/pagination/index.tsx",
"import": "./src/pagination/index.tsx"
},
"./radio": {
"types": "./src/radio/index.tsx",
"import": "./src/radio/index.tsx"

View File

@ -0,0 +1,293 @@
import { render } from 'vitest-browser-react'
import {
Pagination,
PaginationContent,
PaginationNavigation,
PaginationNext,
PaginationPage,
PaginationPageJump,
PaginationPageList,
PaginationPageSize,
PaginationPrevious,
PaginationRoot,
PaginationSkeleton,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
async function renderPagination({
page = 2,
totalPages = 200,
onPageChange = vi.fn(),
pageSize = 25,
onPageSizeChange = vi.fn(),
}: {
page?: number
totalPages?: number
onPageChange?: (page: number) => void
pageSize?: number
onPageSizeChange?: (pageSize: number) => void
} = {}) {
const screen = await render(
<PaginationRoot
page={page}
totalPages={totalPages}
onPageChange={onPageChange}
data-testid="pagination"
>
<PaginationContent data-testid="content">
<PaginationNavigation data-testid="controls">
<PaginationPrevious />
<PaginationPageJump />
<PaginationNext />
</PaginationNavigation>
<PaginationPageList data-testid="pages" />
<PaginationPageSize
value={pageSize}
options={[10, 25, 50]}
onValueChange={onPageSizeChange}
/>
</PaginationContent>
</PaginationRoot>,
)
return {
screen,
onPageChange,
onPageSizeChange,
}
}
describe('Pagination primitive', () => {
it('renders the Figma-aligned pagination structure with semantic navigation', async () => {
const { screen } = await renderPagination()
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '2')
await expect.element(screen.getByTestId('content')).toHaveClass('grid', 'grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
await expect.element(screen.getByTestId('controls')).toHaveClass('justify-self-start', 'rounded-[10px]', 'bg-background-section-burn')
await expect.element(screen.getByRole('list')).toHaveClass('col-start-2', 'justify-self-center')
expect(screen.getByRole('group', { name: 'Items per page' }).element().parentElement).toHaveClass('col-start-3', 'justify-self-end')
await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveTextContent('2/200')
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveClass('h-7', 'px-2')
expect(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).not.toHaveClass('min-w-14')
await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveAttribute('aria-current', 'page')
await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveClass('bg-components-button-tertiary-bg')
await expect.element(screen.getByText('…')).toBeInTheDocument()
})
it('uses one-based page changes for previous, next, and page buttons', async () => {
const { screen, onPageChange } = await renderPagination({ page: 4 })
asHTMLElement(screen.getByRole('button', { name: 'Previous page' }).element()).click()
asHTMLElement(screen.getByRole('button', { name: 'Next page' }).element()).click()
asHTMLElement(screen.getByRole('button', { name: 'Go to page 6' }).element()).click()
expect(onPageChange).toHaveBeenNthCalledWith(1, 3)
expect(onPageChange).toHaveBeenNthCalledWith(2, 5)
expect(onPageChange).toHaveBeenNthCalledWith(3, 6)
})
it('disables previous at the first page', async () => {
const { screen } = await renderPagination({ page: 1, totalPages: 10 })
await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
})
it('disables next at the last page', async () => {
const { screen } = await renderPagination({ page: 10, totalPages: 10 })
await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
})
it('clamps invalid root page values without exposing invalid state', async () => {
const { screen } = await renderPagination({ page: 999, totalPages: 10 })
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '10')
await expect.element(screen.getByRole('button', { name: 'Page 10, current page' })).toHaveAttribute('aria-current', 'page')
})
it('switches the page summary into a selected labelled number field', async () => {
const { screen } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveValue('2')
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveClass('text-center', 'tabular-nums')
expect(input.parentElement?.parentElement?.parentElement).toHaveAttribute('data-page-summary', '2/200')
await vi.waitFor(() => {
expect(input.selectionStart).toBe(0)
expect(input.selectionEnd).toBe(1)
})
})
it('returns to the summary button when the page input loses focus', async () => {
const { screen } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()).blur()
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument()
})
it('commits the page input editing mode with Enter', async () => {
const { screen } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
await vi.waitFor(() => {
expect(document.activeElement).toBe(input)
})
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}))
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument()
})
it('cancels the page input editing mode with Escape', async () => {
const { screen, onPageChange } = await renderPagination()
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
await vi.waitFor(() => {
expect(document.activeElement).toBe(input)
})
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}))
const summaryButton = screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })
await expect.element(summaryButton).toBeInTheDocument()
await vi.waitFor(() => {
expect(document.activeElement).toBe(summaryButton.element())
})
expect(onPageChange).not.toHaveBeenCalled()
})
it('uses Base UI ToggleGroup semantics for page size', async () => {
const { screen, onPageSizeChange } = await renderPagination()
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toHaveClass('bg-components-segmented-control-bg-normal')
await expect.element(screen.getByText('Items per page')).toHaveClass('opacity-0', 'group-hover/page-size:opacity-100', 'group-focus-within/page-size:opacity-100')
await expect.element(screen.getByRole('button', { name: '25' })).toHaveAttribute('aria-pressed', 'true')
await expect.element(screen.getByRole('button', { name: '25' })).toHaveClass('data-pressed:text-text-primary')
asHTMLElement(screen.getByRole('button', { name: '50' }).element()).click()
expect(onPageSizeChange).toHaveBeenCalledWith(50)
})
it('renders the complete pagination bar with optional page size controls', async () => {
const onPageSizeChange = vi.fn()
const screen = await render(
<Pagination
page={2}
totalPages={10}
onPageChange={vi.fn()}
pageSize={{
value: 25,
options: [10, 25, 50],
onValueChange: onPageSizeChange,
}}
/>,
)
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 10' })).toBeInTheDocument()
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toBeInTheDocument()
})
it('uses a localized action label for editing the page number', async () => {
const screen = await render(
<Pagination
page={2}
totalPages={10}
onPageChange={vi.fn()}
labels={{
editPageNumber: (page, totalPages) => `Change page, current page ${page} of ${totalPages}`,
}}
/>,
)
await expect.element(screen.getByRole('button', { name: 'Change page, current page 2 of 10' })).toBeInTheDocument()
})
it('keeps facade page numbers centered when page size controls are omitted', async () => {
const screen = await render(
<Pagination
page={2}
totalPages={10}
onPageChange={vi.fn()}
/>,
)
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument()
expect(screen.container.querySelector('nav[aria-label="Pagination"] > div')).toHaveClass('grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
await expect.element(screen.getByRole('list')).toHaveClass('col-start-2', 'justify-self-center')
})
it('does not expose invalid page controls when there are no pages', async () => {
const screen = await render(
<Pagination
page={1}
totalPages={0}
onPageChange={vi.fn()}
/>,
)
expect(screen.container.querySelector('nav[aria-label="Pagination"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument()
})
it('omits compound page jump and page list content for empty pagination state', async () => {
const { screen } = await renderPagination({ page: 1, totalPages: 0 })
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '1')
expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('button[aria-label="Previous page"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('button[aria-label="Next page"]')).not.toBeInTheDocument()
expect(screen.container.querySelector('ol')).not.toBeInTheDocument()
})
it('allows custom page rendering while keeping the shared context', async () => {
const onPageChange = vi.fn()
const screen = await render(
<PaginationRoot page={3} totalPages={5} onPageChange={onPageChange}>
<ol>
<li>
<PaginationPage page={4} className="custom-page">
Four
</PaginationPage>
</li>
</ol>
</PaginationRoot>,
)
asHTMLElement(screen.getByRole('button', { name: 'Go to page 4' }).element()).click()
await expect.element(screen.getByRole('button', { name: 'Go to page 4' })).toHaveClass('custom-page')
expect(onPageChange).toHaveBeenCalledWith(4)
})
it('renders a non-interactive loading skeleton', async () => {
const screen = await render(<PaginationSkeleton data-testid="skeleton" />)
await expect.element(screen.getByTestId('skeleton')).toHaveAttribute('aria-hidden', 'true')
await expect.element(screen.getByTestId('skeleton')).toHaveClass('select-none')
})
})

View File

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState } from 'react'
import {
Pagination,
PaginationSkeleton,
} from '.'
function PaginationExample({
initialPage = 2,
initialPageSize = 25,
totalPages = 200,
}: {
initialPage?: number
initialPageSize?: number
totalPages?: number
}) {
const [page, setPage] = useState(initialPage)
const [pageSize, setPageSize] = useState(initialPageSize)
return (
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
pageSize={{
value: pageSize,
options: [10, 25, 50],
onValueChange: setPageSize,
}}
/>
)
}
function PaginationDemo(props: ComponentProps<typeof PaginationExample>) {
return (
<div className="w-236 max-w-full bg-components-panel-bg px-16 py-10">
<PaginationExample {...props} />
</div>
)
}
function DesignSpecDemo() {
return (
<div className="flex w-236 max-w-full flex-col gap-6 bg-components-panel-bg px-16 py-10">
<PaginationExample />
<PaginationExample initialPage={2} initialPageSize={25} />
<PaginationExample initialPage={2} initialPageSize={25} />
<PaginationExample initialPage={2} initialPageSize={25} />
</div>
)
}
const meta = {
title: 'Base/UI/Pagination',
component: PaginationDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compound pagination primitive for list navigation. It combines semantic page buttons, a NumberField-backed page jump summary, and a ToggleGroup-backed page-size selector.',
},
},
},
args: {
initialPage: 2,
initialPageSize: 25,
totalPages: 200,
},
tags: ['autodocs'],
} satisfies Meta<typeof PaginationDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {
render: () => <PaginationDemo />,
}
export const DesignSpec: Story = {
render: () => <DesignSpecDemo />,
parameters: {
docs: {
description: {
story: 'Pagination rows with default, hover-like, focused, page-size, and skeleton examples.',
},
},
},
}
export const Loading: Story = {
render: () => <PaginationSkeleton />,
}

View File

@ -0,0 +1,651 @@
'use client'
import type { Button as BaseButtonNS } from '@base-ui/react/button'
import type { ReactNode } from 'react'
import { Button as BaseButton } from '@base-ui/react/button'
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { createContext, useContext, useMemo, useRef, useState } from 'react'
import { cn } from '../cn'
import {
NumberField,
NumberFieldGroup,
NumberFieldInput,
} from '../number-field'
import {
ToggleGroup,
ToggleGroupItem,
} from '../toggle-group'
type PageItem = number | 'ellipsis-start' | 'ellipsis-end'
type PaginationContextValue = {
page: number
totalPages: number
hasPages: boolean
disabled: boolean
onPageChange: (page: number) => void
items: PageItem[]
}
const PaginationContext = createContext<PaginationContextValue | null>(null)
function usePaginationContext(component: string) {
const context = useContext(PaginationContext)
if (!context)
throw new Error(`${component} must be used inside PaginationRoot.`)
return context
}
function clampPage(page: number, totalPages: number) {
if (!Number.isFinite(page))
return 1
return Math.min(Math.max(Math.trunc(page), 1), Math.max(totalPages, 1))
}
function range(start: number, end: number) {
if (end < start)
return []
return Array.from({ length: end - start + 1 }, (_, index) => start + index)
}
type GetPageItemsOptions = {
page: number
totalPages: number
siblingCount: number
boundaryCount: number
visiblePageCount: number
}
function getPageItems({
page,
totalPages,
siblingCount,
boundaryCount,
visiblePageCount,
}: GetPageItemsOptions): PageItem[] {
if (totalPages <= 0)
return []
const normalizedPage = clampPage(page, totalPages)
const normalizedBoundaryCount = Math.max(Math.trunc(boundaryCount), 1)
const normalizedSiblingCount = Math.max(Math.trunc(siblingCount), 0)
const windowSize = Math.max(
Math.trunc(visiblePageCount),
normalizedSiblingCount * 2 + 1,
)
if (totalPages <= windowSize + normalizedBoundaryCount)
return range(1, totalPages)
const nearStartEnd = windowSize
const nearEndStart = totalPages - windowSize + 1
const middleStart = Math.max(
normalizedBoundaryCount + 1,
normalizedPage - normalizedSiblingCount,
)
const middleEnd = Math.min(
totalPages - normalizedBoundaryCount,
normalizedPage + normalizedSiblingCount,
)
const windowPages = normalizedPage <= nearStartEnd - normalizedSiblingCount
? range(1, nearStartEnd)
: normalizedPage >= nearEndStart + normalizedSiblingCount
? range(nearEndStart, totalPages)
: range(middleStart, middleEnd)
const pageSet = new Set([
...range(1, normalizedBoundaryCount),
...windowPages,
...range(totalPages - normalizedBoundaryCount + 1, totalPages),
])
const pages = Array.from(pageSet)
.filter(item => item >= 1 && item <= totalPages)
.sort((a, b) => a - b)
return pages.reduce<PageItem[]>((items, item, index) => {
const previous = pages[index - 1]
if (previous && item - previous === 2)
items.push(previous + 1)
else if (previous && item - previous > 2)
items.push(item < normalizedPage ? 'ellipsis-start' : 'ellipsis-end')
items.push(item)
return items
}, [])
}
type PaginationRootState = {
page: number
totalPages: number
hasPages: boolean
disabled: boolean
}
export type PaginationRootProps = Omit<
useRender.ComponentProps<'nav', PaginationRootState>,
'onChange'
> & {
page: number
totalPages: number
onPageChange: (page: number) => void
siblingCount?: number
boundaryCount?: number
visiblePageCount?: number
}
export function PaginationRoot({
page,
totalPages,
onPageChange,
siblingCount = 1,
boundaryCount = 1,
visiblePageCount = 8,
render,
children,
...props
}: PaginationRootProps) {
const normalizedTotalPages = Math.max(Math.trunc(totalPages), 0)
const normalizedPage = clampPage(page, normalizedTotalPages)
const hasPages = normalizedTotalPages > 0
const disabled = normalizedTotalPages <= 1
const items = useMemo(() => getPageItems({
page: normalizedPage,
totalPages: normalizedTotalPages,
siblingCount,
boundaryCount,
visiblePageCount,
}), [
boundaryCount,
normalizedPage,
normalizedTotalPages,
siblingCount,
visiblePageCount,
])
const context = useMemo<PaginationContextValue>(() => ({
page: normalizedPage,
totalPages: normalizedTotalPages,
hasPages,
disabled,
onPageChange: nextPage => onPageChange(clampPage(nextPage, normalizedTotalPages)),
items,
}), [disabled, hasPages, items, normalizedPage, normalizedTotalPages, onPageChange])
const defaultProps: useRender.ElementProps<'nav'> = {
'aria-label': 'Pagination',
'className': 'flex w-full min-w-0 items-center justify-between px-6 py-3 select-none',
'children': (
<PaginationContext.Provider value={context}>
{children}
</PaginationContext.Provider>
),
}
return useRender({
defaultTagName: 'nav',
render,
state: {
page: normalizedPage,
totalPages: normalizedTotalPages,
hasPages,
disabled,
},
props: mergeProps<'nav'>(defaultProps, props),
})
}
export type PaginationNavigationProps = useRender.ComponentProps<'div'>
export type PaginationContentProps = useRender.ComponentProps<'div'>
export function PaginationContent({
render,
...props
}: PaginationContentProps) {
const defaultProps: useRender.ElementProps<'div'> = {
className: 'grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2',
}
return useRender({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, props),
})
}
export function PaginationNavigation({
render,
className,
...props
}: PaginationNavigationProps) {
const defaultProps: useRender.ElementProps<'div'> = {
className: cn('flex shrink-0 items-center justify-self-start gap-0.5 rounded-[10px] bg-background-section-burn p-0.5', className),
}
return useRender({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, props),
})
}
type PaginationButtonProps = Omit<BaseButtonNS.Props, 'children'> & {
children?: ReactNode
}
const paginationArrowButtonClassName = [
'inline-flex size-7 shrink-0 touch-manipulation items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[10px] transition-[background-color,border-color,color,box-shadow]',
'hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
'focus-visible:ring-2 focus-visible:ring-components-input-border-hover',
'disabled:cursor-not-allowed disabled:border-components-button-secondary-border-disabled disabled:bg-components-button-secondary-bg-disabled disabled:text-components-button-secondary-text-disabled disabled:shadow-none',
'motion-reduce:transition-none',
]
export function PaginationPrevious({
className,
children,
'aria-label': ariaLabel,
...props
}: PaginationButtonProps) {
const pagination = usePaginationContext('PaginationPrevious')
if (!pagination.hasPages)
return null
const disabled = props.disabled || pagination.page <= 1 || pagination.disabled
return (
<BaseButton
{...props}
type="button"
aria-label={ariaLabel ?? 'Previous page'}
className={cn(paginationArrowButtonClassName, className)}
disabled={disabled}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented && !disabled)
pagination.onPageChange(pagination.page - 1)
}}
>
{children ?? <span className="i-ri-arrow-left-line size-4" aria-hidden="true" />}
</BaseButton>
)
}
export function PaginationNext({
className,
children,
'aria-label': ariaLabel,
...props
}: PaginationButtonProps) {
const pagination = usePaginationContext('PaginationNext')
if (!pagination.hasPages)
return null
const disabled = props.disabled || pagination.page >= pagination.totalPages || pagination.disabled
return (
<BaseButton
{...props}
type="button"
aria-label={ariaLabel ?? 'Next page'}
className={cn(paginationArrowButtonClassName, className)}
disabled={disabled}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented && !disabled)
pagination.onPageChange(pagination.page + 1)
}}
>
{children ?? <span className="i-ri-arrow-right-line size-4" aria-hidden="true" />}
</BaseButton>
)
}
export type PaginationPageJumpProps = Omit<BaseButtonNS.Props, 'children'> & {
inputLabel?: string
children?: ReactNode
}
export function PaginationPageJump({
className,
inputLabel = 'Page number',
children,
'aria-label': ariaLabel,
...props
}: PaginationPageJumpProps) {
const pagination = usePaginationContext('PaginationPageJump')
const [editing, setEditing] = useState(false)
const summaryButtonRef = useRef<HTMLButtonElement | null>(null)
if (!pagination.hasPages)
return null
if (editing) {
return (
<span
data-page-summary={`${pagination.page}/${pagination.totalPages}`}
className="inline-grid h-7 system-xs-medium tabular-nums after:invisible after:col-start-1 after:row-start-1 after:py-1.5 after:pr-3 after:pl-2 after:content-[attr(data-page-summary)]"
>
<NumberField
key={pagination.page}
className="col-start-1 row-start-1 w-full"
defaultValue={pagination.page}
min={1}
max={Math.max(pagination.totalPages, 1)}
onValueCommitted={(value) => {
if (value !== null)
pagination.onPageChange(value)
setEditing(false)
}}
>
<NumberFieldGroup
className="h-7 w-full min-w-0 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-active shadow-xs"
>
<NumberFieldInput
aria-label={inputLabel}
autoFocus
className="px-2 py-1.5 text-center system-xs-medium tabular-nums"
onBlur={() => requestAnimationFrame(() => setEditing(false))}
onFocus={(event) => {
const input = event.currentTarget
requestAnimationFrame(() => input.select())
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
event.currentTarget.blur()
return
}
if (event.key === 'Escape') {
event.preventDefault()
setEditing(false)
requestAnimationFrame(() => summaryButtonRef.current?.focus())
}
}}
/>
</NumberFieldGroup>
</NumberField>
</span>
)
}
return (
<BaseButton
{...props}
ref={summaryButtonRef}
type="button"
aria-label={ariaLabel ?? `Edit page number, current page ${pagination.page} of ${pagination.totalPages}`}
className={cn(
'inline-flex h-7 touch-manipulation items-center justify-center gap-0.5 rounded-lg px-2 py-1.5 system-xs-medium tabular-nums text-text-secondary outline-hidden transition-colors hover:cursor-text hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-components-input-border-hover motion-reduce:transition-none',
className,
)}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented)
setEditing(true)
}}
>
{children ?? (
<>
<span>{pagination.page}</span>
<span className="text-text-quaternary">/</span>
<span>{pagination.totalPages}</span>
</>
)}
</BaseButton>
)
}
export type PaginationPageListProps = useRender.ComponentProps<'ol'>
export function PaginationPageList({
render,
className,
...props
}: PaginationPageListProps) {
const pagination = usePaginationContext('PaginationPageList')
if (!pagination.hasPages)
return null
const defaultProps: useRender.ElementProps<'ol'> = {
className: cn('col-start-2 flex min-w-0 list-none items-center justify-self-center gap-0.5', className),
children: pagination.items.map(item => (
<li key={item}>
{typeof item === 'number'
? <PaginationPage page={item} />
: <PaginationEllipsis />}
</li>
)),
}
return useRender({
defaultTagName: 'ol',
render,
props: mergeProps<'ol'>(defaultProps, props),
})
}
export type PaginationPageProps = Omit<BaseButtonNS.Props, 'children'> & {
page: number
children?: ReactNode
}
export function PaginationPage({
page,
className,
children,
'aria-label': ariaLabel,
...props
}: PaginationPageProps) {
const pagination = usePaginationContext('PaginationPage')
const current = page === pagination.page
return (
<BaseButton
{...props}
type="button"
aria-current={current ? 'page' : undefined}
aria-label={ariaLabel ?? (current ? `Page ${page}, current page` : `Go to page ${page}`)}
className={cn(
'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden transition-colors hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-components-input-border-hover',
current && 'bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover',
'motion-reduce:transition-none',
className,
)}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented)
pagination.onPageChange(page)
}}
>
{children ?? page}
</BaseButton>
)
}
export type PaginationEllipsisProps = useRender.ComponentProps<'span'>
export function PaginationEllipsis({
render,
...props
}: PaginationEllipsisProps) {
const defaultProps: useRender.ElementProps<'span'> = {
'aria-hidden': true,
'className': 'flex size-8 items-center justify-center px-1 py-2 system-sm-medium text-text-tertiary',
'children': '…',
}
return useRender({
defaultTagName: 'span',
render,
props: mergeProps<'span'>(defaultProps, props),
})
}
export type PaginationPageSizeProps<Value extends number = number> = {
'value': Value
'options': readonly Value[]
'onValueChange': (value: Value) => void
'label'?: ReactNode
'aria-label'?: string
'className'?: string
}
export function PaginationPageSize<Value extends number = number>({
value,
options,
onValueChange,
label = 'Items per page',
'aria-label': ariaLabel = 'Items per page',
className,
}: PaginationPageSizeProps<Value>) {
return (
<div className={cn('group/page-size col-start-3 flex shrink-0 items-center justify-end justify-self-end gap-2', className)}>
<div className="w-13 shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary opacity-0 transition-opacity group-hover/page-size:opacity-100 group-focus-within/page-size:opacity-100 motion-reduce:transition-none">
{label}
</div>
<ToggleGroup
value={[String(value)]}
aria-label={ariaLabel}
onValueChange={(nextValue) => {
const [selectedValue] = nextValue
if (!selectedValue)
return
const selectedOption = options.find(option => String(option) === selectedValue)
if (selectedOption !== undefined)
onValueChange(selectedOption)
}}
>
{options.map(option => (
<ToggleGroupItem
key={option}
value={String(option)}
className="min-w-9 data-pressed:text-text-primary"
>
{option}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)
}
export type PaginationLabels = {
previous?: string
next?: string
editPageNumber?: (page: number, totalPages: number) => string
pageNumberInput?: string
}
export type PaginationPageSizeConfig<Value extends number = number> = {
value: Value
options: readonly Value[]
onValueChange: (value: Value) => void
label?: ReactNode
ariaLabel?: string
}
export type PaginationProps<Value extends number = number> = Omit<PaginationRootProps, 'children'> & {
labels?: PaginationLabels
pageSize?: PaginationPageSizeConfig<Value>
}
export function Pagination<Value extends number = number>({
labels,
pageSize,
page,
totalPages,
onPageChange,
...props
}: PaginationProps<Value>) {
const normalizedTotalPages = Math.max(Math.trunc(totalPages), 0)
const normalizedPage = clampPage(page, normalizedTotalPages)
const editPageNumber = labels?.editPageNumber?.(normalizedPage, normalizedTotalPages)
if (normalizedTotalPages <= 0)
return null
return (
<PaginationRoot
page={page}
totalPages={totalPages}
onPageChange={onPageChange}
{...props}
>
<PaginationContent>
<PaginationNavigation>
<PaginationPrevious aria-label={labels?.previous} />
<PaginationPageJump
aria-label={editPageNumber}
inputLabel={labels?.pageNumberInput}
/>
<PaginationNext aria-label={labels?.next} />
</PaginationNavigation>
<PaginationPageList />
{pageSize && (
<PaginationPageSize
value={pageSize.value}
options={pageSize.options}
onValueChange={pageSize.onValueChange}
label={pageSize.label}
aria-label={pageSize.ariaLabel}
/>
)}
</PaginationContent>
</PaginationRoot>
)
}
export type PaginationSkeletonProps = useRender.ComponentProps<'div'>
export function PaginationSkeleton({
render,
...props
}: PaginationSkeletonProps) {
const defaultProps: useRender.ElementProps<'div'> = {
'aria-hidden': true,
'className': 'flex w-full min-w-0 items-center justify-between px-6 py-3 select-none',
'children': (
<div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2">
<div className="flex shrink-0 items-center justify-self-start gap-0.5 rounded-[10px] bg-background-section-burn p-0.5">
<div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
<div className="h-7 min-w-14 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
<div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
</div>
<div className="col-start-2 flex items-center justify-self-center gap-0.5">
{range(1, 8).map(item => (
<div key={item} className="h-8 min-w-8 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
))}
</div>
<div className="col-start-3 flex shrink-0 items-center justify-self-end">
<div className="h-8 w-28 animate-pulse rounded-[10px] bg-state-base-hover motion-reduce:animate-none" />
</div>
</div>
),
}
return useRender({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, props),
})
}

View File

@ -207,16 +207,6 @@ describe('Select wrappers', () => {
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-popup-open:bg-state-base-hover-alt')
})
it('should include keyboard focus ring classes', async () => {
const screen = await renderOpenSelect()
await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveClass(
'focus-visible:ring-1',
'focus-visible:ring-components-input-border-active',
'focus-visible:ring-inset',
)
})
})
describe('SelectContent', () => {

View File

@ -24,7 +24,6 @@ const selectTriggerVariants = cva(
[
'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
'data-placeholder:text-components-input-text-placeholder',
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',

View File

@ -49,19 +49,6 @@ describe('Switch', () => {
await expect.element(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
it('should work in uncontrolled mode with defaultChecked prop', async () => {
const onCheckedChange = vi.fn()
const screen = await render(<Switch defaultChecked={false} onCheckedChange={onCheckedChange} />)
const switchElement = screen.getByRole('switch')
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
asHTMLElement(switchElement.element()).click()
expect(onCheckedChange).toHaveBeenCalledWith(true)
await expect.element(switchElement).toHaveAttribute('aria-checked', 'true')
})
it('should not call onCheckedChange when disabled', async () => {
const onCheckedChange = vi.fn()
const screen = await render(<Switch checked={false} disabled onCheckedChange={onCheckedChange} />)
@ -155,24 +142,6 @@ describe('Switch', () => {
expect(screen.container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
})
it('should use checked data attributes to position spinner', async () => {
const screen = await render(<Switch checked={false} loading size="md" />)
const spinner = screen.container.querySelector('span[aria-hidden="true"]')
expect(spinner).toHaveClass(
'left-[calc(50%+6px)]',
'group-data-checked:left-[calc(50%-6px)]',
)
await screen.rerender(<Switch checked={true} loading size="md" />)
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
expect(screen.container.querySelector('span[aria-hidden="true"]')).toHaveClass(
'left-[calc(50%+6px)]',
'group-data-checked:left-[calc(50%-6px)]',
)
})
it('should not show spinner for xs and sm sizes', async () => {
const screen = await render(<Switch checked={false} loading size="xs" />)
expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()

View File

@ -2,11 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState, useTransition } from 'react'
import { Switch, SwitchSkeleton } from '.'
import {
FieldDescription,
FieldLabel,
FieldRoot,
} from '../field'
const meta = {
title: 'Base/Form/Switch',
@ -15,7 +10,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Toggle switch primitive with controlled and uncontrolled state support, loading state, and skeleton placeholder.',
component: 'Toggle switch built on Base UI with CVA variants, Figma-aligned design tokens, loading spinner, and skeleton placeholder. Import `Switch` and `SwitchSkeleton` from `@langgenius/dify-ui/switch`.',
},
},
},
@ -47,27 +42,20 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
type SwitchDemoProps = Partial<Omit<ComponentProps<typeof Switch>, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & {
checked?: boolean
}
const SwitchDemo = (args: SwitchDemoProps) => {
const SwitchDemo = (args: Partial<ComponentProps<typeof Switch>>) => {
const [enabled, setEnabled] = useState(args.checked ?? false)
return (
<FieldRoot name="autoRetry" className="w-72">
<FieldLabel className="flex items-center justify-between gap-3">
<span>Enable auto retry</span>
<Switch
{...args}
checked={enabled}
onCheckedChange={setEnabled}
/>
</FieldLabel>
<FieldDescription>
{enabled ? 'Failures will retry automatically.' : 'Failures require manual retry.'}
</FieldDescription>
</FieldRoot>
<div className="flex items-center justify-center gap-3">
<Switch
{...args}
checked={enabled}
onCheckedChange={setEnabled}
/>
<span className="text-sm text-gray-700">
{enabled ? 'On' : 'Off'}
</span>
</div>
)
}
@ -128,24 +116,24 @@ const AllStatesDemo = () => {
<td className="py-3 font-medium text-gray-900">{size}</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} onCheckedChange={() => {}} aria-label={`${size} unchecked switch`} />
<Switch size={size} checked={true} onCheckedChange={() => {}} aria-label={`${size} checked switch`} />
<Switch size={size} checked={false} onCheckedChange={() => {}} />
<Switch size={size} checked={true} onCheckedChange={() => {}} />
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} disabled aria-label={`${size} disabled unchecked switch`} />
<Switch size={size} checked={true} disabled aria-label={`${size} disabled checked switch`} />
<Switch size={size} checked={false} disabled />
<Switch size={size} checked={true} disabled />
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch size={size} checked={false} loading aria-label={`${size} loading unchecked switch`} />
<Switch size={size} checked={true} loading aria-label={`${size} loading checked switch`} />
<Switch size={size} checked={false} loading />
<Switch size={size} checked={true} loading />
</div>
</td>
<td className="py-3">
<SwitchSkeleton size={size} aria-hidden="true" />
<SwitchSkeleton size={size} />
</td>
</tr>
))}
@ -160,7 +148,7 @@ export const AllStates: Story = {
parameters: {
docs: {
description: {
story: 'Variant matrix for switch sizes and states.',
story: 'Complete variant matrix: all sizes × all states, matching Figma design spec (node 2144:1210).',
},
},
},
@ -176,30 +164,22 @@ const SizeComparisonDemo = () => {
return (
<div className="flex flex-col items-center space-y-4">
<FieldRoot name="extraSmallSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
Extra Small (xs) - 14x10
</FieldLabel>
</FieldRoot>
<FieldRoot name="smallSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
Small (sm) - 20x12
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
Regular (md) - 28x16
</FieldLabel>
</FieldRoot>
<FieldRoot name="largeSwitch">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
Large (lg) - 36x20
</FieldLabel>
</FieldRoot>
<div className="flex items-center gap-3">
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
<span className="text-sm text-gray-700">Extra Small (xs) 14×10</span>
</div>
<div className="flex items-center gap-3">
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
<span className="text-sm text-gray-700">Small (sm) 20×12</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
<span className="text-sm text-gray-700">Regular (md) 28×16</span>
</div>
<div className="flex items-center gap-3">
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
<span className="text-sm text-gray-700">Large (lg) 36×20</span>
</div>
</div>
)
}
@ -220,42 +200,30 @@ const LoadingDemo = () => {
{loading ? 'Stop Loading' : 'Start Loading'}
</button>
<div className="space-y-3">
<FieldRoot name="largeUncheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={false} loading={loading} />
Large unchecked
</FieldLabel>
</FieldRoot>
<FieldRoot name="largeCheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="lg" checked={true} loading={loading} />
Large checked
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularUncheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={false} loading={loading} />
Regular unchecked
</FieldLabel>
</FieldRoot>
<FieldRoot name="regularCheckedLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="md" checked={true} loading={loading} />
Regular checked
</FieldLabel>
</FieldRoot>
<FieldRoot name="smallLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="sm" checked={false} loading={loading} />
Small
</FieldLabel>
</FieldRoot>
<FieldRoot name="extraSmallLoading">
<FieldLabel className="flex items-center gap-3">
<Switch size="xs" checked={false} loading={loading} />
Extra Small
</FieldLabel>
</FieldRoot>
<div className="flex items-center gap-3">
<Switch size="lg" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Large unchecked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="lg" checked={true} loading={loading} />
<span className="text-sm text-gray-700">Large checked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Regular unchecked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="md" checked={true} loading={loading} />
<span className="text-sm text-gray-700">Regular checked</span>
</div>
<div className="flex items-center gap-3">
<Switch size="sm" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Small (no spinner)</span>
</div>
<div className="flex items-center gap-3">
<Switch size="xs" checked={false} loading={loading} />
<span className="text-sm text-gray-700">Extra Small (no spinner)</span>
</div>
</div>
</div>
)
@ -266,7 +234,7 @@ export const Loading: Story = {
parameters: {
docs: {
description: {
story: 'Loading state disables interaction and shows a spinner for md and lg sizes.',
story: 'Loading state disables interaction and shows a spinning icon (i-ri-loader-2-line) for md/lg sizes. Spinner position mirrors the knob: appears on the opposite side of the checked state.',
},
},
},
@ -274,76 +242,61 @@ export const Loading: Story = {
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
function useMockAutoRetrySettingQuery() {
const MutationLoadingDemo = () => {
const [enabled, setEnabled] = useState(false)
return {
data: {
enabled,
},
setData: setEnabled,
}
}
function useMockUpdateAutoRetrySettingMutation({
onSuccess,
}: {
onSuccess: (enabled: boolean) => void
}) {
const [requestCount, setRequestCount] = useState(0)
const [isPending, startTransition] = useTransition()
const mutate = (nextValue: boolean) => {
const handleChange = (nextValue: boolean) => {
if (isPending)
return
startTransition(async () => {
setRequestCount(current => current + 1)
await wait(1200)
onSuccess(nextValue)
setEnabled(nextValue)
})
}
return {
requestCount,
isPending,
mutate,
}
}
const MutationLoadingDemo = () => {
const autoRetrySetting = useMockAutoRetrySettingQuery()
const updateAutoRetrySetting = useMockUpdateAutoRetrySettingMutation({
onSuccess: autoRetrySetting.setData,
})
const statusText = updateAutoRetrySetting.isPending
? 'Saving changes...'
: autoRetrySetting.data.enabled
? 'Auto retry is enabled.'
: 'Auto retry is disabled.'
return (
<div className="grid w-90 gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
<FieldRoot name="autoRetry">
<FieldLabel className="flex items-center justify-between gap-4">
<span className="system-sm-medium text-text-secondary">Enable auto retry</span>
<Switch
size="lg"
checked={autoRetrySetting.data.enabled}
loading={updateAutoRetrySetting.isPending}
onCheckedChange={updateAutoRetrySetting.mutate}
/>
</FieldLabel>
<FieldDescription>Retry failed workflow runs without manual intervention.</FieldDescription>
</FieldRoot>
<div className="w-[340px] space-y-4 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
<div className="space-y-1">
<p className="text-sm font-medium text-text-primary">Mutation Loading Guard</p>
<p className="text-xs text-text-tertiary">
Click once to start a simulated mutate call. While the request is pending, the switch enters
{' '}
<code className="rounded-sm bg-state-base-hover px-1 py-0.5 text-[11px]">loading</code>
{' '}
and rejects duplicate clicks.
</p>
</div>
<span className="text-xs text-text-tertiary" aria-live="polite">
{statusText}
{' '}
Save attempts:
{' '}
{updateAutoRetrySetting.requestCount}
</span>
<div className="flex items-center justify-between rounded-xl border border-components-panel-border-subtle bg-background-default-dodge px-3 py-2 shadow-sm">
<div className="space-y-1">
<p className="text-sm font-medium text-text-primary">Enable Auto Retry</p>
<p className="text-xs text-text-tertiary">
{isPending ? 'Saving…' : enabled ? 'Saved as on' : 'Saved as off'}
</p>
</div>
<Switch
size="lg"
checked={enabled}
loading={isPending}
onCheckedChange={handleChange}
aria-label="Enable Auto Retry"
/>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-text-tertiary">
<div className="rounded-lg bg-state-base-hover px-3 py-2">
<div className="font-medium text-text-secondary">Committed Value</div>
<div>{enabled ? 'On' : 'Off'}</div>
</div>
<div className="rounded-lg bg-state-base-hover px-3 py-2">
<div className="font-medium text-text-secondary">Mutate Count</div>
<div>{requestCount}</div>
</div>
</div>
</div>
)
}
@ -353,7 +306,7 @@ export const MutationLoadingGuard: Story = {
parameters: {
docs: {
description: {
story: 'Controlled switch that enters loading while the change is saved.',
story: 'Simulates a controlled switch backed by an async mutate call. The component keeps its previous committed value, sets `loading` during the request, and blocks duplicate clicks until the mutation resolves.',
},
},
},
@ -362,19 +315,19 @@ export const MutationLoadingGuard: Story = {
const SkeletonDemo = () => (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<SwitchSkeleton size="xs" aria-hidden="true" />
<SwitchSkeleton size="xs" />
<span className="text-sm text-gray-700">Extra Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="sm" aria-hidden="true" />
<SwitchSkeleton size="sm" />
<span className="text-sm text-gray-700">Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="md" aria-hidden="true" />
<SwitchSkeleton size="md" />
<span className="text-sm text-gray-700">Regular skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="lg" aria-hidden="true" />
<SwitchSkeleton size="lg" />
<span className="text-sm text-gray-700">Large skeleton</span>
</div>
</div>
@ -385,7 +338,7 @@ export const Skeleton: Story = {
parameters: {
docs: {
description: {
story: 'Non-interactive placeholders for switch loading layouts.',
story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Exported from `@langgenius/dify-ui/switch` alongside `Switch`.',
},
},
},

View File

@ -45,34 +45,26 @@ const switchThumbVariants = cva(
export type SwitchSize = NonNullable<VariantProps<typeof switchRootVariants>['size']>
const switchSpinnerVariants = cva(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
{
variants: {
size: {
md: 'size-2 left-[calc(50%+6px)] group-data-checked:left-[calc(50%-6px)]',
lg: 'size-2.5 left-[calc(50%+8px)] group-data-checked:left-[calc(50%-8px)]',
},
},
const spinnerSizeConfig: Partial<Record<SwitchSize, {
icon: string
uncheckedPosition: string
checkedPosition: string
}>> = {
md: {
icon: 'size-2',
uncheckedPosition: 'left-[calc(50%+6px)]',
checkedPosition: 'left-[calc(50%-6px)]',
},
lg: {
icon: 'size-2.5',
uncheckedPosition: 'left-[calc(50%+8px)]',
checkedPosition: 'left-[calc(50%-8px)]',
},
)
type ControlledSwitchProps = {
checked: boolean
defaultChecked?: never
}
type UncontrolledSwitchProps = {
checked?: never
defaultChecked?: boolean
}
type SwitchControlProps = ControlledSwitchProps | UncontrolledSwitchProps
export type SwitchProps
= Omit<BaseSwitchNS.Root.Props, 'checked' | 'defaultChecked' | 'className' | 'size' | 'onCheckedChange'>
= Omit<BaseSwitchNS.Root.Props, 'className' | 'size' | 'onCheckedChange'>
& VariantProps<typeof switchRootVariants>
& SwitchControlProps
& {
onCheckedChange?: (checked: boolean) => void
loading?: boolean
@ -89,6 +81,7 @@ export function Switch({
...props
}: SwitchProps) {
const isDisabled = disabled || loading
const spinner = loading && size ? spinnerSizeConfig[size] : undefined
return (
<BaseSwitch.Root
@ -102,10 +95,14 @@ export function Switch({
<BaseSwitch.Thumb
className={switchThumbVariants({ size })}
/>
{loading && (size === 'md' || size === 'lg')
{spinner
? (
<span
className={switchSpinnerVariants({ size })}
className={cn(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
spinner.icon,
checked ? spinner.checkedPosition : spinner.uncheckedPosition,
)}
aria-hidden="true"
>
<i className="i-ri-loader-2-line size-full animate-spin text-text-tertiary motion-reduce:animate-none" />
@ -134,8 +131,11 @@ const switchSkeletonVariants = cva(
)
export type SwitchSkeletonProps
= HTMLAttributes<HTMLDivElement>
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
& VariantProps<typeof switchSkeletonVariants>
& {
className?: string
}
export function SwitchSkeleton({
size = 'md',

View File

@ -41,7 +41,6 @@ describe('@langgenius/dify-ui/toast', () => {
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60')
expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4')
expect(screen.getByText('Saved').element().closest('[class*="transition-opacity"]')).toHaveClass('motion-reduce:transition-none')
expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden')
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).toBeInTheDocument()

View File

@ -171,7 +171,7 @@ function ToastCard({
aria-hidden="true"
className={cn('absolute -inset-px bg-linear-to-r opacity-40', getToneGradientClasses(toastType))}
/>
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100 motion-reduce:transition-none">
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100">
<div className="flex shrink-0 items-center justify-center p-0.5">
<ToastIcon type={toastType} />
</div>

View File

@ -10,7 +10,11 @@ export default defineConfig({
tsconfigPaths: true,
},
optimizeDeps: {
include: ['@base-ui/react/form'],
include: [
'@base-ui/react/form',
'@base-ui/react/merge-props',
'@base-ui/react/use-render',
],
},
test: {
globals: true,

View File

@ -5,6 +5,7 @@ import type { AnnotationItem, AnnotationItemBasic } from './type'
import type { AnnotationReplyConfig } from '@/models/debug'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { RiEqualizer2Line } from '@remixicon/react'
@ -16,7 +17,6 @@ import ActionButton from '@/app/components/base/action-button'
import ConfigParamModal from '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal'
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import { APP_PAGE_LIMIT } from '@/config'
import { useProviderContext } from '@/context/provider-context'
@ -49,6 +49,7 @@ const Annotation: FC<Props> = (props) => {
const [limit, setLimit] = useState(APP_PAGE_LIMIT)
const [list, setList] = useState<AnnotationItem[]>([])
const [total, setTotal] = useState(0)
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const [isLoading, setIsLoading] = useState(false)
const [controlUpdateList, setControlUpdateList] = useState(() => Date.now())
const [currItem, setCurrItem] = useState<AnnotationItem | null>(null)
@ -217,11 +218,22 @@ const Annotation: FC<Props> = (props) => {
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
current={currPage}
onChange={setCurrPage}
total={total}
limit={limit}
onLimitChange={setLimit}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -20,12 +20,12 @@ import {
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { Pagination } from '@langgenius/dify-ui/pagination'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
import Pagination from '@/app/components/base/pagination'
import TabSlider from '@/app/components/base/tab-slider-plain'
import { APP_PAGE_LIMIT } from '@/config'
import useTimestamp from '@/hooks/use-timestamp'
@ -62,6 +62,7 @@ const ViewAnnotationModal: FC<Props> = ({
const { formatTime } = useTimestamp()
const [currPage, setCurrPage] = React.useState<number>(0)
const [total, setTotal] = useState(0)
const totalPages = total ? Math.max(Math.ceil(total / APP_PAGE_LIMIT), 1) : 1
const [hitHistoryList, setHitHistoryList] = useState<HitHistoryItem[]>([])
// Update local state when item prop changes (e.g., when modal is reopened with updated data)
@ -197,10 +198,15 @@ const ViewAnnotationModal: FC<Props> = ({
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
className="px-0"
current={currPage}
onChange={setCurrPage}
total={total}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -67,9 +67,11 @@ vi.mock('@/app/components/base/loading', () => ({
default: () => <div>loading-logs</div>,
}))
vi.mock('@/app/components/base/pagination', () => ({
default: ({ onChange }: { onChange: (page: number) => void }) => (
<button onClick={() => onChange(1)}>go-to-page-2</button>
vi.mock('@langgenius/dify-ui/pagination', () => ({
Pagination: ({ onPageChange }: { onPageChange: (page: number) => void }) => (
<div>
<button onClick={() => onPageChange(2)}>go-to-page-2</button>
</div>
),
}))

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { App } from '@/types/app'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import { omit } from 'es-toolkit/object'
@ -8,7 +9,6 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
@ -98,6 +98,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
})
const total = isChatMode ? chatConversations?.total : completionConversations?.total
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const handleQueryParamsChange = useCallback((next: QueryParam) => {
setCurrPage(0)
@ -130,11 +131,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
current={currPage}
onChange={handlePageChange}
total={total}
limit={limit}
onLimitChange={setLimit}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => handlePageChange(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -1,6 +1,7 @@
'use client'
import type { FC } from 'react'
import type { App } from '@/types/app'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
@ -11,7 +12,6 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import EmptyElement from '@/app/components/app/log/empty-element'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import { APP_PAGE_LIMIT } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useWorkflowLogs } from '@/service/use-log'
@ -59,6 +59,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
params: query,
})
const total = workflowLogs?.total
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
return (
<div className="flex h-full flex-col">
@ -76,11 +77,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
current={currPage}
onChange={setCurrPage}
total={total}
limit={limit}
onLimitChange={setLimit}
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
)
: null}

View File

@ -1,155 +0,0 @@
import { renderHook } from '@testing-library/react'
import usePagination from '../hook'
const defaultProps = {
currentPage: 0,
setCurrentPage: vi.fn(),
totalPages: 10,
edgePageCount: 2,
middlePagesSiblingCount: 1,
truncableText: '...',
truncableClassName: 'truncable',
}
describe('usePagination', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('pages', () => {
it('should generate correct pages array', () => {
const { result } = renderHook(() => usePagination(defaultProps))
expect(result.current.pages).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
})
it('should generate empty pages for totalPages 0', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 0 }))
expect(result.current.pages).toEqual([])
})
it('should generate single page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 1 }))
expect(result.current.pages).toEqual([1])
})
})
describe('hasPreviousPage / hasNextPage', () => {
it('should have no previous page on first page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
expect(result.current.hasPreviousPage).toBe(false)
})
it('should have previous page when not on first page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 3 }))
expect(result.current.hasPreviousPage).toBe(true)
})
it('should have next page when not on last page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
expect(result.current.hasNextPage).toBe(true)
})
it('should have no next page on last page', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 10 }))
expect(result.current.hasNextPage).toBe(false)
})
})
describe('middlePages', () => {
it('should return correct middle pages when at start', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
// isReachedToFirst: currentPage(0) <= middlePagesSiblingCount(1), so slice(0, 3)
expect(result.current.middlePages).toEqual([1, 2, 3])
})
it('should return correct middle pages when in the middle', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// Not at start or end, slice(5-1, 5+1+1) = slice(4, 7) = [5, 6, 7]
expect(result.current.middlePages).toEqual([5, 6, 7])
})
it('should return correct middle pages when at end', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
// isReachedToLast: currentPage(9) + middlePagesSiblingCount(1) >= totalPages(10), so slice(-3)
expect(result.current.middlePages).toEqual([8, 9, 10])
})
})
describe('previousPages and nextPages', () => {
it('should return empty previousPages when at start', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
expect(result.current.previousPages).toEqual([])
})
it('should return previousPages when in the middle', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// edgePageCount=2, so first 2 pages filtered by not in middlePages
expect(result.current.previousPages).toEqual([1, 2])
})
it('should return empty nextPages when at end', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
expect(result.current.nextPages).toEqual([])
})
it('should return nextPages when in the middle', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// Last 2 pages: [9, 10], filtered by not in middlePages [5,6,7]
expect(result.current.nextPages).toEqual([9, 10])
})
})
describe('truncation', () => {
it('should be previous truncable when middle pages are far from edge', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// previousPages=[1,2], middlePages=[5,6,7], 5 > 2+1 = true
expect(result.current.isPreviousTruncable).toBe(true)
})
it('should not be previous truncable when pages are contiguous', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 2 }))
expect(result.current.isPreviousTruncable).toBe(false)
})
it('should be next truncable when middle pages are far from end edge', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
// middlePages=[5,6,7], nextPages=[9,10], 7+1 < 9 = true
expect(result.current.isNextTruncable).toBe(true)
})
it('should not be next truncable when pages are contiguous', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 7 }))
expect(result.current.isNextTruncable).toBe(false)
})
})
describe('passthrough values', () => {
it('should pass through currentPage', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
expect(result.current.currentPage).toBe(5)
})
it('should pass through setCurrentPage', () => {
const setCurrentPage = vi.fn()
const { result } = renderHook(() => usePagination({ ...defaultProps, setCurrentPage }))
result.current.setCurrentPage(3)
expect(setCurrentPage).toHaveBeenCalledWith(3)
})
it('should pass through truncableText', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, truncableText: '…' }))
expect(result.current.truncableText).toBe('…')
})
it('should pass through truncableClassName', () => {
const { result } = renderHook(() => usePagination({ ...defaultProps, truncableClassName: 'custom-trunc' }))
expect(result.current.truncableClassName).toBe('custom-trunc')
})
it('should use default truncableText', () => {
const { currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount } = defaultProps
const { result } = renderHook(() => usePagination({ currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount }))
expect(result.current.truncableText).toBe('...')
})
})
})

View File

@ -1,444 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import CustomizedPagination from '../index'
describe('CustomizedPagination', () => {
const defaultProps = {
current: 0,
onChange: vi.fn(),
total: 100,
}
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<CustomizedPagination {...defaultProps} />)
expect(container)!.toBeInTheDocument()
})
it('should display current page and total pages', () => {
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} />)
// current + 1 = 1, totalPages = 10
// The page info display shows "1 / 10" and page buttons also show numbers
// current + 1 = 1, totalPages = 10
// The page info display shows "1 / 10" and page buttons also show numbers
expect(screen.getByText('/'))!.toBeInTheDocument()
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1)
})
it('should render prev and next buttons', () => {
render(<CustomizedPagination {...defaultProps} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(2)
})
it('should render page number buttons', () => {
render(<CustomizedPagination {...defaultProps} total={50} limit={10} />)
// 5 pages total, should see page numbers
// 5 pages total, should see page numbers
expect(screen.getByText('2'))!.toBeInTheDocument()
expect(screen.getByText('3'))!.toBeInTheDocument()
})
it('should display slash separator between current page and total', () => {
render(<CustomizedPagination {...defaultProps} />)
expect(screen.getByText('/'))!.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(<CustomizedPagination {...defaultProps} className="my-custom" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper)!.toHaveClass('my-custom')
})
it('should default limit to 10', () => {
render(<CustomizedPagination {...defaultProps} total={100} />)
// totalPages = 100 / 10 = 10, displayed in the page info area
expect(screen.getAllByText('10').length).toBeGreaterThanOrEqual(1)
})
it('should calculate total pages based on custom limit', () => {
render(<CustomizedPagination {...defaultProps} total={100} limit={25} />)
// totalPages = 100 / 25 = 4, displayed in the page info area
expect(screen.getAllByText('4').length).toBeGreaterThanOrEqual(1)
})
it('should disable prev button on first page', () => {
render(<CustomizedPagination {...defaultProps} current={0} />)
const buttons = screen.getAllByRole('button')
// First button is prev
// First button is prev
expect(buttons[0])!.toBeDisabled()
})
it('should disable next button on last page', () => {
render(<CustomizedPagination {...defaultProps} current={9} total={100} limit={10} />)
const buttons = screen.getAllByRole('button')
// Last button is next
// Last button is next
expect(buttons[buttons.length - 1])!.toBeDisabled()
})
it('should not render limit selector when onLimitChange is not provided', () => {
render(<CustomizedPagination {...defaultProps} />)
expect(screen.queryByText(/common\.pagination\.perPage/i)).not.toBeInTheDocument()
})
it('should render limit selector when onLimitChange is provided', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
// Should show limit options 10, 25, 50
// Should show limit options 10, 25, 50
expect(screen.getByText('25'))!.toBeInTheDocument()
expect(screen.getByText('50'))!.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onChange when next button is clicked', () => {
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
const buttons = screen.getAllByRole('button')
const nextButton = buttons[buttons.length - 1]
fireEvent.click(nextButton!)
expect(onChange).toHaveBeenCalledWith(1)
})
it('should call onChange when prev button is clicked', () => {
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0]!)
expect(onChange).toHaveBeenCalledWith(4)
})
it('should show input when page display is clicked', () => {
render(<CustomizedPagination {...defaultProps} />)
// Click the current page display (the div containing "1 / 10")
fireEvent.click(screen.getByText('/'))
// Input should appear
// Input should appear
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should navigate to entered page on Enter key', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '5' } })
fireEvent.keyDown(input, { key: 'Enter' })
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(4) // 0-indexed
})
it('should cancel input on Escape key', () => {
render(<CustomizedPagination {...defaultProps} current={0} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'Escape' })
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
// Input should be hidden and page display should return
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText('/'))!.toBeInTheDocument()
})
it('should confirm input on blur-sm', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '3' } })
fireEvent.blur(input)
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
})
it('should clamp page to max when input exceeds total pages', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '999' } })
fireEvent.keyDown(input, { key: 'Enter' })
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(9) // last page (0-indexed)
})
it('should clamp page to min when input is less than 1', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '0' } })
fireEvent.keyDown(input, { key: 'Enter' })
act(() => {
vi.advanceTimersByTime(500)
})
expect(onChange).toHaveBeenCalledWith(0)
})
it('should ignore non-numeric input and empty input', () => {
render(<CustomizedPagination {...defaultProps} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'abc' } })
expect(input)!.toHaveValue('')
fireEvent.change(input, { target: { value: '' } })
expect(input)!.toHaveValue('')
})
it('should show per page tip on hover and hide on leave', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
const container = screen.getByText('25').closest('.bg-components-segmented-control-bg-normal')!
fireEvent.mouseEnter(container)
// I18n mock returns ns.key
// I18n mock returns ns.key
expect(screen.getByText('common.pagination.perPage'))!.toBeInTheDocument()
fireEvent.mouseLeave(container)
expect(screen.queryByText('common.pagination.perPage')).not.toBeInTheDocument()
})
it('should call onLimitChange when limit option is clicked', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
fireEvent.click(screen.getByText('25'))
expect(onLimitChange).toHaveBeenCalledWith(25)
})
it('should call onLimitChange with 10 when 10 option is clicked', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
const container = screen.getByText('25').closest('.bg-components-segmented-control-bg-normal')!
const option10 = Array.from(container.children).find(el => el.textContent === '10')!
fireEvent.click(option10)
expect(onLimitChange).toHaveBeenCalledWith(10)
})
it('should call onLimitChange with 50 when 50 option is clicked', () => {
const onLimitChange = vi.fn()
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
fireEvent.click(screen.getByText('50'))
expect(onLimitChange).toHaveBeenCalledWith(50)
})
it('should call onChange when a page button is clicked', () => {
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={0} total={50} limit={10} onChange={onChange} />)
fireEvent.click(screen.getByText('3'))
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
})
it('should correctly select active limit style for 25 and 50', () => {
// Test limit 25
const { container: containerA } = render(<CustomizedPagination current={0} total={100} limit={25} onChange={vi.fn()} onLimitChange={vi.fn()} />)
const wrapper25 = Array.from(containerA.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '25')!
expect(wrapper25)!.toHaveClass('bg-components-segmented-control-item-active-bg')
// Test limit 50
const { container: containerB } = render(<CustomizedPagination current={0} total={100} limit={50} onChange={vi.fn()} onLimitChange={vi.fn()} />)
const wrapper50 = Array.from(containerB.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '50')!
expect(wrapper50)!.toHaveClass('bg-components-segmented-control-item-active-bg')
})
})
describe('Edge Cases', () => {
it('should handle total of 0', () => {
const { container } = render(<CustomizedPagination {...defaultProps} total={0} />)
expect(container)!.toBeInTheDocument()
})
it('should handle confirm when input value is unchanged (covers false branch of empty string check)', () => {
vi.useFakeTimers()
const onChange = vi.fn()
render(<CustomizedPagination {...defaultProps} current={4} onChange={onChange} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
// Blur without changing anything
fireEvent.blur(input)
act(() => {
vi.advanceTimersByTime(500)
})
// onChange should NOT be called
expect(onChange).not.toHaveBeenCalled()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should ignore other keys in handleInputKeyDown (covers false branch of Escape check)', () => {
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.keyDown(input, { key: 'a' })
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should trigger handleInputConfirm with empty string specifically on keydown Enter', async () => {
const { userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, '{Enter}')
// Wait for debounce 500ms
await new Promise(r => setTimeout(r, 600))
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should explicitly trigger Escape key logic in handleInputKeyDown', async () => {
const { userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
await user.type(input, '{Escape}')
// Wait for debounce 500ms
await new Promise(r => setTimeout(r, 600))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should handle single page', () => {
render(<CustomizedPagination {...defaultProps} total={5} limit={10} />)
// totalPages = 1, both buttons should be disabled
const buttons = screen.getAllByRole('button')
expect(buttons[0])!.toBeDisabled()
expect(buttons[buttons.length - 1])!.toBeDisabled()
})
it('should restore input value when blurred with empty value', () => {
render(<CustomizedPagination {...defaultProps} current={4} />)
fireEvent.click(screen.getByText('/'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
// Should close input without calling onChange, restoring to current + 1
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
})

View File

@ -1,549 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { Pagination } from '../pagination'
// Helper to render Pagination with common defaults
function renderPagination({
currentPage = 0,
totalPages = 10,
setCurrentPage = vi.fn(),
edgePageCount = 2,
middlePagesSiblingCount = 1,
truncableText = '...',
truncableClassName = 'truncable',
children,
}: {
currentPage?: number
totalPages?: number
setCurrentPage?: (page: number) => void
edgePageCount?: number
middlePagesSiblingCount?: number
truncableText?: string
truncableClassName?: string
children?: React.ReactNode
} = {}) {
return render(
<Pagination
currentPage={currentPage}
totalPages={totalPages}
setCurrentPage={setCurrentPage}
edgePageCount={edgePageCount}
middlePagesSiblingCount={middlePagesSiblingCount}
truncableText={truncableText}
truncableClassName={truncableClassName}
>
{children}
</Pagination>,
)
}
describe('Pagination', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderPagination()
expect(container).toBeInTheDocument()
})
it('should render children', () => {
renderPagination({ children: <span>child content</span> })
expect(screen.getByText(/child content/i)).toBeInTheDocument()
})
it('should apply className to wrapper div', () => {
const { container } = render(
<Pagination
currentPage={0}
totalPages={5}
setCurrentPage={vi.fn()}
edgePageCount={2}
middlePagesSiblingCount={1}
className="my-pagination"
>
<span>test</span>
</Pagination>,
)
expect(container.firstChild).toHaveClass('my-pagination')
})
it('should apply data-testid when provided', () => {
render(
<Pagination
currentPage={0}
totalPages={5}
setCurrentPage={vi.fn()}
edgePageCount={2}
middlePagesSiblingCount={1}
dataTestId="my-pagination"
>
<span>test</span>
</Pagination>,
)
expect(screen.getByTestId('my-pagination')).toBeInTheDocument()
})
})
describe('PrevButton', () => {
it('should render prev button', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i)).toBeInTheDocument()
})
it('should call setCurrentPage with previous page when clicked', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 3,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.click(screen.getByText(/prev/i))
expect(setCurrentPage).toHaveBeenCalledWith(2)
})
it('should not navigate below page 0', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.click(screen.getByText(/prev/i))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should be disabled on first page', () => {
renderPagination({
currentPage: 0,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
})
it('should navigate on Enter key press', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 3,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.keyDown(screen.getByText(/prev/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).toHaveBeenCalledWith(2)
})
it('should not navigate on Enter when disabled', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
setCurrentPage,
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
})
fireEvent.keyDown(screen.getByText(/prev/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should render with custom as element', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton as={<div />}>Prev</Pagination.PrevButton>,
})
expect(screen.getByText(/prev/i)).toBeInTheDocument()
})
it('should apply dataTestId', () => {
renderPagination({
currentPage: 3,
children: <Pagination.PrevButton dataTestId="prev-btn">Prev</Pagination.PrevButton>,
})
expect(screen.getByTestId('prev-btn')).toBeInTheDocument()
})
})
describe('NextButton', () => {
it('should render next button', () => {
renderPagination({
currentPage: 0,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
expect(screen.getByText(/next/i)).toBeInTheDocument()
})
it('should call setCurrentPage with next page when clicked', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.click(screen.getByText(/next/i))
expect(setCurrentPage).toHaveBeenCalledWith(1)
})
it('should not navigate beyond last page', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 9,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.click(screen.getByText(/next/i))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should be disabled on last page', () => {
renderPagination({
currentPage: 9,
totalPages: 10,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
})
it('should navigate on Enter key press', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.keyDown(screen.getByText(/next/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).toHaveBeenCalledWith(1)
})
it('should not navigate on Enter when disabled', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 9,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton>Next</Pagination.NextButton>,
})
fireEvent.keyDown(screen.getByText(/next/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should apply dataTestId', () => {
renderPagination({
currentPage: 0,
children: <Pagination.NextButton dataTestId="next-btn">Next</Pagination.NextButton>,
})
expect(screen.getByTestId('next-btn')).toBeInTheDocument()
})
})
describe('PageButton', () => {
it('should render page number buttons', () => {
renderPagination({
currentPage: 0,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should apply activeClassName to current page', () => {
renderPagination({
currentPage: 2,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
// current page is 2, so page 3 (1-indexed) should be active
expect(screen.getByText('3').closest('a')).toHaveClass('active')
})
it('should apply inactiveClassName to non-current pages', () => {
renderPagination({
currentPage: 2,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
expect(screen.getByText('1').closest('a')).toHaveClass('inactive')
})
it('should call setCurrentPage when a page button is clicked', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 5,
setCurrentPage,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
fireEvent.click(screen.getByText('3'))
expect(setCurrentPage).toHaveBeenCalledWith(2) // 0-indexed
})
it('should navigate on Enter key press on a page button', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 5,
setCurrentPage,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
fireEvent.keyDown(screen.getByText('4').closest('a')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
expect(setCurrentPage).toHaveBeenCalledWith(3) // 0-indexed
})
it('should render truncable text when pages are truncated', () => {
renderPagination({
currentPage: 5,
totalPages: 20,
edgePageCount: 2,
middlePagesSiblingCount: 1,
truncableText: '...',
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
/>
),
})
// With 20 pages and current at 5, there should be truncation
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1)
})
})
describe('Edge Cases', () => {
it('should handle single page', () => {
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 0,
totalPages: 1,
setCurrentPage,
children: (
<>
<Pagination.PrevButton>Prev</Pagination.PrevButton>
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
<Pagination.NextButton>Next</Pagination.NextButton>
</>
),
})
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle zero total pages', () => {
const { container } = renderPagination({
currentPage: 0,
totalPages: 0,
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
expect(container).toBeInTheDocument()
})
it('should cover undefined active/inactive dataTestIds', () => {
// Re-render PageButton without active/inactive data test ids to hit the undefined branch in cn() fallback
renderPagination({
currentPage: 1,
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
renderExtraProps={page => ({ 'aria-label': `Page ${page}` })}
/>
),
})
expect(screen.getByText('2')).toHaveAttribute('aria-label', 'Page 2')
})
it('should cover nextPages when edge pages fall perfectly into middle Pages', () => {
renderPagination({
currentPage: 5,
totalPages: 10,
edgePageCount: 8, // Very large edge page count to hit the filter(!middlePages.includes) branches
middlePagesSiblingCount: 1,
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('10')).toBeInTheDocument()
})
it('should hide truncation element if truncable is false', () => {
renderPagination({
currentPage: 2,
totalPages: 5,
edgePageCount: 1,
middlePagesSiblingCount: 1,
// When we are at page 2, middle pages are [2, 3, 4] (if 0-indexed, wait, currentPage is 0-indexed in hook?)
// Let's just render the component which calls the internal TruncableElement, when previous/next are NOT truncable
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
// Truncation only happens if middlePages > previousPages.last + 1
expect(screen.queryByText('...')).not.toBeInTheDocument()
})
it('should hit getAllPreviousPages with less than 1 element', () => {
renderPagination({
currentPage: 0,
totalPages: 10,
edgePageCount: 1,
middlePagesSiblingCount: 0,
children: <Pagination.PageButton className="btn" activeClassName="act" inactiveClassName="inact" />,
})
// With currentPage = 0, middlePages = [1], getAllPreviousPages() -> slice(0, 0) -> []
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should fire previous() keyboard event even if it does nothing without crashing', () => {
// Line 38: pagination.currentPage + 1 > 1 check is usually guarded by disabled, but we can verify it explicitly.
const setCurrentPage = vi.fn()
// Use a span so that 'disabled' attribute doesn't prevent fireEvent.click from firing
renderPagination({
currentPage: 0,
setCurrentPage,
children: <Pagination.PrevButton as={<span />}>Prev</Pagination.PrevButton>,
})
fireEvent.click(screen.getByText('Prev'))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should fire next() even if it does nothing without crashing', () => {
// Line 73: pagination.currentPage + 1 < pages.length verify
const setCurrentPage = vi.fn()
renderPagination({
currentPage: 10,
totalPages: 10,
setCurrentPage,
children: <Pagination.NextButton as={<span />}>Next</Pagination.NextButton>,
})
fireEvent.click(screen.getByText('Next'))
expect(setCurrentPage).not.toHaveBeenCalled()
})
it('should fall back to undefined when truncableClassName is empty', () => {
// Line 115: `<li className={truncableClassName || undefined}>{truncableText}</li>`
renderPagination({
currentPage: 5,
totalPages: 10,
truncableClassName: '',
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
// Should not have a class attribute
const truncableElements = screen.getAllByText('...')
expect(truncableElements[0]).not.toHaveAttribute('class')
})
it('should handle dataTestIdActive and dataTestIdInactive completely', () => {
// Lines 137-144
renderPagination({
currentPage: 1, // 0-indexed, so page 2 is active
totalPages: 5,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
dataTestIdActive="active-test-id"
dataTestIdInactive="inactive-test-id"
/>
),
})
const activeBtn = screen.getByTestId('active-test-id')
expect(activeBtn).toHaveTextContent('2')
const inactiveBtn = screen.getByTestId('inactive-test-id-1') // page 1
expect(inactiveBtn).toHaveTextContent('1')
})
it('should hit getAllNextPages.length < 1 in hook', () => {
renderPagination({
currentPage: 2,
totalPages: 3,
edgePageCount: 1,
middlePagesSiblingCount: 0,
children: (
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
),
})
// Current is 3 (index 2). middlePages = [3]. getAllNextPages = slice(3, 3) = []
// This will trigger the `getAllNextPages.length < 1` branch
expect(screen.getByText('3')).toBeInTheDocument()
})
it('should handle only dataTestIdInactive without dataTestIdActive', () => {
renderPagination({
currentPage: 1,
totalPages: 3,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
dataTestIdInactive="inactive-test-id"
/>
),
})
// Missing dataTestIdActive branch coverage on line 144
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle only dataTestIdActive without dataTestIdInactive', () => {
renderPagination({
currentPage: 1, // page 2 is active
totalPages: 3,
children: (
<Pagination.PageButton
className="page-btn"
activeClassName="active"
inactiveClassName="inactive"
dataTestIdActive="active-test-id"
/>
),
})
// This hits the branch where dataTestIdActive exists but not dataTestIdInactive
expect(screen.getByTestId('active-test-id')).toHaveTextContent('2')
expect(screen.queryByTestId('inactive-test-id-1')).not.toBeInTheDocument()
})
})
})

View File

@ -1,94 +0,0 @@
import type { IPaginationProps, IUsePagination } from './type'
import * as React from 'react'
import { useCallback } from 'react'
const usePagination = ({
currentPage,
setCurrentPage,
truncableText = '...',
truncableClassName = '',
totalPages,
edgePageCount,
middlePagesSiblingCount,
}: IPaginationProps): IUsePagination => {
const pages = React.useMemo(() => Array.from({ length: totalPages }, (_, i) => i + 1), [totalPages])
const hasPreviousPage = currentPage > 1
const hasNextPage = currentPage < totalPages
const isReachedToFirst = currentPage <= middlePagesSiblingCount
const isReachedToLast = currentPage + middlePagesSiblingCount >= totalPages
const middlePages = React.useMemo(() => {
const middlePageCount = middlePagesSiblingCount * 2 + 1
if (isReachedToFirst)
return pages.slice(0, middlePageCount)
if (isReachedToLast)
return pages.slice(-middlePageCount)
return pages.slice(
currentPage - middlePagesSiblingCount,
currentPage + middlePagesSiblingCount + 1,
)
}, [currentPage, isReachedToFirst, isReachedToLast, middlePagesSiblingCount, pages])
const getAllPreviousPages = useCallback(() => {
return pages.slice(0, middlePages[0]! - 1)
}, [middlePages, pages])
const previousPages = React.useMemo(() => {
if (isReachedToFirst || getAllPreviousPages().length < 1)
return []
return pages
.slice(0, edgePageCount)
.filter(p => !middlePages.includes(p))
}, [edgePageCount, getAllPreviousPages, isReachedToFirst, middlePages, pages])
const getAllNextPages = React.useMemo(() => {
return pages.slice(
middlePages[middlePages.length - 1],
pages[pages.length],
)
}, [pages, middlePages])
const nextPages = React.useMemo(() => {
if (isReachedToLast)
return []
if (getAllNextPages.length < 1)
return []
return pages
.slice(pages.length - edgePageCount, pages.length)
.filter(p => !middlePages.includes(p))
}, [edgePageCount, getAllNextPages.length, isReachedToLast, middlePages, pages])
const isPreviousTruncable = React.useMemo(() => {
// Is truncable if first value of middlePage is larger than last value of previousPages
return middlePages[0]! > previousPages[previousPages.length - 1]! + 1
}, [previousPages, middlePages])
const isNextTruncable = React.useMemo(() => {
// Is truncable if last value of middlePage is larger than first value of previousPages
return middlePages[middlePages.length - 1]! + 1 < nextPages[0]!
}, [nextPages, middlePages])
return {
currentPage,
setCurrentPage,
truncableText,
truncableClassName,
pages,
hasPreviousPage,
hasNextPage,
previousPages,
isPreviousTruncable,
middlePages,
isNextTruncable,
nextPages,
}
}
export default usePagination

View File

@ -1,81 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useMemo, useState } from 'react'
import Pagination from '.'
const TOTAL_ITEMS = 120
const PaginationDemo = ({
initialPage = 0,
initialLimit = 10,
}: {
initialPage?: number
initialLimit?: number
}) => {
const [current, setCurrent] = useState(initialPage)
const [limit, setLimit] = useState(initialLimit)
const pageSummary = useMemo(() => {
const start = current * limit + 1
const end = Math.min((current + 1) * limit, TOTAL_ITEMS)
return `${start}-${end} of ${TOTAL_ITEMS}`
}, [current, limit])
return (
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
<span>Log pagination</span>
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 font-medium text-text-secondary">
{pageSummary}
</span>
</div>
<Pagination
current={current}
total={TOTAL_ITEMS}
limit={limit}
onChange={setCurrent}
onLimitChange={(nextLimit) => {
setCurrent(0)
setLimit(nextLimit)
}}
/>
</div>
)
}
const meta = {
title: 'Base/Navigation/Pagination',
component: PaginationDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Paginate long lists with optional per-page selector. Demonstrates the inline page jump input and quick limit toggles.',
},
},
},
args: {
initialPage: 0,
initialLimit: 10,
},
argTypes: {
initialPage: {
control: { type: 'number', min: 0, max: 9, step: 1 },
},
initialLimit: {
control: { type: 'radio' },
options: [10, 25, 50],
},
},
tags: ['autodocs'],
} satisfies Meta<typeof PaginationDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const StartAtMiddle: Story = {
args: {
initialPage: 4,
},
}

View File

@ -1,201 +0,0 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowLeftLine, RiArrowRightLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { Pagination } from './pagination'
export type Props = {
className?: string
current: number
onChange: (cur: number) => void
total: number
limit?: number
onLimitChange?: (limit: number) => void
}
const CustomizedPagination: FC<Props> = ({
className,
current,
onChange,
total,
limit = 10,
onLimitChange,
}) => {
const { t } = useTranslation()
const totalPages = Math.ceil(total / limit)
const inputRef = React.useRef<HTMLDivElement>(null)
const [showInput, setShowInput] = React.useState(false)
const [inputValue, setInputValue] = React.useState<string | number>(current + 1)
const [showPerPageTip, setShowPerPageTip] = React.useState(false)
const { run: handlePaging } = useDebounceFn((value: string) => {
if (Number.parseInt(value) > totalPages) {
setInputValue(totalPages)
onChange(totalPages - 1)
setShowInput(false)
return
}
if (Number.parseInt(value) < 1) {
setInputValue(1)
onChange(0)
setShowInput(false)
return
}
onChange(Number.parseInt(value) - 1)
setInputValue(Number.parseInt(value))
setShowInput(false)
}, { wait: 500 })
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (!value)
return setInputValue('')
if (isNaN(Number.parseInt(value)))
return setInputValue('')
setInputValue(Number.parseInt(value))
}
const handleInputConfirm = () => {
if (inputValue !== '' && String(inputValue) !== String(current + 1)) {
handlePaging(String(inputValue))
return
}
if (inputValue === '')
setInputValue(current + 1)
setShowInput(false)
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleInputConfirm()
}
else if (e.key === 'Escape') {
e.preventDefault()
setInputValue(current + 1)
setShowInput(false)
}
}
const handleInputBlur = () => {
handleInputConfirm()
}
return (
<Pagination
className={cn('flex w-full items-center px-6 py-3 select-none', className)}
currentPage={current}
edgePageCount={2}
middlePagesSiblingCount={1}
setCurrentPage={onChange}
totalPages={totalPages}
truncableClassName="flex items-center justify-center w-8 px-1 py-2 system-sm-medium text-text-tertiary"
truncableText="..."
>
<div className="flex items-center gap-0.5 rounded-[10px] bg-background-section-burn p-0.5">
<Pagination.PrevButton
as={<div></div>}
disabled={current === 0}
>
<Button
variant="secondary"
className="size-7 px-1.5"
disabled={current === 0}
>
<RiArrowLeftLine className="size-4" />
</Button>
</Pagination.PrevButton>
{!showInput && (
<div
ref={inputRef}
className="flex items-center gap-0.5 rounded-lg px-2 py-1.5 hover:cursor-text hover:bg-state-base-hover-alt"
onClick={() => setShowInput(true)}
>
<div className="system-xs-medium text-text-secondary">{current + 1}</div>
<div className="system-xs-medium text-text-quaternary">/</div>
<div className="system-xs-medium text-text-secondary">{totalPages}</div>
</div>
)}
{showInput && (
<Input
styleCss={{
height: '28px',
width: `${inputRef.current?.clientWidth}px`,
}}
placeholder=""
autoFocus
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
/>
)}
<Pagination.NextButton
as={<div></div>}
disabled={current === totalPages - 1}
>
<Button
variant="secondary"
className="size-7 px-1.5"
disabled={current === totalPages - 1}
>
<RiArrowRightLine className="size-4" />
</Button>
</Pagination.NextButton>
</div>
<div className={cn('flex grow list-none items-center justify-center gap-1')}>
<Pagination.PageButton
className="flex min-w-8 cursor-pointer items-center justify-center rounded-lg px-1 py-2 system-sm-medium hover:bg-components-button-ghost-bg-hover"
activeClassName="bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover"
inactiveClassName="text-text-tertiary"
/>
</div>
{onLimitChange && (
<div className="flex shrink-0 items-center gap-2">
<div className="w-[51px] shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary">{showPerPageTip ? t('pagination.perPage', { ns: 'common' }) : ''}</div>
<div
className="flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5"
onMouseEnter={() => setShowPerPageTip(true)}
onMouseLeave={() => setShowPerPageTip(false)}
>
<div
className={cn(
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
limit === 10 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
)}
onClick={() => onLimitChange?.(10)}
>
10
</div>
<div
className={cn(
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
limit === 25 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
)}
onClick={() => onLimitChange?.(25)}
>
25
</div>
<div
className={cn(
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
limit === 50 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
)}
onClick={() => onLimitChange?.(50)}
>
50
</div>
</div>
</div>
)}
</Pagination>
)
}
export default CustomizedPagination

View File

@ -1,190 +0,0 @@
import type {
ButtonProps,
IPagination,
IPaginationProps,
PageButtonProps,
} from './type'
import { cn } from '@langgenius/dify-ui/cn'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import usePagination from './hook'
const defaultState: IPagination = {
currentPage: 0,
setCurrentPage: noop,
truncableText: '...',
truncableClassName: '',
pages: [],
hasPreviousPage: false,
hasNextPage: false,
previousPages: [],
isPreviousTruncable: false,
middlePages: [],
isNextTruncable: false,
nextPages: [],
}
const PaginationContext: React.Context<IPagination> = React.createContext<IPagination>(defaultState)
const PrevButton = ({
className,
children,
dataTestId,
as = <button type="button" />,
...buttonProps
}: ButtonProps) => {
const pagination = React.useContext(PaginationContext)
const previous = () => {
if (pagination.currentPage + 1 > 1)
pagination.setCurrentPage(pagination.currentPage - 1)
}
const disabled = pagination.currentPage === 0
return (
<as.type
{...buttonProps}
{...as.props}
className={cn(className, as.props.className)}
onClick={() => previous()}
tabIndex={disabled ? '-1' : 0}
disabled={disabled}
data-testid={dataTestId}
onKeyDown={(event: React.KeyboardEvent) => {
event.preventDefault()
if (event.key === 'Enter' && !disabled)
previous()
}}
>
{as.props.children ?? children}
</as.type>
)
}
const NextButton = ({
className,
children,
dataTestId,
as = <button type="button" />,
...buttonProps
}: ButtonProps) => {
const pagination = React.useContext(PaginationContext)
const next = () => {
if (pagination.currentPage + 1 < pagination.pages.length)
pagination.setCurrentPage(pagination.currentPage + 1)
}
const disabled = pagination.currentPage === pagination.pages.length - 1
return (
<as.type
{...buttonProps}
{...as.props}
className={cn(className, as.props.className)}
onClick={() => next()}
tabIndex={disabled ? '-1' : 0}
disabled={disabled}
data-testid={dataTestId}
onKeyDown={(event: React.KeyboardEvent) => {
event.preventDefault()
if (event.key === 'Enter' && !disabled)
next()
}}
>
{as.props.children ?? children}
</as.type>
)
}
type ITruncableElementProps = {
prev?: boolean
}
const TruncableElement = ({ prev }: ITruncableElementProps) => {
const pagination: IPagination = React.useContext(PaginationContext)
const {
isPreviousTruncable,
isNextTruncable,
truncableText,
truncableClassName,
} = pagination
return ((isPreviousTruncable && prev === true) || (isNextTruncable && !prev))
? (
<li className={truncableClassName || undefined}>{truncableText}</li>
)
: null
}
const PageButton = ({
as = <a />,
className,
dataTestIdActive,
dataTestIdInactive,
activeClassName,
inactiveClassName,
renderExtraProps,
}: PageButtonProps) => {
const pagination: IPagination = React.useContext(PaginationContext)
const renderPageButton = (page: number) => (
<li key={page}>
<as.type
data-testid={
cn({
[`${dataTestIdActive}`]:
dataTestIdActive && pagination.currentPage + 1 === page,
[`${dataTestIdInactive}-${page}`]:
dataTestIdActive && pagination.currentPage + 1 !== page,
}) || undefined
}
tabIndex={0}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === 'Enter')
pagination.setCurrentPage(page - 1)
}}
onClick={() => pagination.setCurrentPage(page - 1)}
className={cn(
className,
pagination.currentPage + 1 === page
? activeClassName
: inactiveClassName,
)}
{...as.props}
{...(renderExtraProps ? renderExtraProps(page) : {})}
>
{page}
</as.type>
</li>
)
return (
<>
{pagination.previousPages.map(renderPageButton)}
<TruncableElement prev />
{pagination.middlePages.map(renderPageButton)}
<TruncableElement />
{pagination.nextPages.map(renderPageButton)}
</>
)
}
export const Pagination = ({
dataTestId,
...paginationProps
}: IPaginationProps & { dataTestId?: string }) => {
const pagination = usePagination(paginationProps)
return (
<PaginationContext.Provider value={pagination}>
<div className={paginationProps.className} data-testid={dataTestId}>
{paginationProps.children}
</div>
</PaginationContext.Provider>
)
}
Pagination.PrevButton = PrevButton
Pagination.NextButton = NextButton
Pagination.PageButton = PageButton

View File

@ -1,64 +0,0 @@
import type { ButtonHTMLAttributes } from 'react'
type ElementProps = {
className?: string
children?: React.ReactNode
[key: string]: unknown
}
type IBasePaginationProps = {
currentPage: number
setCurrentPage: (page: number) => void
truncableText?: string
truncableClassName?: string
}
type IPaginationProps = IBasePaginationProps & {
totalPages: number
edgePageCount: number
middlePagesSiblingCount: number
className?: string
children?: React.ReactNode
}
type IUsePagination = IBasePaginationProps & {
pages: number[]
hasPreviousPage: boolean
hasNextPage: boolean
previousPages: number[]
isPreviousTruncable: boolean
middlePages: number[]
isNextTruncable: boolean
nextPages: number[]
}
type IPagination = IUsePagination & {
setCurrentPage: (page: number) => void
}
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
as?: React.ReactElement<ElementProps>
children?: string | React.ReactNode
className?: string
dataTestId?: string
}
type PageButtonProps = ButtonProps & {
/**
* Provide a custom ReactElement (e.g. Next/Link)
*/
as?: React.ReactElement<ElementProps>
activeClassName?: string
inactiveClassName?: string
dataTestIdActive?: string
dataTestIdInactive?: string
renderExtraProps?: (pageNum: number) => {}
}
export type {
ButtonProps,
IPagination,
IPaginationProps,
IUsePagination,
PageButtonProps,
}

View File

@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
import type { Props as PaginationProps } from '@/app/components/base/pagination'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen } from '@testing-library/react'
@ -9,6 +8,14 @@ import DocumentList from '../../list'
const mockPush = vi.fn()
type PaginationProps = {
current: number
onChange: (page: number) => void
total: number
limit?: number
onLimitChange?: (limit: number) => void
}
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,

View File

@ -1,12 +1,11 @@
'use client'
import type { Props as PaginationProps } from '@/app/components/base/pagination'
import type { SimpleDocumentDetail } from '@/models/datasets'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useBoolean } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Pagination from '@/app/components/base/pagination'
import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal'
import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata'
import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail'
@ -19,6 +18,15 @@ import RenameModal from './rename-modal'
type LocalDoc = SimpleDocumentDetail & { percent?: number }
type PaginationProps = {
className?: string
current: number
total: number
limit?: number
onChange: (page: number) => void
onLimitChange?: (limit: number) => void
}
type DocumentListProps = {
embeddingAvailable: boolean
documents: LocalDoc[]
@ -48,6 +56,8 @@ const DocumentList = ({
onSortChange,
}: DocumentListProps) => {
const { t } = useTranslation()
const pageSize = pagination.limit ?? 10
const totalPages = Math.max(Math.ceil(pagination.total / pageSize), 1)
const datasetConfig = useDatasetDetailContext(s => s.dataset)
const chunkingMode = datasetConfig?.doc_form
const isGeneralMode = chunkingMode !== ChunkingMode.parentChild
@ -198,8 +208,25 @@ const DocumentList = ({
{!!pagination.total && (
<Pagination
{...pagination}
className="w-full shrink-0"
className="shrink-0"
page={pagination.current + 1}
totalPages={totalPages}
onPageChange={page => pagination.onChange(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={pagination.onLimitChange
? {
value: pageSize,
options: [10, 25, 50],
onValueChange: pagination.onLimitChange,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}
: undefined}
/>
)}

View File

@ -203,18 +203,22 @@ vi.mock('@/app/components/base/divider', () => ({
default: () => <hr data-testid="divider" />,
}))
vi.mock('@/app/components/base/pagination', () => ({
default: ({ current, total, onChange, onLimitChange }: {
current: number
total: number
onChange: (page: number) => void
onLimitChange: (limit: number) => void
vi.mock('@langgenius/dify-ui/pagination', () => ({
Pagination: ({ page, totalPages, onPageChange, pageSize }: {
page: number
totalPages: number
onPageChange: (page: number) => void
pageSize?: {
onValueChange: (limit: number) => void
}
}) => (
<div data-testid="pagination">
<span data-testid="current-page">{current}</span>
<span data-testid="total-items">{total}</span>
<button data-testid="next-page" onClick={() => onChange(current + 1)}>Next</button>
<button data-testid="change-limit" onClick={() => onLimitChange(20)}>Change Limit</button>
<span data-testid="current-page">{page - 1}</span>
<span data-testid="total-pages">{totalPages}</span>
<button data-testid="next-page" onClick={() => onPageChange(page + 1)}>Next</button>
{pageSize && (
<button data-testid="change-limit" onClick={() => pageSize.onValueChange(20)}>Change Limit</button>
)}
</div>
),
}))
@ -1180,15 +1184,14 @@ describe('Inline callback and hook initialization coverage', () => {
})
})
// Covers paginationTotal in full-doc mode
it('should compute pagination total from child chunk data in full-doc mode', () => {
it('should compute pagination pages from child chunk data in full-doc mode', () => {
mockDocForm.current = ChunkingModeEnum.parentChild
mockParentMode.current = 'full-doc'
mockChildSegmentListData.total = 42
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
expect(screen.getByTestId('total-items'))!.toHaveTextContent('42')
expect(screen.getByTestId('total-pages'))!.toHaveTextContent('5')
})
// Covers search input change

View File

@ -3,10 +3,10 @@ import type { FC } from 'react'
import type { SegmentListContextValue } from './segment-list-context'
import type { SegmentImportStatus } from '@/types/dataset'
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Pagination from '@/app/components/base/pagination'
import {
useChunkListAllKey,
useChunkListDisabledKey,
@ -142,10 +142,11 @@ const Completed: FC<ICompletedProps> = ({
return childSegmentDataHook.childChunkListData?.total || 0
return segmentListDataHook.segmentListData?.total || 0
}, [segmentListDataHook.isFullDocMode, childSegmentDataHook.childChunkListData, segmentListDataHook.segmentListData])
const totalPages = Math.max(Math.ceil(paginationTotal / limit), 1)
// Handle page change
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page + 1)
setCurrentPage(page)
}, [])
// Context value
@ -225,12 +226,22 @@ const Completed: FC<ICompletedProps> = ({
{/* Pagination */}
<Divider type="horizontal" className="mx-6 my-0 h-px w-auto bg-divider-subtle" />
<Pagination
current={currentPage - 1}
onChange={handlePageChange}
total={paginationTotal}
limit={limit}
onLimitChange={setLimit}
className={segmentListDataHook.isFullDocMode ? 'px-3' : ''}
page={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
pageSize={{
value: limit,
options: [10, 25, 50],
onValueChange: setLimit,
label: t('pagination.perPage', { ns: 'common' }),
ariaLabel: t('pagination.perPage', { ns: 'common' }),
}}
/>
{/* Drawer Group - only render when docForm is available */}

View File

@ -18,6 +18,7 @@ import {
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { Pagination } from '@langgenius/dify-ui/pagination'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
@ -25,7 +26,6 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import FloatRightContainer from '@/app/components/base/float-right-container'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
import docStyle from '@/app/components/datasets/documents/detail/completed/style.module.css'
import DatasetDetailContext from '@/context/dataset-detail'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -63,6 +63,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
const { data: recordsRes, refetch: recordsRefetch, isLoading: isRecordsLoading } = useDatasetTestingRecords(datasetId, { limit, page: currPage + 1 })
const total = recordsRes?.total || 0
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
const { dataset: currentDataset } = useContext(DatasetDetailContext)
const isExternal = currentDataset?.provider === 'external'
@ -151,7 +152,19 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
<>
<Records records={recordsRes?.data} onClickRecord={handleClickRecord} />
{(total && total > limit)
? <Pagination current={currPage} onChange={setCurrPage} total={total} limit={limit} />
? (
<Pagination
page={currPage + 1}
totalPages={totalPages}
onPageChange={page => setCurrPage(page - 1)}
labels={{
previous: t('pagination.previous', { ns: 'common' }),
next: t('pagination.next', { ns: 'common' }),
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
}}
/>
)
: null}
</>
)}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "تصفح",
"imageInput.dropImageHere": "أسقط صورتك هنا، أو",
"imageInput.supportedFormats": "يدعم PNG و JPG و JPEG و WEBP و GIF",
"imageUploader.imageList": "قائمة الصور",
"imageUploader.imageUpload": "تحميل الصورة",
"imageUploader.pasteImageLink": "لصق رابط الصورة",
"imageUploader.pasteImageLinkInputPlaceholder": "لصق رابط الصورة هنا",
@ -512,6 +513,8 @@
"operation.ok": "موافق",
"operation.openInNewTab": "فتح في علامة تبويب جديدة",
"operation.params": "معلمات",
"operation.pause": "إيقاف مؤقت",
"operation.play": "تشغيل",
"operation.refresh": "إعادة تشغيل",
"operation.regenerate": "إعادة إنشاء",
"operation.reload": "إعادة تحميل",
@ -519,6 +522,7 @@
"operation.rename": "إعادة تسمية",
"operation.reset": "إعادة تعيين",
"operation.resetKeywords": "إعادة تعيين الكلمات الرئيسية",
"operation.retry": "إعادة المحاولة",
"operation.save": "حفظ",
"operation.saveAndEnable": "حفظ وتمكين",
"operation.saveAndRegenerate": "حفظ وإعادة إنشاء القطع الفرعية",
@ -533,13 +537,19 @@
"operation.skip": "تخطي",
"operation.submit": "إرسال",
"operation.sure": "أنا متأكد",
"operation.toggleFullscreen": "تبديل ملء الشاشة",
"operation.toggleMute": "تبديل كتم الصوت",
"operation.view": "عرض",
"operation.viewDetails": "عرض التفاصيل",
"operation.viewMore": "عرض المزيد",
"operation.yes": "نعم",
"operation.zoomIn": "تكبير",
"operation.zoomOut": "تصغير",
"pagination.editPageNumber": "تعديل رقم الصفحة، الصفحة الحالية {{page}} من {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "عناصر لكل صفحة",
"pagination.previous": "Previous page",
"placeholder.input": "يرجى الإدخال",
"placeholder.search": "بحث...",
"placeholder.select": "يرجى التحديد",
@ -677,5 +687,6 @@
"voiceInput.converting": "التحويل إلى نص...",
"voiceInput.notAllow": "الميكروفون غير مصرح به",
"voiceInput.speaking": "تحدث الآن...",
"voiceInput.start": "إدخال صوتي",
"you": "أنت"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "blättern",
"imageInput.dropImageHere": "Laden Sie Ihr Bild hierher hoch oder",
"imageInput.supportedFormats": "Unterstützt PNG, JPG, JPEG, WEBP und GIF",
"imageUploader.imageList": "Bilderliste",
"imageUploader.imageUpload": "Bild-Upload",
"imageUploader.pasteImageLink": "Bildlink einfügen",
"imageUploader.pasteImageLinkInputPlaceholder": "Bildlink hier einfügen",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "In neuem Tab öffnen",
"operation.params": "Parameter",
"operation.pause": "Pausieren",
"operation.play": "Abspielen",
"operation.refresh": "Neustart",
"operation.regenerate": "Erneuern",
"operation.reload": "Neu laden",
@ -519,6 +522,7 @@
"operation.rename": "Umbenennen",
"operation.reset": "Zurücksetzen",
"operation.resetKeywords": "Schlüsselwörter zurücksetzen",
"operation.retry": "Erneut versuchen",
"operation.save": "Speichern",
"operation.saveAndEnable": "Speichern und Aktivieren",
"operation.saveAndRegenerate": "Speichern und Regenerieren von untergeordneten Chunks",
@ -533,13 +537,19 @@
"operation.skip": "Schiff",
"operation.submit": "Senden",
"operation.sure": "Ich bin sicher",
"operation.toggleFullscreen": "Vollbild umschalten",
"operation.toggleMute": "Stummschaltung umschalten",
"operation.view": "Ansehen",
"operation.viewDetails": "Details anzeigen",
"operation.viewMore": "MEHR SEHEN",
"operation.yes": "Ja",
"operation.zoomIn": "Vergrößern",
"operation.zoomOut": "Verkleinern",
"pagination.editPageNumber": "Seitennummer bearbeiten, aktuelle Seite {{page}} von {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Artikel pro Seite",
"pagination.previous": "Previous page",
"placeholder.input": "Bitte eingeben",
"placeholder.search": "Suchen...",
"placeholder.select": "Bitte auswählen",
@ -677,5 +687,6 @@
"voiceInput.converting": "Umwandlung in Text...",
"voiceInput.notAllow": "Mikrofon nicht autorisiert",
"voiceInput.speaking": "Sprechen Sie jetzt...",
"voiceInput.start": "Spracheingabe",
"you": "Du"
}

View File

@ -545,7 +545,11 @@
"operation.yes": "Yes",
"operation.zoomIn": "Zoom In",
"operation.zoomOut": "Zoom Out",
"pagination.editPageNumber": "Edit page number, current page {{page}} of {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Items per page",
"pagination.previous": "Previous page",
"placeholder.input": "Please enter",
"placeholder.search": "Search...",
"placeholder.select": "Please select",

View File

@ -194,6 +194,7 @@
"imageInput.browse": "navegar",
"imageInput.dropImageHere": "Deja tu imagen aquí, o",
"imageInput.supportedFormats": "Soporta PNG, JPG, JPEG, WEBP y GIF",
"imageUploader.imageList": "Lista de imágenes",
"imageUploader.imageUpload": "Carga de Imagen",
"imageUploader.pasteImageLink": "Pegar enlace de imagen",
"imageUploader.pasteImageLinkInputPlaceholder": "Pega el enlace de imagen aquí",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Abrir en una nueva pestaña",
"operation.params": "Parámetros",
"operation.pause": "Pausar",
"operation.play": "Reproducir",
"operation.refresh": "Reiniciar",
"operation.regenerate": "Regenerar",
"operation.reload": "Recargar",
@ -519,6 +522,7 @@
"operation.rename": "Renombrar",
"operation.reset": "Restablecer",
"operation.resetKeywords": "Restablecer palabras clave",
"operation.retry": "Reintentar",
"operation.save": "Guardar",
"operation.saveAndEnable": "Guardar y habilitar",
"operation.saveAndRegenerate": "Guardar y regenerar fragmentos secundarios",
@ -533,13 +537,19 @@
"operation.skip": "Navío",
"operation.submit": "Enviar",
"operation.sure": "Estoy seguro",
"operation.toggleFullscreen": "Alternar pantalla completa",
"operation.toggleMute": "Alternar silencio",
"operation.view": "Vista",
"operation.viewDetails": "Ver detalles",
"operation.viewMore": "VER MÁS",
"operation.yes": "Sí",
"operation.zoomIn": "Acercar",
"operation.zoomOut": "Alejar",
"pagination.editPageNumber": "Editar número de página, página actual {{page}} de {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Elementos por página",
"pagination.previous": "Previous page",
"placeholder.input": "Por favor ingresa",
"placeholder.search": "Buscar...",
"placeholder.select": "Por favor selecciona",
@ -677,5 +687,6 @@
"voiceInput.converting": "Convirtiendo a texto...",
"voiceInput.notAllow": "micrófono no autorizado",
"voiceInput.speaking": "Habla ahora...",
"voiceInput.start": "Entrada de voz",
"you": "Tú"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "مرورگر",
"imageInput.dropImageHere": "عکس خود را اینجا رها کنید، یا",
"imageInput.supportedFormats": "از فرمت‌های PNG، JPG، JPEG، WEBP و GIF پشتیبانی می‌کند",
"imageUploader.imageList": "فهرست تصاویر",
"imageUploader.imageUpload": "بارگذاری تصویر",
"imageUploader.pasteImageLink": "پیوند تصویر را بچسبانید",
"imageUploader.pasteImageLinkInputPlaceholder": "پیوند تصویر را اینجا بچسبانید",
@ -512,6 +513,8 @@
"operation.ok": "تایید",
"operation.openInNewTab": "باز کردن در برگه جدید",
"operation.params": "پارامترها",
"operation.pause": "مکث",
"operation.play": "پخش",
"operation.refresh": "شروع مجدد",
"operation.regenerate": "بازسازی",
"operation.reload": "بارگذاری مجدد",
@ -519,6 +522,7 @@
"operation.rename": "تغییر نام",
"operation.reset": "بازنشانی",
"operation.resetKeywords": "بازنشانی کلمات کلیدی",
"operation.retry": "تلاش دوباره",
"operation.save": "ذخیره",
"operation.saveAndEnable": "ذخیره و فعال سازی",
"operation.saveAndRegenerate": "ذخیره و بازسازی تکه های فرزند",
@ -533,13 +537,19 @@
"operation.skip": "کشتی",
"operation.submit": "ارسال",
"operation.sure": "مطمئن هستم",
"operation.toggleFullscreen": "تغییر حالت تمام‌صفحه",
"operation.toggleMute": "تغییر حالت بی‌صدا",
"operation.view": "مشاهده",
"operation.viewDetails": "دیدن جزئیات",
"operation.viewMore": "بیشتر ببینید",
"operation.yes": "بله",
"operation.zoomIn": "بزرگنمایی",
"operation.zoomOut": "کوچک نمایی",
"pagination.editPageNumber": "ویرایش شماره صفحه، صفحه فعلی {{page}} از {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "موارد در هر صفحه",
"pagination.previous": "Previous page",
"placeholder.input": "لطفا وارد کنید",
"placeholder.search": "جستجو...",
"placeholder.select": "لطفا انتخاب کنید",
@ -677,5 +687,6 @@
"voiceInput.converting": "در حال تبدیل به متن...",
"voiceInput.notAllow": "میکروفون مجاز نیست",
"voiceInput.speaking": "اکنون صحبت کنید...",
"voiceInput.start": "ورودی صوتی",
"you": "تو"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "naviguer",
"imageInput.dropImageHere": "Déposez votre image ici, ou",
"imageInput.supportedFormats": "Prend en charge PNG, JPG, JPEG, WEBP et GIF",
"imageUploader.imageList": "Liste des images",
"imageUploader.imageUpload": "Téléchargement d'image",
"imageUploader.pasteImageLink": "Collez le lien de l'image",
"imageUploader.pasteImageLinkInputPlaceholder": "Collez le lien de l'image ici",
@ -512,6 +513,8 @@
"operation.ok": "D'accord",
"operation.openInNewTab": "Ouvrir dans un nouvel onglet",
"operation.params": "Paramètres",
"operation.pause": "Pause",
"operation.play": "Lire",
"operation.refresh": "Redémarrer",
"operation.regenerate": "Régénérer",
"operation.reload": "Recharger",
@ -519,6 +522,7 @@
"operation.rename": "Renommer",
"operation.reset": "Réinitialiser",
"operation.resetKeywords": "Réinitialiser les mots-clés",
"operation.retry": "Réessayer",
"operation.save": "Enregistrer",
"operation.saveAndEnable": "Enregistrer et Activer",
"operation.saveAndRegenerate": "Enregistrer et régénérer des morceaux enfants",
@ -533,13 +537,19 @@
"operation.skip": "Bateau",
"operation.submit": "Envoyer",
"operation.sure": "Je suis sûr",
"operation.toggleFullscreen": "Basculer en plein écran",
"operation.toggleMute": "Activer/désactiver le son",
"operation.view": "Vue",
"operation.viewDetails": "Voir les détails",
"operation.viewMore": "VOIR PLUS",
"operation.yes": "Oui",
"operation.zoomIn": "Zoom avant",
"operation.zoomOut": "Zoom arrière",
"pagination.editPageNumber": "Modifier le numéro de page, page actuelle {{page}} sur {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Articles par page",
"pagination.previous": "Previous page",
"placeholder.input": "Veuillez entrer",
"placeholder.search": "Rechercher...",
"placeholder.select": "Veuillez sélectionner",
@ -677,5 +687,6 @@
"voiceInput.converting": "Conversion en texte...",
"voiceInput.notAllow": "microphone non autorisé",
"voiceInput.speaking": "Parle maintenant...",
"voiceInput.start": "Saisie vocale",
"you": "Vous"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "ब्राउज़ करें",
"imageInput.dropImageHere": "अपनी छवि यहाँ छोड़ें, या",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP और GIF का समर्थन करता है",
"imageUploader.imageList": "छवि सूची",
"imageUploader.imageUpload": "छवि अपलोड",
"imageUploader.pasteImageLink": "छवि लिंक पेस्ट करें",
"imageUploader.pasteImageLinkInputPlaceholder": "छवि लिंक यहाँ पेस्ट करें",
@ -512,6 +513,8 @@
"operation.ok": "ठीक है",
"operation.openInNewTab": "नए टैब में खोलें",
"operation.params": "पैरामीटर",
"operation.pause": "रोकें",
"operation.play": "चलाएं",
"operation.refresh": "पुनः प्रारंभ करें",
"operation.regenerate": "पुनर्जन्म",
"operation.reload": "पुनः लोड करें",
@ -519,6 +522,7 @@
"operation.rename": "नाम बदलें",
"operation.reset": "रीसेट करें",
"operation.resetKeywords": "कीवर्ड रीसेट करें",
"operation.retry": "पुनः प्रयास करें",
"operation.save": "सहेजें",
"operation.saveAndEnable": "सहेजें और सक्षम करें",
"operation.saveAndRegenerate": "सहेजें और पुन: उत्पन्न करें बाल विखंडू",
@ -533,13 +537,19 @@
"operation.skip": "जहाज़",
"operation.submit": "जमा करें",
"operation.sure": "मुझे यकीन है",
"operation.toggleFullscreen": "फ़ुलस्क्रीन टॉगल करें",
"operation.toggleMute": "म्यूट टॉगल करें",
"operation.view": "देखना",
"operation.viewDetails": "विवरण देखें",
"operation.viewMore": "और देखें",
"operation.yes": "हाँ",
"operation.zoomIn": "ज़ूम इन करें",
"operation.zoomOut": "ज़ूम आउट करें",
"pagination.editPageNumber": "पृष्ठ संख्या संपादित करें, वर्तमान पृष्ठ {{page}} / {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "प्रति पृष्ठ आइटम",
"pagination.previous": "Previous page",
"placeholder.input": "कृपया दर्ज करें",
"placeholder.search": "खोजें...",
"placeholder.select": "कृपया चयन करें",
@ -677,5 +687,6 @@
"voiceInput.converting": "पाठ में परिवर्तित हो रहा है...",
"voiceInput.notAllow": "माइक्रोफोन अधिकृत नहीं है",
"voiceInput.speaking": "अब बोलें...",
"voiceInput.start": "वॉइस इनपुट",
"you": "आप"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "Telusuri",
"imageInput.dropImageHere": "Letakkan gambar Anda di sini, atau",
"imageInput.supportedFormats": "Mendukung PNG, JPG, JPEG, WEBP dan GIF",
"imageUploader.imageList": "Daftar gambar",
"imageUploader.imageUpload": "Unggah Gambar",
"imageUploader.pasteImageLink": "Tempel tautan gambar",
"imageUploader.pasteImageLinkInputPlaceholder": "Tempel tautan gambar di sini",
@ -512,6 +513,8 @@
"operation.ok": "OKE",
"operation.openInNewTab": "Buka di tab baru",
"operation.params": "Parameter",
"operation.pause": "Jeda",
"operation.play": "Putar",
"operation.refresh": "Segarkan",
"operation.regenerate": "Regenerasi",
"operation.reload": "Muat Ulang",
@ -519,6 +522,7 @@
"operation.rename": "Ubah nama",
"operation.reset": "Reset",
"operation.resetKeywords": "Atur ulang kata kunci",
"operation.retry": "Coba lagi",
"operation.save": "Simpan",
"operation.saveAndEnable": "Simpan & Aktifkan",
"operation.saveAndRegenerate": "Simpan & Buat Ulang Potongan Anak",
@ -533,13 +537,19 @@
"operation.skip": "Lewat",
"operation.submit": "Kirim",
"operation.sure": "Saya yakin",
"operation.toggleFullscreen": "Alihkan layar penuh",
"operation.toggleMute": "Alihkan bisu",
"operation.view": "Lihat",
"operation.viewDetails": "Lihat Detail",
"operation.viewMore": "LIHAT LEBIH BANYAK",
"operation.yes": "Ya",
"operation.zoomIn": "Perbesar",
"operation.zoomOut": "Perkecil",
"pagination.editPageNumber": "Edit nomor halaman, halaman saat ini {{page}} dari {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Item per halaman",
"pagination.previous": "Previous page",
"placeholder.input": "Silakan masuk",
"placeholder.search": "Cari...",
"placeholder.select": "Silakan pilih",
@ -677,5 +687,6 @@
"voiceInput.converting": "Mengonversi ke teks...",
"voiceInput.notAllow": "mikrofon tidak diizinkan",
"voiceInput.speaking": "Bicaralah sekarang...",
"voiceInput.start": "Input suara",
"you": "Kamu"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "sfogliare",
"imageInput.dropImageHere": "Trascina la tua immagine qui, oppure",
"imageInput.supportedFormats": "Supporta PNG, JPG, JPEG, WEBP e GIF",
"imageUploader.imageList": "Elenco immagini",
"imageUploader.imageUpload": "Caricamento Immagine",
"imageUploader.pasteImageLink": "Incolla link immagine",
"imageUploader.pasteImageLinkInputPlaceholder": "Incolla qui il link immagine",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Apri in una nuova scheda",
"operation.params": "Parametri",
"operation.pause": "Pausa",
"operation.play": "Riproduci",
"operation.refresh": "Riavvia",
"operation.regenerate": "Rigenerare",
"operation.reload": "Ricarica",
@ -519,6 +522,7 @@
"operation.rename": "Rinomina",
"operation.reset": "Reimposta",
"operation.resetKeywords": "Reimposta parole chiave",
"operation.retry": "Riprova",
"operation.save": "Salva",
"operation.saveAndEnable": "Salva & Abilita",
"operation.saveAndRegenerate": "Salva e rigenera i blocchi figlio",
@ -533,13 +537,19 @@
"operation.skip": "Nave",
"operation.submit": "Invia",
"operation.sure": "Sono sicuro",
"operation.toggleFullscreen": "Attiva/disattiva schermo intero",
"operation.toggleMute": "Attiva/disattiva muto",
"operation.view": "Vista",
"operation.viewDetails": "Visualizza dettagli",
"operation.viewMore": "SCOPRI DI PIÙ",
"operation.yes": "Sì",
"operation.zoomIn": "Ingrandisci",
"operation.zoomOut": "Zoom indietro",
"pagination.editPageNumber": "Modifica numero pagina, pagina corrente {{page}} di {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Articoli per pagina",
"pagination.previous": "Previous page",
"placeholder.input": "Per favore inserisci",
"placeholder.search": "Cerca...",
"placeholder.select": "Per favore seleziona",
@ -677,5 +687,6 @@
"voiceInput.converting": "Conversione in testo...",
"voiceInput.notAllow": "microfono non autorizzato",
"voiceInput.speaking": "Parla ora...",
"voiceInput.start": "Input vocale",
"you": "Tu"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "ブラウズする",
"imageInput.dropImageHere": "ここに画像をドロップするか、",
"imageInput.supportedFormats": "PNG、JPG、JPEG、WEBP、および GIF をサポートしています。",
"imageUploader.imageList": "画像リスト",
"imageUploader.imageUpload": "画像アップロード",
"imageUploader.pasteImageLink": "画像リンクを貼り付ける",
"imageUploader.pasteImageLinkInputPlaceholder": "ここに画像リンクを貼り付けてください",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "新しいタブで開く",
"operation.params": "パラメータ",
"operation.pause": "一時停止",
"operation.play": "再生",
"operation.refresh": "リフレッシュ",
"operation.regenerate": "再生成",
"operation.reload": "再読み込み",
@ -519,6 +522,7 @@
"operation.rename": "名前の変更",
"operation.reset": "リセット",
"operation.resetKeywords": "キーワードをリセット",
"operation.retry": "再試行",
"operation.save": "保存",
"operation.saveAndEnable": "保存 & 有効に",
"operation.saveAndRegenerate": "保存して子チャンクを再生成",
@ -533,13 +537,19 @@
"operation.skip": "スキップ",
"operation.submit": "送信",
"operation.sure": "確認済み",
"operation.toggleFullscreen": "全画面表示を切り替え",
"operation.toggleMute": "ミュートを切り替え",
"operation.view": "表示",
"operation.viewDetails": "詳細を見る",
"operation.viewMore": "さらに表示",
"operation.yes": "はい",
"operation.zoomIn": "ズームインする",
"operation.zoomOut": "ズームアウト",
"pagination.editPageNumber": "ページ番号を編集、現在のページ {{page}} / {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "ページあたりのアイテム数",
"pagination.previous": "Previous page",
"placeholder.input": "入力してください",
"placeholder.search": "検索...",
"placeholder.select": "選択してください",
@ -677,5 +687,6 @@
"voiceInput.converting": "テキストに変換中...",
"voiceInput.notAllow": "マイクが許可されていません",
"voiceInput.speaking": "今話しています...",
"voiceInput.start": "音声入力",
"you": "あなた"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "찾아보기",
"imageInput.dropImageHere": "여기에 이미지를 드롭하거나",
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP 및 GIF 를 지원합니다.",
"imageUploader.imageList": "이미지 목록",
"imageUploader.imageUpload": "이미지 업로드",
"imageUploader.pasteImageLink": "이미지 링크 붙여넣기",
"imageUploader.pasteImageLinkInputPlaceholder": "여기에 이미지 링크를 붙여넣으세요",
@ -512,6 +513,8 @@
"operation.ok": "확인",
"operation.openInNewTab": "새 탭에서 열기",
"operation.params": "매개변수",
"operation.pause": "일시 중지",
"operation.play": "재생",
"operation.refresh": "새로 고침",
"operation.regenerate": "재생성",
"operation.reload": "다시 불러오기",
@ -519,6 +522,7 @@
"operation.rename": "이름 바꾸기",
"operation.reset": "초기화",
"operation.resetKeywords": "키워드 재설정",
"operation.retry": "다시 시도",
"operation.save": "저장",
"operation.saveAndEnable": "저장 및 활성화",
"operation.saveAndRegenerate": "저장 및 자식 청크 재생성",
@ -533,13 +537,19 @@
"operation.skip": "건너뛰기",
"operation.submit": "전송",
"operation.sure": "확인",
"operation.toggleFullscreen": "전체 화면 전환",
"operation.toggleMute": "음소거 전환",
"operation.view": "보기",
"operation.viewDetails": "세부 정보보기",
"operation.viewMore": "더보기",
"operation.yes": "네",
"operation.zoomIn": "확대",
"operation.zoomOut": "축소",
"pagination.editPageNumber": "페이지 번호 편집, 현재 {{page}} / {{totalPages}} 페이지",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "페이지당 항목 수",
"pagination.previous": "Previous page",
"placeholder.input": "입력해주세요",
"placeholder.search": "검색...",
"placeholder.select": "선택해주세요",
@ -677,5 +687,6 @@
"voiceInput.converting": "텍스트로 변환 중...",
"voiceInput.notAllow": "마이크가 허용되지 않았습니다",
"voiceInput.speaking": "지금 말하고 있습니다...",
"voiceInput.start": "음성 입력",
"you": "나"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "browse",
"imageInput.dropImageHere": "Drop your image here, or",
"imageInput.supportedFormats": "Supports PNG, JPG, JPEG, WEBP and GIF",
"imageUploader.imageList": "Afbeeldingenlijst",
"imageUploader.imageUpload": "Image Upload",
"imageUploader.pasteImageLink": "Paste image link",
"imageUploader.pasteImageLinkInputPlaceholder": "Paste image link here",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Open in new tab",
"operation.params": "Params",
"operation.pause": "Pauzeren",
"operation.play": "Afspelen",
"operation.refresh": "Restart",
"operation.regenerate": "Regenerate",
"operation.reload": "Reload",
@ -519,6 +522,7 @@
"operation.rename": "Rename",
"operation.reset": "Reset",
"operation.resetKeywords": "Reset keywords",
"operation.retry": "Opnieuw proberen",
"operation.save": "Save",
"operation.saveAndEnable": "Save & Enable",
"operation.saveAndRegenerate": "Save & Regenerate Child Chunks",
@ -533,13 +537,19 @@
"operation.skip": "Skip",
"operation.submit": "Submit",
"operation.sure": "I'm sure",
"operation.toggleFullscreen": "Volledig scherm schakelen",
"operation.toggleMute": "Dempen schakelen",
"operation.view": "View",
"operation.viewDetails": "View Details",
"operation.viewMore": "VIEW MORE",
"operation.yes": "Yes",
"operation.zoomIn": "Zoom In",
"operation.zoomOut": "Zoom Out",
"pagination.editPageNumber": "Paginanummer bewerken, huidige pagina {{page}} van {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Items per page",
"pagination.previous": "Previous page",
"placeholder.input": "Please enter",
"placeholder.search": "Search...",
"placeholder.select": "Please select",
@ -677,5 +687,6 @@
"voiceInput.converting": "Converting to text...",
"voiceInput.notAllow": "microphone not authorized",
"voiceInput.speaking": "Speak now...",
"voiceInput.start": "Spraakinvoer",
"you": "You"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "przeglądaj",
"imageInput.dropImageHere": "Upuść swój obraz tutaj, lub",
"imageInput.supportedFormats": "Obsługuje PNG, JPG, JPEG, WEBP i GIF",
"imageUploader.imageList": "Lista obrazów",
"imageUploader.imageUpload": "Przesyłanie obrazu",
"imageUploader.pasteImageLink": "Wklej link do obrazu",
"imageUploader.pasteImageLinkInputPlaceholder": "Wklej tutaj link do obrazu",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Otwórz w nowej karcie",
"operation.params": "Parametry",
"operation.pause": "Pauza",
"operation.play": "Odtwórz",
"operation.refresh": "Odśwież",
"operation.regenerate": "Ponownie wygenerować",
"operation.reload": "Przeładuj",
@ -519,6 +522,7 @@
"operation.rename": "Zmień nazwę",
"operation.reset": "Resetuj",
"operation.resetKeywords": "Resetuj słowa kluczowe",
"operation.retry": "Spróbuj ponownie",
"operation.save": "Zapisz",
"operation.saveAndEnable": "Zapisz i Włącz",
"operation.saveAndRegenerate": "Zapisywanie i regeneracja fragmentów podrzędnych",
@ -533,13 +537,19 @@
"operation.skip": "Statek",
"operation.submit": "Prześlij",
"operation.sure": "Jestem pewien",
"operation.toggleFullscreen": "Przełącz pełny ekran",
"operation.toggleMute": "Przełącz wyciszenie",
"operation.view": "Widok",
"operation.viewDetails": "Wyświetl szczegóły",
"operation.viewMore": "ZOBACZ WIĘCEJ",
"operation.yes": "Tak",
"operation.zoomIn": "Powiększenie",
"operation.zoomOut": "Pomniejszanie",
"pagination.editPageNumber": "Edytuj numer strony, bieżąca strona {{page}} z {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Ilość elementów na stronie",
"pagination.previous": "Previous page",
"placeholder.input": "Proszę wprowadzić",
"placeholder.search": "Szukaj...",
"placeholder.select": "Proszę wybrać",
@ -677,5 +687,6 @@
"voiceInput.converting": "Konwertowanie na tekst...",
"voiceInput.notAllow": "mikrofon nieautoryzowany",
"voiceInput.speaking": "Mów teraz...",
"voiceInput.start": "Wprowadzanie głosowe",
"you": "Ty"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "navegar",
"imageInput.dropImageHere": "Arraste sua imagem aqui, ou",
"imageInput.supportedFormats": "Suporta PNG, JPG, JPEG, WEBP e GIF",
"imageUploader.imageList": "Lista de imagens",
"imageUploader.imageUpload": "Enviar Imagem",
"imageUploader.pasteImageLink": "Colar link da imagem",
"imageUploader.pasteImageLinkInputPlaceholder": "Cole o link da imagem aqui",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Abrir em nova guia",
"operation.params": "Parâmetros",
"operation.pause": "Pausar",
"operation.play": "Reproduzir",
"operation.refresh": "Reiniciar",
"operation.regenerate": "Regenerar",
"operation.reload": "Recarregar",
@ -519,6 +522,7 @@
"operation.rename": "Renomear",
"operation.reset": "Redefinir",
"operation.resetKeywords": "Redefinir palavras-chave",
"operation.retry": "Tentar novamente",
"operation.save": "Salvar",
"operation.saveAndEnable": "Salvar e Ativar",
"operation.saveAndRegenerate": "Salvar e regenerar pedaços filhos",
@ -533,13 +537,19 @@
"operation.skip": "Navio",
"operation.submit": "Enviar",
"operation.sure": "Tenho certeza",
"operation.toggleFullscreen": "Alternar tela cheia",
"operation.toggleMute": "Alternar mudo",
"operation.view": "Vista",
"operation.viewDetails": "Ver detalhes",
"operation.viewMore": "VER MAIS",
"operation.yes": "Sim",
"operation.zoomIn": "Ampliar",
"operation.zoomOut": "Diminuir o zoom",
"pagination.editPageNumber": "Editar número da página, página atual {{page}} de {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Itens por página",
"pagination.previous": "Previous page",
"placeholder.input": "Por favor, insira",
"placeholder.search": "Pesquisar...",
"placeholder.select": "Por favor, selecione",
@ -677,5 +687,6 @@
"voiceInput.converting": "Convertendo para texto...",
"voiceInput.notAllow": "microfone não autorizado",
"voiceInput.speaking": "Fale agora...",
"voiceInput.start": "Entrada por voz",
"you": "Você"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "naviga",
"imageInput.dropImageHere": "Trageți imaginea aici sau",
"imageInput.supportedFormats": "Suportă PNG, JPG, JPEG, WEBP și GIF",
"imageUploader.imageList": "Listă de imagini",
"imageUploader.imageUpload": "Încărcare imagine",
"imageUploader.pasteImageLink": "Inserați link-ul imaginii",
"imageUploader.pasteImageLinkInputPlaceholder": "Inserați link-ul imaginii aici",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Deschide într-o filă nouă",
"operation.params": "Parametri",
"operation.pause": "Pauză",
"operation.play": "Redare",
"operation.refresh": "Reîncarcă",
"operation.regenerate": "Regenera",
"operation.reload": "Reîncarcă",
@ -519,6 +522,7 @@
"operation.rename": "Redenumește",
"operation.reset": "Resetează",
"operation.resetKeywords": "Resetează cuvintele cheie",
"operation.retry": "Reîncercați",
"operation.save": "Salvează",
"operation.saveAndEnable": "Salvează și Activează",
"operation.saveAndRegenerate": "Salvați și regenerați bucățile secundare",
@ -533,13 +537,19 @@
"operation.skip": "Navă",
"operation.submit": "Prezinte",
"operation.sure": "Sunt sigur",
"operation.toggleFullscreen": "Comută ecran complet",
"operation.toggleMute": "Comută sunetul",
"operation.view": "Vedere",
"operation.viewDetails": "Vezi detalii",
"operation.viewMore": "VEZI MAI MULT",
"operation.yes": "Da",
"operation.zoomIn": "Măriți",
"operation.zoomOut": "Micșorare",
"pagination.editPageNumber": "Editați numărul paginii, pagina curentă {{page}} din {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Articole pe pagină",
"pagination.previous": "Previous page",
"placeholder.input": "Vă rugăm să introduceți",
"placeholder.search": "Caută...",
"placeholder.select": "Vă rugăm să selectați",
@ -677,5 +687,6 @@
"voiceInput.converting": "Se convertește la text...",
"voiceInput.notAllow": "microfonul nu este autorizat",
"voiceInput.speaking": "Vorbiți acum...",
"voiceInput.start": "Introducere vocală",
"you": "Tu"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "просмотр",
"imageInput.dropImageHere": "Перетащите ваше изображение сюда или",
"imageInput.supportedFormats": "Поддерживает PNG, JPG, JPEG, WEBP и GIF",
"imageUploader.imageList": "Список изображений",
"imageUploader.imageUpload": "Загрузка изображения",
"imageUploader.pasteImageLink": "Вставить ссылку на изображение",
"imageUploader.pasteImageLinkInputPlaceholder": "Вставьте ссылку на изображение здесь",
@ -512,6 +513,8 @@
"operation.ok": "ОК",
"operation.openInNewTab": "Открыть в новой вкладке",
"operation.params": "Параметры",
"operation.pause": "Пауза",
"operation.play": "Воспроизвести",
"operation.refresh": "Перезапустить",
"operation.regenerate": "Регенерировать",
"operation.reload": "Перезагрузить",
@ -519,6 +522,7 @@
"operation.rename": "Переименовать",
"operation.reset": "Сбросить",
"operation.resetKeywords": "Сбросить ключевые слова",
"operation.retry": "Повторить",
"operation.save": "Сохранить",
"operation.saveAndEnable": "Сохранить и включить",
"operation.saveAndRegenerate": "Сохранение и повторное создание дочерних блоков",
@ -533,13 +537,19 @@
"operation.skip": "Корабль",
"operation.submit": "Отправить",
"operation.sure": "Я уверен",
"operation.toggleFullscreen": "Переключить полноэкранный режим",
"operation.toggleMute": "Переключить звук",
"operation.view": "Вид",
"operation.viewDetails": "Подробнее",
"operation.viewMore": "ПОДРОБНЕЕ",
"operation.yes": "Да",
"operation.zoomIn": "Увеличить",
"operation.zoomOut": "Уменьшение масштаба",
"pagination.editPageNumber": "Изменить номер страницы, текущая страница {{page}} из {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Элементов на странице",
"pagination.previous": "Previous page",
"placeholder.input": "Пожалуйста, введите",
"placeholder.search": "Поиск...",
"placeholder.select": "Пожалуйста, выберите",
@ -677,5 +687,6 @@
"voiceInput.converting": "Преобразование в текст...",
"voiceInput.notAllow": "микрофон не авторизован",
"voiceInput.speaking": "Говорите сейчас...",
"voiceInput.start": "Голосовой ввод",
"you": "Ты"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "brskati",
"imageInput.dropImageHere": "Tukaj spustite svojo sliko ali",
"imageInput.supportedFormats": "Podpira PNG, JPG, JPEG, WEBP in GIF",
"imageUploader.imageList": "Seznam slik",
"imageUploader.imageUpload": "Nalaganje slik",
"imageUploader.pasteImageLink": "Prilepi povezavo do slike",
"imageUploader.pasteImageLinkInputPlaceholder": "Tukaj prilepi povezavo do slike",
@ -512,6 +513,8 @@
"operation.ok": "V redu",
"operation.openInNewTab": "Odpri v novem zavihku",
"operation.params": "Parametri",
"operation.pause": "Premor",
"operation.play": "Predvajaj",
"operation.refresh": "Osveži",
"operation.regenerate": "Regeneracijo",
"operation.reload": "Ponovno naloži",
@ -519,6 +522,7 @@
"operation.rename": "Preimenuj",
"operation.reset": "Ponastavi",
"operation.resetKeywords": "Ponastavi ključne besede",
"operation.retry": "Poskusi znova",
"operation.save": "Shrani",
"operation.saveAndEnable": "Shrani in omogoči",
"operation.saveAndRegenerate": "Shranite in regenerirajte otroške koščke",
@ -533,13 +537,19 @@
"operation.skip": "Ladja",
"operation.submit": "Predložiti",
"operation.sure": "Prepričan sem",
"operation.toggleFullscreen": "Preklopi celozaslonski način",
"operation.toggleMute": "Preklopi utišanje",
"operation.view": "Pogled",
"operation.viewDetails": "Poglej podrobnosti",
"operation.viewMore": "POGLEJ VEČ",
"operation.yes": "Da",
"operation.zoomIn": "Povečava",
"operation.zoomOut": "Pomanjšanje",
"pagination.editPageNumber": "Uredi številko strani, trenutna stran {{page}} od {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Elementi na stran",
"pagination.previous": "Previous page",
"placeholder.input": "Vnesite prosim",
"placeholder.search": "Išči...",
"placeholder.select": "Izberite prosim",
@ -677,5 +687,6 @@
"voiceInput.converting": "Pretvorba v besedilo ...",
"voiceInput.notAllow": "Mikrofon ni pooblaščen",
"voiceInput.speaking": "Spregovorite zdaj ...",
"voiceInput.start": "Glasovni vnos",
"you": "Ti"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "ท่องเว็บ",
"imageInput.dropImageHere": "วางภาพของคุณที่นี่ หรือ",
"imageInput.supportedFormats": "รองรับ PNG, JPG, JPEG, WEBP และ GIF",
"imageUploader.imageList": "รายการรูปภาพ",
"imageUploader.imageUpload": "อัปโหลดรูปภาพ",
"imageUploader.pasteImageLink": "วางลิงก์รูปภาพ",
"imageUploader.pasteImageLinkInputPlaceholder": "วางลิงค์รูปภาพที่นี่",
@ -512,6 +513,8 @@
"operation.ok": "ตกลง, ได้",
"operation.openInNewTab": "เปิดในแท็บใหม่",
"operation.params": "พารามิเตอร์",
"operation.pause": "หยุดชั่วคราว",
"operation.play": "เล่น",
"operation.refresh": "เริ่มใหม่",
"operation.regenerate": "สร้างใหม่",
"operation.reload": "โหลด",
@ -519,6 +522,7 @@
"operation.rename": "ตั้งชื่อใหม่",
"operation.reset": "รี เซ็ต",
"operation.resetKeywords": "รีเซ็ตคำสำคัญ",
"operation.retry": "ลองอีกครั้ง",
"operation.save": "ประหยัด",
"operation.saveAndEnable": "บันทึกและเปิดใช้งาน",
"operation.saveAndRegenerate": "บันทึกและสร้างก้อนย่อยใหม่",
@ -533,13 +537,19 @@
"operation.skip": "เรือ",
"operation.submit": "ส่ง",
"operation.sure": "ฉันแน่ใจ",
"operation.toggleFullscreen": "สลับเต็มหน้าจอ",
"operation.toggleMute": "สลับปิดเสียง",
"operation.view": "ทิวทัศน์",
"operation.viewDetails": "ดูรายละเอียด",
"operation.viewMore": "ดูเพิ่มเติม",
"operation.yes": "ใช่",
"operation.zoomIn": "ซูมเข้า",
"operation.zoomOut": "ซูมออก",
"pagination.editPageNumber": "แก้ไขหมายเลขหน้า หน้าปัจจุบัน {{page}} จาก {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "รายการต่อหน้า",
"pagination.previous": "Previous page",
"placeholder.input": "กรุณากรอก",
"placeholder.search": "ค้นหา...",
"placeholder.select": "กรุณาเลือก",
@ -677,5 +687,6 @@
"voiceInput.converting": "กําลังแปลงเป็นข้อความ...",
"voiceInput.notAllow": "ไม่ได้รับอนุญาตไมโครโฟน",
"voiceInput.speaking": "พูดเดี๋ยวนี้...",
"voiceInput.start": "ป้อนข้อมูลด้วยเสียง",
"you": "คุณ"
}

View File

@ -194,6 +194,7 @@
"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",
"imageUploader.imageList": "Görsel listesi",
"imageUploader.imageUpload": "Görüntü Yükleme",
"imageUploader.pasteImageLink": "Görüntü bağlantısını yapıştır",
"imageUploader.pasteImageLinkInputPlaceholder": "Görüntü bağlantısını buraya yapıştırın",
@ -512,6 +513,8 @@
"operation.ok": "Tamam",
"operation.openInNewTab": "Yeni sekmede aç",
"operation.params": "Parametreler",
"operation.pause": "Duraklat",
"operation.play": "Oynat",
"operation.refresh": "Yeniden Başlat",
"operation.regenerate": "Yeniden Oluştur",
"operation.reload": "Yeniden Yükle",
@ -519,6 +522,7 @@
"operation.rename": "Yeniden Adlandır",
"operation.reset": "Sıfırla",
"operation.resetKeywords": "Anahtar kelimeleri sıfırla",
"operation.retry": "Tekrar dene",
"operation.save": "Kaydet",
"operation.saveAndEnable": "Kaydet ve Etkinleştir",
"operation.saveAndRegenerate": "Alt Parçaları Kaydetme ve Yeniden Oluşturma",
@ -533,13 +537,19 @@
"operation.skip": "Atla",
"operation.submit": "Gönder",
"operation.sure": "Eminim",
"operation.toggleFullscreen": "Tam ekranı aç/kapat",
"operation.toggleMute": "Sessize al/aç",
"operation.view": "Görüntüle",
"operation.viewDetails": "Detayları Görüntüle",
"operation.viewMore": "DAHA FAZLA GÖSTER",
"operation.yes": "Evet",
"operation.zoomIn": "Yakınlaştırma",
"operation.zoomOut": "Uzaklaştırma",
"pagination.editPageNumber": "Sayfa numarasını düzenle, geçerli sayfa {{page}} / {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Sayfa başına öğe sayısı",
"pagination.previous": "Previous page",
"placeholder.input": "Lütfen girin",
"placeholder.search": "Ara...",
"placeholder.select": "Lütfen seçin",
@ -677,5 +687,6 @@
"voiceInput.converting": "Metne dönüştürülüyor...",
"voiceInput.notAllow": "mikrofon yetkilendirilmedi",
"voiceInput.speaking": "Şimdi konuş...",
"voiceInput.start": "Sesli giriş",
"you": "Sen"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "перегляд",
"imageInput.dropImageHere": "Перетягніть зображення сюди або",
"imageInput.supportedFormats": "Підтримує PNG, JPG, JPEG, WEBP і GIF",
"imageUploader.imageList": "Список зображень",
"imageUploader.imageUpload": "Завантаження зображення",
"imageUploader.pasteImageLink": "Вставити посилання на зображення",
"imageUploader.pasteImageLinkInputPlaceholder": "Вставте посилання на зображення тут",
@ -512,6 +513,8 @@
"operation.ok": "ОК",
"operation.openInNewTab": "Відкрити в новій вкладці",
"operation.params": "Параметри",
"operation.pause": "Пауза",
"operation.play": "Відтворити",
"operation.refresh": "Перезапустити",
"operation.regenerate": "Відновити",
"operation.reload": "Перезавантажити",
@ -519,6 +522,7 @@
"operation.rename": "Перейменувати",
"operation.reset": "Скинути",
"operation.resetKeywords": "Скинути ключові слова",
"operation.retry": "Повторити",
"operation.save": "Зберегти",
"operation.saveAndEnable": "Зберегти та Увімкнути",
"operation.saveAndRegenerate": "Збереження та регенерація дочірніх фрагментів",
@ -533,13 +537,19 @@
"operation.skip": "Корабель",
"operation.submit": "Представити",
"operation.sure": "Я впевнений",
"operation.toggleFullscreen": "Перемкнути повноекранний режим",
"operation.toggleMute": "Перемкнути звук",
"operation.view": "Вид",
"operation.viewDetails": "Перегляд докладних відомостей",
"operation.viewMore": "ДИВИТИСЬ БІЛЬШЕ",
"operation.yes": "Так",
"operation.zoomIn": "Збільшити масштаб",
"operation.zoomOut": "Зменшити масштаб",
"pagination.editPageNumber": "Редагувати номер сторінки, поточна сторінка {{page}} з {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Елементів на сторінці",
"pagination.previous": "Previous page",
"placeholder.input": "Будь ласка, введіть текст",
"placeholder.search": "Пошук...",
"placeholder.select": "Будь ласка, оберіть параметр",
@ -677,5 +687,6 @@
"voiceInput.converting": "Перетворення на текст...",
"voiceInput.notAllow": "мікрофон не авторизований",
"voiceInput.speaking": "Говоріть зараз...",
"voiceInput.start": "Голосове введення",
"you": "Ти"
}

View File

@ -194,6 +194,7 @@
"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",
"imageUploader.imageList": "Danh sách hình ảnh",
"imageUploader.imageUpload": "Tải ảnh lên",
"imageUploader.pasteImageLink": "Dán liên kết ảnh",
"imageUploader.pasteImageLinkInputPlaceholder": "Dán liên kết ảnh ở đây",
@ -512,6 +513,8 @@
"operation.ok": "OK",
"operation.openInNewTab": "Mở trong tab mới",
"operation.params": "Tham số",
"operation.pause": "Tạm dừng",
"operation.play": "Phát",
"operation.refresh": "Làm mới",
"operation.regenerate": "Tái tạo",
"operation.reload": "Tải lại",
@ -519,6 +522,7 @@
"operation.rename": "Đổi tên",
"operation.reset": "Đặt lại",
"operation.resetKeywords": "Đặt lại từ khóa",
"operation.retry": "Thử lại",
"operation.save": "Lưu",
"operation.saveAndEnable": "Lưu & Kích hoạt",
"operation.saveAndRegenerate": "Lưu và tạo lại các phần con",
@ -533,13 +537,19 @@
"operation.skip": "Tàu",
"operation.submit": "Trình",
"operation.sure": "Tôi chắc chắn",
"operation.toggleFullscreen": "Chuyển đổi toàn màn hình",
"operation.toggleMute": "Bật/tắt tiếng",
"operation.view": "Cảnh",
"operation.viewDetails": "Xem chi tiết",
"operation.viewMore": "XEM THÊM",
"operation.yes": "Vâng",
"operation.zoomIn": "Phóng to",
"operation.zoomOut": "Thu nhỏ",
"pagination.editPageNumber": "Chỉnh sửa số trang, trang hiện tại {{page}} trên {{totalPages}}",
"pagination.next": "Next page",
"pagination.pageNumber": "Page number",
"pagination.perPage": "Mục trên mỗi trang",
"pagination.previous": "Previous page",
"placeholder.input": "Vui lòng nhập",
"placeholder.search": "Tìm kiếm...",
"placeholder.select": "Vui lòng chọn",
@ -677,5 +687,6 @@
"voiceInput.converting": "Chuyển đổi thành văn bản...",
"voiceInput.notAllow": "micro không được ủy quyền",
"voiceInput.speaking": "Hãy nói...",
"voiceInput.start": "Nhập bằng giọng nói",
"you": "Bạn"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "浏览",
"imageInput.dropImageHere": "将图片拖放到此处,或",
"imageInput.supportedFormats": "支持 PNG、JPG、JPEG、WEBP 和 GIF 格式",
"imageUploader.imageList": "图片列表",
"imageUploader.imageUpload": "图片上传",
"imageUploader.pasteImageLink": "粘贴图片链接",
"imageUploader.pasteImageLinkInputPlaceholder": "将图像链接粘贴到此处",
@ -512,6 +513,8 @@
"operation.ok": "好的",
"operation.openInNewTab": "在新标签页打开",
"operation.params": "参数设置",
"operation.pause": "暂停",
"operation.play": "播放",
"operation.refresh": "重新开始",
"operation.regenerate": "重新生成",
"operation.reload": "刷新",
@ -519,6 +522,7 @@
"operation.rename": "重命名",
"operation.reset": "重置",
"operation.resetKeywords": "重置关键词",
"operation.retry": "重试",
"operation.save": "保存",
"operation.saveAndEnable": "保存并启用",
"operation.saveAndRegenerate": "保存并重新生成子分段",
@ -533,13 +537,19 @@
"operation.skip": "跳过",
"operation.submit": "提交",
"operation.sure": "我确定",
"operation.toggleFullscreen": "切换全屏",
"operation.toggleMute": "切换静音",
"operation.view": "查看",
"operation.viewDetails": "查看详情",
"operation.viewMore": "查看更多",
"operation.yes": "是",
"operation.zoomIn": "放大",
"operation.zoomOut": "缩小",
"pagination.editPageNumber": "编辑页码,当前第 {{page}} 页,共 {{totalPages}} 页",
"pagination.next": "下一页",
"pagination.pageNumber": "页码",
"pagination.perPage": "每页显示",
"pagination.previous": "上一页",
"placeholder.input": "请输入",
"placeholder.search": "搜索...",
"placeholder.select": "请选择",
@ -677,5 +687,6 @@
"voiceInput.converting": "正在转换为文本...",
"voiceInput.notAllow": "麦克风未授权",
"voiceInput.speaking": "现在讲...",
"voiceInput.start": "语音输入",
"you": "你"
}

View File

@ -194,6 +194,7 @@
"imageInput.browse": "瀏覽",
"imageInput.dropImageHere": "將您的圖片放在這裡,或",
"imageInput.supportedFormats": "支援 PNG、JPG、JPEG、WEBP 和 GIF",
"imageUploader.imageList": "圖片列表",
"imageUploader.imageUpload": "圖片上傳",
"imageUploader.pasteImageLink": "貼上圖片連結",
"imageUploader.pasteImageLinkInputPlaceholder": "將影象連結貼上到此處",
@ -512,6 +513,8 @@
"operation.ok": "好的",
"operation.openInNewTab": "在新選項卡中打開",
"operation.params": "引數設定",
"operation.pause": "暫停",
"operation.play": "播放",
"operation.refresh": "重新開始",
"operation.regenerate": "再生",
"operation.reload": "重新整理",
@ -519,6 +522,7 @@
"operation.rename": "重新命名",
"operation.reset": "重置",
"operation.resetKeywords": "重置關鍵字",
"operation.retry": "重試",
"operation.save": "儲存",
"operation.saveAndEnable": "儲存並啟用",
"operation.saveAndRegenerate": "保存並重新生成子塊",
@ -533,13 +537,19 @@
"operation.skip": "船",
"operation.submit": "提交",
"operation.sure": "我確定",
"operation.toggleFullscreen": "切換全螢幕",
"operation.toggleMute": "切換靜音",
"operation.view": "視圖",
"operation.viewDetails": "查看詳情",
"operation.viewMore": "查看更多",
"operation.yes": "是",
"operation.zoomIn": "放大",
"operation.zoomOut": "縮小",
"pagination.editPageNumber": "編輯頁碼,目前第 {{page}} 頁,共 {{totalPages}} 頁",
"pagination.next": "下一頁",
"pagination.pageNumber": "頁碼",
"pagination.perPage": "每頁項目數",
"pagination.previous": "上一頁",
"placeholder.input": "請輸入",
"placeholder.search": "搜尋...",
"placeholder.select": "請選擇",
@ -677,5 +687,6 @@
"voiceInput.converting": "正在轉換為文字...",
"voiceInput.notAllow": "麥克風未授權",
"voiceInput.speaking": "現在講...",
"voiceInput.start": "語音輸入",
"you": "你"
}