diff --git a/api/fields/dataset_fields.py b/api/fields/dataset_fields.py index b44e6a13650..f967e78d10a 100644 --- a/api/fields/dataset_fields.py +++ b/api/fields/dataset_fields.py @@ -162,9 +162,9 @@ class DatasetVectorSettingResponse(ResponseModel): class DatasetWeightedScoreResponse(ResponseModel): - weight_type: str | None - keyword_setting: DatasetKeywordSettingResponse | None - vector_setting: DatasetVectorSettingResponse | None + weight_type: str | None = None + keyword_setting: DatasetKeywordSettingResponse | None = None + vector_setting: DatasetVectorSettingResponse | None = None class DatasetRetrievalModelResponse(ResponseModel): diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 188e1a4a9f4..ab2949100f0 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -12085,9 +12085,9 @@ Condition detail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| keyword_setting | [DatasetKeywordSettingResponse](#datasetkeywordsettingresponse) | | Yes | -| vector_setting | [DatasetVectorSettingResponse](#datasetvectorsettingresponse) | | Yes | -| weight_type | string | | Yes | +| keyword_setting | [DatasetKeywordSettingResponse](#datasetkeywordsettingresponse) | | No | +| vector_setting | [DatasetVectorSettingResponse](#datasetvectorsettingresponse) | | No | +| weight_type | string | | No | #### DatasourceCredentialDeletePayload diff --git a/api/openapi/markdown/service-swagger.md b/api/openapi/markdown/service-swagger.md index 7f5591e32e9..2d0f63415bf 100644 --- a/api/openapi/markdown/service-swagger.md +++ b/api/openapi/markdown/service-swagger.md @@ -2574,9 +2574,9 @@ Condition detail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| keyword_setting | [DatasetKeywordSettingResponse](#datasetkeywordsettingresponse) | | Yes | -| vector_setting | [DatasetVectorSettingResponse](#datasetvectorsettingresponse) | | Yes | -| weight_type | string | | Yes | +| keyword_setting | [DatasetKeywordSettingResponse](#datasetkeywordsettingresponse) | | No | +| vector_setting | [DatasetVectorSettingResponse](#datasetvectorsettingresponse) | | No | +| weight_type | string | | No | #### DatasourceNodeRunPayload diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index 3de2260c421..d9f3270bd00 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -245,6 +245,55 @@ class TestDatasetList: assert status == 200 + def test_get_allows_legacy_weighted_score_without_weight_type(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + + current_user = self._mock_user() + datasets = [ + make_dataset( + retrieval_model={ + "search_method": "hybrid_search", + "reranking_enable": True, + "reranking_mode": "weighted_score", + "reranking_model": None, + "weights": { + "vector_setting": { + "vector_weight": 0.7, + "embedding_model_name": "text-embedding", + "embedding_provider_name": "openai", + }, + "keyword_setting": {"keyword_weight": 0.3}, + }, + "top_k": 3, + "score_threshold_enabled": False, + "score_threshold": 0.0, + } + ) + ] + + with app.test_request_context("/datasets"): + with ( + patch( + "controllers.console.datasets.datasets.current_account_with_tenant", + return_value=(current_user, "tenant-1"), + ), + patch.object( + DatasetService, + "get_datasets", + return_value=(datasets, 1), + ), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + ): + resp, status = method(api) + + assert status == 200 + assert resp["data"][0]["retrieval_model_dict"]["weights"]["weight_type"] is None + def test_embedding_available_false(self, app: Flask): api = DatasetListApi() method = unwrap(api.get) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 3cb9ce928db..954603ccf96 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index b0cafa0a663..e53acaa3292 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -709,9 +709,9 @@ export type DatasetRerankingModelResponse = { } export type DatasetWeightedScoreResponse = { - keyword_setting: DatasetKeywordSettingResponse - vector_setting: DatasetVectorSettingResponse - weight_type: string | null + keyword_setting?: DatasetKeywordSettingResponse + vector_setting?: DatasetVectorSettingResponse + weight_type?: string | null } export type DatasetRerankingModel = { diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 8f21000acea..9acd97cf49e 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -670,9 +670,9 @@ export const zDatasetVectorSettingResponse = z.object({ * DatasetWeightedScoreResponse */ export const zDatasetWeightedScoreResponse = z.object({ - keyword_setting: zDatasetKeywordSettingResponse, - vector_setting: zDatasetVectorSettingResponse, - weight_type: z.string().nullable(), + keyword_setting: zDatasetKeywordSettingResponse.optional(), + vector_setting: zDatasetVectorSettingResponse.optional(), + weight_type: z.string().nullish(), }) /** diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index 88145a0a6e9..aaf4d618f46 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -377,9 +377,9 @@ export type DatasetVectorSettingResponse = { } export type DatasetWeightedScoreResponse = { - keyword_setting: DatasetKeywordSettingResponse - vector_setting: DatasetVectorSettingResponse - weight_type: string | null + keyword_setting?: DatasetKeywordSettingResponse + vector_setting?: DatasetVectorSettingResponse + weight_type?: string | null } export type DatasourceNodeRunPayload = { diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index d1836978115..1b0b03f8cdd 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -354,9 +354,9 @@ export const zDatasetVectorSettingResponse = z.object({ * DatasetWeightedScoreResponse */ export const zDatasetWeightedScoreResponse = z.object({ - keyword_setting: zDatasetKeywordSettingResponse, - vector_setting: zDatasetVectorSettingResponse, - weight_type: z.string().nullable(), + keyword_setting: zDatasetKeywordSettingResponse.optional(), + vector_setting: zDatasetVectorSettingResponse.optional(), + weight_type: z.string().nullish(), }) /** diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 325454d4660..f3890dea078 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -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: diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index b18b8f3462e..75181b521ee 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -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" diff --git a/packages/dify-ui/src/pagination/__tests__/index.spec.tsx b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx new file mode 100644 index 00000000000..254b08c86e7 --- /dev/null +++ b/packages/dify-ui/src/pagination/__tests__/index.spec.tsx @@ -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( + + + + + + + + + + + , + ) + + 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( + , + ) + + 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( + `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( + , + ) + + 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( + , + ) + + 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( + +
    +
  1. + + Four + +
  2. +
+
, + ) + + 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() + + await expect.element(screen.getByTestId('skeleton')).toHaveAttribute('aria-hidden', 'true') + await expect.element(screen.getByTestId('skeleton')).toHaveClass('select-none') + }) +}) diff --git a/packages/dify-ui/src/pagination/index.stories.tsx b/packages/dify-ui/src/pagination/index.stories.tsx new file mode 100644 index 00000000000..53b5023bf84 --- /dev/null +++ b/packages/dify-ui/src/pagination/index.stories.tsx @@ -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 ( + + ) +} + +function PaginationDemo(props: ComponentProps) { + return ( +
+ +
+ ) +} + +function DesignSpecDemo() { + return ( +
+ + + + +
+ ) +} + +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 + +export default meta +type Story = StoryObj + +export const Playground: Story = { + render: () => , +} + +export const DesignSpec: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Pagination rows with default, hover-like, focused, page-size, and skeleton examples.', + }, + }, + }, +} + +export const Loading: Story = { + render: () => , +} diff --git a/packages/dify-ui/src/pagination/index.tsx b/packages/dify-ui/src/pagination/index.tsx new file mode 100644 index 00000000000..447f02fd9b9 --- /dev/null +++ b/packages/dify-ui/src/pagination/index.tsx @@ -0,0 +1,655 @@ +'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(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((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, + className, + ...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(() => ({ + 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': cn('flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', className), + 'children': ( + + {children} + + ), + } + + 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, + className, + ...props +}: PaginationContentProps) { + const defaultProps: useRender.ElementProps<'div'> = { + className: cn('grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2', className), + } + + 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 & { + 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 ( + { + props.onClick?.(event) + + if (!event.defaultPrevented && !disabled) + pagination.onPageChange(pagination.page - 1) + }} + > + {children ?? + ) +} + +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 ( + { + props.onClick?.(event) + + if (!event.defaultPrevented && !disabled) + pagination.onPageChange(pagination.page + 1) + }} + > + {children ?? + ) +} + +export type PaginationPageJumpProps = Omit & { + 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(null) + + if (!pagination.hasPages) + return null + + if (editing) { + return ( + + { + if (value !== null) + pagination.onPageChange(value) + + setEditing(false) + }} + > + + 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()) + } + }} + /> + + + + ) + } + + return ( + { + props.onClick?.(event) + + if (!event.defaultPrevented) + setEditing(true) + }} + > + {children ?? ( + <> + {pagination.page} + / + {pagination.totalPages} + + )} + + ) +} + +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 => ( +
  • + {typeof item === 'number' + ? + : } +
  • + )), + } + + return useRender({ + defaultTagName: 'ol', + render, + props: mergeProps<'ol'>(defaultProps, props), + }) +} + +export type PaginationPageProps = Omit & { + 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 ( + { + props.onClick?.(event) + + if (!event.defaultPrevented) + pagination.onPageChange(page) + }} + > + {children ?? page} + + ) +} + +export type PaginationEllipsisProps = useRender.ComponentProps<'span'> + +export function PaginationEllipsis({ + render, + className, + ...props +}: PaginationEllipsisProps) { + const defaultProps: useRender.ElementProps<'span'> = { + 'aria-hidden': true, + 'className': cn('flex size-8 items-center justify-center px-1 py-2 system-sm-medium text-text-tertiary', className), + 'children': '…', + } + + return useRender({ + defaultTagName: 'span', + render, + props: mergeProps<'span'>(defaultProps, props), + }) +} + +export type PaginationPageSizeProps = { + 'value': Value + 'options': readonly Value[] + 'onValueChange': (value: Value) => void + 'label'?: ReactNode + 'aria-label'?: string + 'className'?: string +} + +export function PaginationPageSize({ + value, + options, + onValueChange, + label = 'Items per page', + 'aria-label': ariaLabel = 'Items per page', + className, +}: PaginationPageSizeProps) { + return ( +
    +
    + {label} +
    + { + const [selectedValue] = nextValue + + if (!selectedValue) + return + + const selectedOption = options.find(option => String(option) === selectedValue) + + if (selectedOption !== undefined) + onValueChange(selectedOption) + }} + > + {options.map(option => ( + + {option} + + ))} + +
    + ) +} + +export type PaginationLabels = { + previous?: string + next?: string + editPageNumber?: (page: number, totalPages: number) => string + pageNumberInput?: string +} + +export type PaginationPageSizeConfig = { + value: Value + options: readonly Value[] + onValueChange: (value: Value) => void + label?: ReactNode + ariaLabel?: string +} + +export type PaginationProps = Omit & { + labels?: PaginationLabels + pageSize?: PaginationPageSizeConfig +} + +export function Pagination({ + labels, + pageSize, + page, + totalPages, + onPageChange, + ...props +}: PaginationProps) { + 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 ( + + + + + + + + + {pageSize && ( + + )} + + + ) +} + +export type PaginationSkeletonProps = useRender.ComponentProps<'div'> + +export function PaginationSkeleton({ + render, + className, + ...props +}: PaginationSkeletonProps) { + const defaultProps: useRender.ElementProps<'div'> = { + 'aria-hidden': true, + 'className': cn('flex w-full min-w-0 items-center justify-between px-6 py-3 select-none', className), + 'children': ( +
    +
    +
    +
    +
    +
    +
    + {range(1, 8).map(item => ( +
    + ))} +
    +
    +
    +
    +
    + ), + } + + return useRender({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, props), + }) +} diff --git a/packages/dify-ui/src/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx index ccdb13c61d5..09450f56afc 100644 --- a/packages/dify-ui/src/select/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/select/__tests__/index.spec.tsx @@ -207,6 +207,16 @@ 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', () => { diff --git a/packages/dify-ui/src/select/index.tsx b/packages/dify-ui/src/select/index.tsx index 3dd145be98b..c16d72af980 100644 --- a/packages/dify-ui/src/select/index.tsx +++ b/packages/dify-ui/src/select/index.tsx @@ -24,6 +24,7 @@ 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', diff --git a/packages/dify-ui/src/switch/__tests__/index.spec.tsx b/packages/dify-ui/src/switch/__tests__/index.spec.tsx index 56fe68d34b0..28aa8a655ce 100644 --- a/packages/dify-ui/src/switch/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/switch/__tests__/index.spec.tsx @@ -49,6 +49,19 @@ 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() + 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() @@ -142,6 +155,24 @@ 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() + 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() + + 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() expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument() diff --git a/packages/dify-ui/src/switch/index.stories.tsx b/packages/dify-ui/src/switch/index.stories.tsx index 4d47ef688e9..2156000bbc0 100644 --- a/packages/dify-ui/src/switch/index.stories.tsx +++ b/packages/dify-ui/src/switch/index.stories.tsx @@ -2,6 +2,11 @@ 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', @@ -10,7 +15,7 @@ const meta = { layout: 'centered', docs: { description: { - 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`.', + component: 'Toggle switch primitive with controlled and uncontrolled state support, loading state, and skeleton placeholder.', }, }, }, @@ -42,20 +47,27 @@ const meta = { export default meta type Story = StoryObj -const SwitchDemo = (args: Partial>) => { +type SwitchDemoProps = Partial, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & { + checked?: boolean +} + +const SwitchDemo = (args: SwitchDemoProps) => { const [enabled, setEnabled] = useState(args.checked ?? false) return ( -
    - - - {enabled ? 'On' : 'Off'} - -
    + + + Enable auto retry + + + + {enabled ? 'Failures will retry automatically.' : 'Failures require manual retry.'} + + ) } @@ -116,24 +128,24 @@ const AllStatesDemo = () => { {size}
    - {}} /> - {}} /> + {}} aria-label={`${size} unchecked switch`} /> + {}} aria-label={`${size} checked switch`} />
    - - + +
    - - + +
    - +
    ) @@ -234,7 +266,7 @@ export const Loading: Story = { parameters: { docs: { description: { - 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.', + story: 'Loading state disables interaction and shows a spinner for md and lg sizes.', }, }, }, @@ -242,61 +274,76 @@ export const Loading: Story = { const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) -const MutationLoadingDemo = () => { +function useMockAutoRetrySettingQuery() { 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 handleChange = (nextValue: boolean) => { + const mutate = (nextValue: boolean) => { if (isPending) return startTransition(async () => { setRequestCount(current => current + 1) await wait(1200) - setEnabled(nextValue) + onSuccess(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 ( -
    -
    -

    Mutation Loading Guard

    -

    - Click once to start a simulated mutate call. While the request is pending, the switch enters - {' '} - loading - {' '} - and rejects duplicate clicks. -

    -
    +
    + + + Enable auto retry + + + Retry failed workflow runs without manual intervention. + -
    -
    -

    Enable Auto Retry

    -

    - {isPending ? 'Saving…' : enabled ? 'Saved as on' : 'Saved as off'} -

    -
    - -
    - -
    -
    -
    Committed Value
    -
    {enabled ? 'On' : 'Off'}
    -
    -
    -
    Mutate Count
    -
    {requestCount}
    -
    -
    + + {statusText} + {' '} + Save attempts: + {' '} + {updateAutoRetrySetting.requestCount} +
    ) } @@ -306,7 +353,7 @@ export const MutationLoadingGuard: Story = { parameters: { docs: { description: { - 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.', + story: 'Controlled switch that enters loading while the change is saved.', }, }, }, @@ -315,19 +362,19 @@ export const MutationLoadingGuard: Story = { const SkeletonDemo = () => (
    - +
    - +
    - +
    - +
    @@ -338,7 +385,7 @@ export const Skeleton: Story = { parameters: { docs: { description: { - story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Exported from `@langgenius/dify-ui/switch` alongside `Switch`.', + story: 'Non-interactive placeholders for switch loading layouts.', }, }, }, diff --git a/packages/dify-ui/src/switch/index.tsx b/packages/dify-ui/src/switch/index.tsx index dd15ef6f797..8c4bb3e5712 100644 --- a/packages/dify-ui/src/switch/index.tsx +++ b/packages/dify-ui/src/switch/index.tsx @@ -45,26 +45,34 @@ const switchThumbVariants = cva( export type SwitchSize = NonNullable['size']> -const spinnerSizeConfig: Partial> = { - 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)]', +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)]', + }, + }, }, +) + +type ControlledSwitchProps = { + checked: boolean + defaultChecked?: never } +type UncontrolledSwitchProps = { + checked?: never + defaultChecked?: boolean +} + +type SwitchControlProps = ControlledSwitchProps | UncontrolledSwitchProps + export type SwitchProps - = Omit + = Omit & VariantProps + & SwitchControlProps & { onCheckedChange?: (checked: boolean) => void loading?: boolean @@ -81,7 +89,6 @@ export function Switch({ ...props }: SwitchProps) { const isDisabled = disabled || loading - const spinner = loading && size ? spinnerSizeConfig[size] : undefined return ( - {spinner + {loading && (size === 'md' || size === 'lg') ? (